UNPKG

ngraph.svg

Version:
377 lines (295 loc) 9.54 kB
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); } } }