<!DOCTYPE html>
<html>

<head>
    <link data-require="bootstrap@*" data-semver="3.2.0" rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.css" />
    <link data-require="bootstrap-css@~3.1.1" data-semver="3.1.1" rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.min.css" />
    <script data-require="angular.js@*" data-semver="1.3.0-rc2" src="https://code.angularjs.org/1.3.0-rc.2/angular.js"></script>
    <link rel="stylesheet" href="style.css" />
    <script src="app.js"></script>
    <script src="node.js"></script>
    <script src="edge.js"></script>
</head>

<body ng-app="app">
    <div class="container" ng-controller="AppCtrl as ctrl">
        <h1>Angular Graph</h1>

        <div class="row btn-row">
            <div class="btn-toolbar">
                <div class="btn-group">
                    <button class="btn disabled">{{ ctrl.nodes.length }} Nodes</button>
                    <button class="btn btn-primary" ng-click="ctrl.incrNodes()">+</button>
                    <button class="btn btn-info" ng-click="ctrl.decrNodes()">-</button>
                </div>
                <div class="btn-group">
                    <button class="btn disabled">{{ ctrl.edges.length }} Edges</button>
                    <button class="btn btn-primary" ng-click="ctrl.incrEdges()">+</button>
                    <button class="btn btn-info" ng-click="ctrl.decrEdges()">-</button>
                </div>
                <div class="btn-group">
                    <button class="btn btn-primary" ng-click="ctrl.randomize()">Randomize Edges</button>
                </div>
            </div>
        </div>

        <div class="row edge-container">
            <div class="col-md-8 col-sm-12">
                <div ng-repeat="node in ctrl.nodes" node="node"></div>
                <div ng-repeat="edge in ctrl.edges" edge from="edge.from" to="edge.to"></div>
            </div>
        </div>
    </div>
</body>

</html>
angular.module('app', ['app.node', 'app.edge'])

.controller('AppCtrl', function() {
    var ctrl = this;
    
    ctrl.nodes = genNodes(16);
    
    ctrl.edges = [
        { from: 1, to: 2 },
        { from: 1, to: 4 },
        { from: 2, to: 4 },
        { from: 3, to: 1 },
        { from: 4, to: 3 },
        { from: 1, to: 7 },
        { from: 1, to: 14 },
        { from: 15, to: 6 },
    ];
    
    ctrl.incrNodes = function() {
        ctrl.nodes = genNodes(ctrl.nodes.length * 2);
        ctrl.randomize();
    };
    
    ctrl.decrNodes = function() {
        ctrl.nodes = genNodes(ctrl.nodes.length / 2);
        ctrl.randomize();
    };
    
    ctrl.incrEdges = function() {
        ctrl.edges = genEdges(ctrl.edges.length * 2);
    };
    
    ctrl.decrEdges = function() {
        ctrl.edges = genEdges(ctrl.edges.length / 2);
    };
    
    // function to randomize edges
    ctrl.randomize = function() {
        ctrl.edges = genEdges(ctrl.edges.length);
    };
    
    function genEdges(count) {
        var edges = [];
        for(var i = 0; i < count; i++) {
            edges.push({
                from: randomNode(),
                to: randomNode()
            });
        }
        return edges
    }
    
    function randomNode() {
        return ctrl.nodes[Math.floor(Math.random() * ctrl.nodes.length)];
    }
    
    function genNodes(count) {
        var nodes = [];
        for(var i = 0; i < count; i++) {
            nodes.push(i + 1);
        }
        return nodes;
    }
});
.btn-row {
    margin: 10px;
}

[node] .panel {
    float: left;
    margin: 20px;
    cursor: pointer;
}

.edge-container {
    position: relative; /* necessary for absolute positioning of edges
                         * calculated from node offset values */
}

.edge {
    position: absolute;
    z-index: -100; /* stay in background, behind nodes etc. */
    pointer-events: none; /* forward through click events */
}

.edge.selected, .edge.hovered {
    z-index: 100; /* except when selected or hovered */
}

.edge.hovered svg,
.edge.reverse.hovered svg .left-arrow {
    stroke: #d6e9c6;
    fill: #d6e9c6;
}

.edge.selected svg,
.edge.reverse.selected svg .left-arrow {
    stroke: #428bca;
    fill: #428bca;
}

.edge svg,
.edge.reverse svg .left-arrow {
    stroke: #ddd;
    fill: #ddd;
}

.edge svg path {
    fill: none;
}

.edge svg .left-arrow,
.edge.reverse svg .right-arrow {
    stroke: none;
    fill: none;
}
`edge` directive draws directed edges (arrows) between arbitrary node directives.


`node` directive is simply a [bootstrap panel](http://getbootstrap.com/components/#panels)
that provides hooks for selection and hovering.


`edge` directive in turn watches those hooks and all incident edges highlight
when a node is either selected (clicked) or hovered over.


`edge` directive also watches element positions, assignment, and $window size
rendering the resulting graph dynamic and responsive.


Animations added for demo/debug purposes
<div class="panel panel-default"
ng-class="{ 'panel-primary': isSelected(), 'panel-success': isHovered()}"
ng-click="toggleSelected()" ng-mouseenter="enter()" ng-mouseleave="leave()">
    <div class="panel-body">Node: {{ id }}</div>
</div>
<div class="edge" ng-style="edgeCss"
ng-class="{ reverse: reverse, selected: selected, hovered: hovered }">
    <svg viewBox="0 0 100 50">
        <path d="M 0 0 Q 50 50, 100 0" fill="none"/>
        <polygon class="right-arrow" points="100 0, 100 5, 95, 0"/>
        <polygon class="left-arrow" points="0 0, 0 5, 5 0"/>
    </svg>
</div>
angular.module('app.edge', ['app.node'])

.directive('edge', function(Nodes, SelectedNode, HoveredNode, $window) {
    /** e.g.
     * <div edge from="from_node_id" to="to_node_id"></div>
     */
    return {
        scope: { from: '=', to: '='},
        link: function(scope, element) {
            var to, from;
            var aspectRatio = 2;
            
            scope.hovered = false;
            scope.selected = false;
            
            function isIncident(name) {
                return function(id) {
                    scope[name] = scope.to === id || scope.from === id;
                };
            }
            
            scope.$watch(idGetter(SelectedNode), isIncident('selected'));
            scope.$watch(idGetter(HoveredNode), isIncident('hovered'));
            
            scope.$watch(function() { return Nodes[scope.to]; },
            function(el) {
                to = el && el[0];
            });
            
            scope.$watch(function() { return Nodes[scope.from]; },
            function(el) {
                from = el && el[0];
            });
            
            scope.$watch(function() { return to && to.offsetTop; }, draw);
            scope.$watch(function() { return to && to.offsetLeft; }, draw);
            scope.$watch(function() { return from && from.offsetTop; }, draw);
            scope.$watch(function() { return from && from.offsetLeft; }, draw);
            // still not perfect $watching, some redundant calls
            
            angular.element($window).bind('resize', function() {
                scope.$apply(draw);
            });
            
            function draw() {
                if(!to || !from) { return; } // abort if missing an endpoint
                
                var left = positionHelper(from);
                var right = positionHelper(to);
                // Fix left / right positioning (naming) if needed
                if(left.left > right.left) {
                    var tmp = left;
                    left = right;
                    right = tmp;
                    scope.reverse = true;
                } else {
                    scope.reverse = false;
                }
                
                var css = {};
                var pos = {};
                pos.top = left.bottom;
                pos.left = left.middle;
                // Calculate positioning and width, rotate if needed
                if(left.bottom === right.bottom) {
                    pos.width = right.middle - left.middle;
                }
                else {
                    // Need to rotate and calculate euclidean distance for width
                    var dest = {};
                    if(left.bottom > right.bottom) {
                        dest.x = right.middle;
                        dest.y = right.bottom;
                        
                        if(right.left < left.right) {
                            pos.top = left.top;
                            pos.left = left.middle;
                        }
                        else {
                            pos.top = left.v_middle;
                            pos.left = left.right;
                        }
                    }
                    else {
                        if(right.left < left.right) {
                            dest.x = right.middle;
                            dest.y = right.top;
                        } else {
                            dest.x = right.left;
                            dest.y = right.v_middle;
                        }
                    }
                    var line = getPolarLine(pos.left, pos.top, dest.x, dest.y);
                    pos.width = line.radius;
                    prefixHelper('transform', 'rotate(' + line.angle + 'deg)', css);
                    prefixHelper('transform-origin', 'top left', css);
                    // Note: transition for debug/demo only, not really needed
                    prefixHelper('transition', 'transform 1s', css, true);
                }
                
                // Fix height to width to maintain aspect ratio of svg
                pos.height = Math.floor(pos.width / aspectRatio);
                
                // Set CSS values and update scope variable
                css.top = pos.top + 'px';
                css.left = pos.left + 'px';
                css.width = pos.width + 'px';
                css.height = pos.height + 'px';
                
                scope.edgeCss = css;
            }
        },
        templateUrl: 'edge.html'
    };
    
    function getPolarLine(x1, y1, x2, y2) {
        return {
            radius: Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2)),
            angle: (Math.atan2(y2 - y1, x2 - x1) * 180 / Math.PI)
        };
    }
    
    function positionHelper(domEl) {
        /**
         * @param domEl: DOM element
         * @return position object of the input element:
         *      { top, bottom, v_middle, left, right, middle }
         */
         var top = domEl.offsetTop;
         var left = domEl.offsetLeft;
         var width = domEl.offsetWidth;
         var height = domEl.offsetHeight;
         
         return {
             top: top,
             bottom: (top + height),
             v_middle: (top + height/2),
             
             left: left,
             right: (left + width),
             middle: (left + width/2)
         };
    }
    
    function prefixHelper(name, value, dest, prefixValue) {
        /**
         * @param name : string suffix of property to prefix e.g. transform
         * @param value: value to set prefixed in destination object
         * @param dest : destination object
         * @param prefixValue: if truthy will also apply prefixes to each value
         */
        if(!dest) { return; }
        
        var prefixes = ['-webkit-', '-moz-', '-ms-', '-o-', ''];
        
        for(var i = 0; i < prefixes.length; i++) {
            if(prefixValue) {
                dest[ prefixes[i] + name ] = prefixes[i] + value;
            }
            else {
                dest[ prefixes[i] + name ] = value;
            }
        }
    }
    
    function idGetter(obj) {
        return function() {
            return obj && obj.id;
        };
    }
});
angular.module('app.node', [])

.value('Nodes', {}) // map of node id to jqlite element

.value('SelectedNode', { id: '' })  // id of node selected

.value('HoveredNode', { id: '' }) // id of node in focus

.directive('node', function(Nodes, SelectedNode, HoveredNode) {
    /** e.g.
     * <div node="some_unique_id"></div>
     */
    return {
        scope: { id: '=node' },
        link: function(scope, element) {
            Nodes[scope.id] = element.children();
            
            // functionality for hovering
            scope.enter = function() {
                HoveredNode.id = scope.id;
            };
            scope.leave = function() {
                HoveredNode.id = '';
            };
            scope.isHovered = function() {
                return HoveredNode.id === scope.id && !scope.isSelected();
                // note: selection trumps hover
            };
            
            // functionality for clicking
            scope.isSelected = function() {
                return SelectedNode.id === scope.id;
            };
            scope.toggleSelected = function() {
                if(scope.isSelected()) {
                    SelectedNode.id = '';
                }
                else {
                    SelectedNode.id = scope.id;
                }
            };
        },
        templateUrl: 'node.html'
    };
});