<!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'
};
});