ngraph.svg
Version:
Svg based graph rendering
377 lines (295 loc) • 9.54 kB
JavaScript
var svg = require('simplesvg');
var hammer = require('hammerjs');
var MOVE_EVENTS = 'panstart panmove panend';
module.exports = ngraphSvg;
ngraphSvg.svg = svg; // let consumers use this directly
function ngraphSvg(graph, settings) {
settings = settings || {};
var layout = require('./lib/defaultLayout.js')(graph, settings);
var scrollSpeed = typeof settings.scrollSpeed === 'number' ?
settings.scrollSpeed : 0.1;
var container = settings.container || document.body;
var isStable = false;
var disposed = false;
var sceneInitialized = false;
var svgRoot = createSvgRoot(container);
var sceneRoot = createSceneRoot(svgRoot);
var sceneTransform = createSceneTransform(sceneRoot);
var panSession = {};
var panNode = 0;
var linkLayer = addLayer('links', sceneRoot);
var nodeLayer = addLayer('nodes', sceneRoot);
var nodes = Object.create(null);
var links = Object.create(null);
var fromX = 0, fromY = 0;
var screenPinchX, screenPinchY, lastScale;
var cachedPos = { x: 0, y: 0 },
cachedFromPos = { x: 0, y: 0 },
cachedToPos = { x: 0, y: 0 };
var defaultUI = require('./lib/defaultUI.js');
var nodeBuilder = defaultUI.nodeBuilder,
nodePositionCallback = defaultUI.nodePositionCallback,
linkBuilder = defaultUI.linkBuilder,
linkPositionCallback = defaultUI.linkPositionCallback;
var currentTransform = {
tx : 0,
ty : 0,
scale: 1
};
var api = {
run: animationLoop,
renderOneFrame: renderOneFrame,
layout: layout,
dispose: dispose,
node: setNodeBuilder,
link: setLinkBuilder,
placeNode: placeNode,
placeLink: placeLink,
svgRoot: svgRoot,
resetStable: resetStable
};
require('ngraph.events')(api);
return api;
function dispose() {
layout.dispose();
api.off();
disposed = true;
listenToGraphEvents(false);
releaseDOMEvents();
}
function placeLink(newPlaceLinkCallback) {
linkPositionCallback = newPlaceLinkCallback;
return api;
}
function placeNode(newPlaceCallback) {
nodePositionCallback = newPlaceCallback;
return api;
}
function animationLoop() {
if (disposed) return;
requestAnimationFrame(animationLoop);
if (!isStable) {
layout.step();
renderOneFrame();
}
}
function renderOneFrame() {
if (disposed) return;
if (!sceneInitialized) initializeScene();
for (var nodeId in nodes) {
var nodeInfo = nodes[nodeId];
cachedPos.x = nodeInfo.pos.x;
cachedPos.y = nodeInfo.pos.y;
nodePositionCallback(nodeInfo.ui, cachedPos, nodeInfo.model);
}
for (var linkId in links) {
var linkInfo = links[linkId];
cachedFromPos.x = linkInfo.pos.from.x;
cachedFromPos.y = linkInfo.pos.from.y;
cachedToPos.x = linkInfo.pos.to.x;
cachedToPos.y = linkInfo.pos.to.y;
linkPositionCallback(linkInfo.ui, cachedFromPos, cachedToPos, linkInfo.model);
}
}
function initializeScene() {
graph.forEachNode(addNode);
graph.forEachLink(addLink);
moveTo(container.clientWidth / 2, container.clientHeight / 2);
listenToGraphEvents(true);
sceneInitialized = true;
}
function addLink(link) {
var linkUI = linkBuilder(link);
if (!linkUI) throw new Error('Link builder is supposed to return SVG object');
links[link.id] = {
pos: layout.getLinkPosition(link.id),
model: link,
ui: linkUI
};
linkLayer.append(linkUI);
}
function addNode(node) {
var ui = nodeBuilder(node);
if (!ui) throw new Error('Node builder is supposed to return SVG object');
nodeLayer.append(ui);
var pos = layout.getNodePosition(node.id);
var nodeDescriptor = {
pos: pos,
model: node,
ui: ui
};
var recognizers = { recognizers: [
[hammer.Pan, { threshold: 1 }]
] };
nodeDescriptor.events = hammer(ui, recognizers).on(MOVE_EVENTS, onNodePan(pos, node));
nodes[node.id] = nodeDescriptor;
}
function setLinkBuilder(builderCallback) {
if (typeof builderCallback !== "function") throw new Error('link builder callback is supposed to be a function');
linkBuilder = builderCallback; // todo: rebuild all nodes?
return api;
}
function setNodeBuilder(builderCallback) {
if (typeof builderCallback !== "function") throw new Error('node builder callback is supposed to be a function');
nodeBuilder = builderCallback; // todo: rebuild all nodes?
return api;
}
function listenToGraphEvents(isOn) {
graph[isOn ? 'on' : 'off']('changed', onGraphChanged);
}
function onGraphChanged(changes) {
resetStable();
for (var i = 0; i < changes.length; ++i) {
var change = changes[i];
if (change.changeType === 'add') {
if (change.node) {
addNode(change.node);
}
if (change.link) {
addLink(change.link);
}
} else if (change.changeType === 'remove') {
if (change.node) {
removeNode(change.node);
}
if (change.link) {
removeLink(change.link);
}
}
}
}
function resetStable() {
isStable = false;
}
function createSvgRoot(element) {
if (element instanceof SVGSVGElement) return element;
var svgRoot = svg("svg");
element.appendChild(svgRoot);
return svgRoot;
}
function createSceneRoot(svgRoot) {
var scene = svg('g').attr("buffered-rendering", "dynamic");
svgRoot.append(scene);
var sceneMoveRecognizer = { recognizers: [
[hammer.Pan, { threshold: 1 }],
[hammer.Pinch, { enable: true }]
] };
// somehow ios does not fire events on svg. Use body instead:
hammer(container, sceneMoveRecognizer)
.on(MOVE_EVENTS, onScenePan)
.on('pinchstart pinchin pinchout', onScreenPinch);
var addWheelListener = require('wheel');
addWheelListener(svgRoot, onWheel);
return scene;
}
function onScenePan(e) {
if (e.target !== svgRoot || panNode > 0) return;
if (e.type === 'panmove') {
currentTransform.tx = fromX + e.deltaX;
currentTransform.ty = fromY + e.deltaY;
updateTransformMatrix();
} else if (e.type === 'panstart') {
fromX = currentTransform.tx;
fromY = currentTransform.ty;
}
}
function onWheel(e) {
var isZoomIn = e.deltaY < 0;
var direction = isZoomIn ? 1 : -1;
var factor = (1 + direction * scrollSpeed);
var x = e.offsetX === undefined ? e.layerX : e.offsetX;
var y = e.offsetY === undefined ? e.layerY : e.offsetY;
zoomTo(x, y, factor);
e.preventDefault();
}
function zoomTo(x, y, factor) {
currentTransform.tx = x - factor * (x - currentTransform.tx);
currentTransform.ty = y - factor * (y - currentTransform.ty);
currentTransform.scale *= factor;
updateTransformMatrix();
}
function addLayer(name, parent) {
var layer = svg('g').attr('id', name);
parent.append(layer);
return layer;
}
function getModelPosition(pos) {
return {
x: (pos.x - currentTransform.tx)/currentTransform.scale,
y: (pos.y - currentTransform.ty)/currentTransform.scale,
};
}
function createSceneTransform(scene) {
var transform = svgRoot.createSVGTransform();
scene.transform.baseVal.appendItem(transform);
return transform;
}
function moveTo(x, y) {
currentTransform.tx = x;
currentTransform.ty = y;
updateTransformMatrix();
}
function updateTransformMatrix() {
sceneTransform.matrix.e = currentTransform.tx;
sceneTransform.matrix.f = currentTransform.ty;
sceneTransform.matrix.a = sceneTransform.matrix.d = currentTransform.scale;
}
function onNodePan(pos, model) {
return function onNodePan(e) {
var clickPosition = getModelPosition(e.center);
var status;
resetStable();
if (e.type === 'panmove') {
status = panSession[model.id];
layout.setNodePosition(model.id, clickPosition.x - status.dx , clickPosition.y - status.dy);
} else if (e.type === 'panstart') {
panSession[model.id] = {
isPinned: layout.isNodePinned(model),
dx: clickPosition.x - pos.x,
dy: clickPosition.y - pos.y
};
layout.pinNode(model, true);
panNode += 1;
} else if (e.type === 'panend') {
status = panSession[model.id];
if (status) layout.pinNode(model, status.isPinned);
panNode -= 1;
if (panNode < 0) panNode = 0;
}
};
}
function releaseDOMEvents() {
for (var key in nodes) {
var descriptor = nodes[key];
if (descriptor.events) descriptor.events.destroy();
}
}
function removeNode(node) {
var descriptor = nodes[node.id];
if (!descriptor) return;
descriptor.events.destroy();
var parent = descriptor.ui.parentNode;
if (parent) parent.removeChild(descriptor.ui);
delete nodes[node.id];
}
function removeLink(link) {
var descriptor = links[link.id];
if (!descriptor) return;
var parent = descriptor.ui.parentNode;
if (parent) parent.removeChild(descriptor.ui);
delete links[link.id];
}
function onScreenPinch(e) {
if (e.target !== svgRoot) return;
if (e.type === 'pinchstart') {
screenPinchX = e.center.x;
screenPinchY = e.center.y;
lastScale = e.scale;
} else {
var direction = lastScale > e.scale ? -1 : 1;
lastScale = e.scale;
var factor = (1 + direction * 0.04);
zoomTo(screenPinchX, screenPinchY, factor);
}
}
}