diff --git a/springy.js b/springy.js index 0bf5ba4..49a9474 100644 --- a/springy.js +++ b/springy.js @@ -24,6 +24,7 @@ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. */ +"use strict"; (function (root, factory) { if (typeof define === 'function' && define.amd) { // AMD. Register as an anonymous module. @@ -40,7 +41,7 @@ root.Springy = factory(); } }(this, function() { - + const boosted = typeof(Float64Array) !== 'undefined' ? 2 : 1; // DS: 0: 2.7fps vs 1: 11.7fps vs 2: 18.4fps var Springy = {}; var Graph = Springy.Graph = function() { @@ -60,8 +61,11 @@ // Data fields used by layout algorithm in this file: // this.data.mass + // this.data.insulator // Data used by default renderer in springyui.js // this.data.label + // this.data.color + // this.inside }; var Edge = Springy.Edge = function(id, source, target, data) { @@ -327,22 +331,41 @@ // ----------- var Layout = Springy.Layout = {}; - Layout.ForceDirected = function(graph, stiffness, repulsion, damping, minEnergyThreshold, maxSpeed) { + Layout.ForceDirected = function(graph, ctx, stiffness, repulsion, damping, minEnergyThreshold, maxSpeed, fontsize, fontname, zoomFactor, pinWeight) { this.graph = graph; + this.ctx = ctx; this.stiffness = stiffness; // spring stiffness constant this.repulsion = repulsion; // repulsion constant this.damping = damping; // velocity damping factor this.minEnergyThreshold = minEnergyThreshold || 0.01; //threshold used to determine render stop - this.maxSpeed = maxSpeed || Infinity; // nodes aren't allowed to exceed this speed - + this.maxSpeed = maxSpeed || 100.0; // nodes aren't allowed to exceed this speed + this.fontsize = fontsize || 8.0; + this.edgeFontsize = this.fontsize * 9 / 10; + this.fontname = fontname || "Verdana, sans-serif"; + this.nodeFont = this.fontsize.toString() + 'px ' + this.fontname; + this.edgeFont = this.edgeFontsize.toString() + 'px ' + this.fontname; + this.pinWeight = pinWeight || 10; + this.scaleFactor = 1.025; // scale factor for each wheel click. + this.zoomFactor = zoomFactor || 1.0; // current zoom factor for the whole canvas. + this.realFontsize = this.fontsize * this.zoomFactor; + this.realEdgeFontsize = this.edgeFontsize * this.zoomFactor; + this.selected = null; + this.exciteMethod = 'none'; // none, downstream, upstream, connected + this.energy = 0; this.nodePoints = {}; // keep track of points associated with nodes this.edgeSprings = {}; // keep track of springs associated with edges + this.times = []; + this.fps = 0; }; Layout.ForceDirected.prototype.point = function(node) { if (!(node.id in this.nodePoints)) { - var mass = (node.data.mass !== undefined) ? node.data.mass : 1.0; - this.nodePoints[node.id] = new Layout.ForceDirected.Point(Vector.random(), mass); + var mass = (node.data.mass !== undefined) ? parseFloat(node.data.mass) : 1.0; + var insulator = (node.data.insulator !== undefined) ? node.data.insulator : false; + // DS: load positions from user data + var x = (node.data.x !== undefined) ? parseFloat(node.data.x) : 10.0 * (Math.random() - 0.5); + var y = (node.data.y !== undefined) ? parseFloat(node.data.y) : 10.0 * (Math.random() - 0.5); + this.nodePoints[node.id] = new Layout.ForceDirected.Point(new Vector(x, y), mass, insulator); } return this.nodePoints[node.id]; @@ -384,6 +407,21 @@ return this.edgeSprings[edge.id]; }; + // produce a random sample: callback should accept two arguments: Node, Point + Layout.ForceDirected.prototype.sampleNode = function(callback, limit) { + var t = this; + var sample = []; + var length = this.graph.nodes.length; + var n = Math.max(Math.min(limit, length), 0); + while (n--) { + var rand = Math.floor(Math.random() * length); + sample[rand] = t.graph.nodes[rand]; // deduplicate + } + sample.forEach(function(n){ + callback.call(t, n, t.point(n)); + }); + }; + // callback should accept two arguments: Node, Point Layout.ForceDirected.prototype.eachNode = function(callback) { var t = this; @@ -408,15 +446,141 @@ }); }; + Layout.ForceDirected.prototype.scaleFontSize = function(factor) { + const t = this; + let realFontsize = t.fontsize * t.zoomFactor * factor; + realFontsize = Math.max(Math.min(realFontsize, 30), 0.5); + t.realFontsize = realFontsize; + t.fontsize = realFontsize / t.zoomFactor; + t.realEdgeFontsize = realFontsize * 9 / 10; + t.edgeFontsize = t.fontsize * 9 / 10; + t.nodeFont = t.fontsize.toString() + 'px ' + t.fontname; + t.edgeFont = t.edgeFontsize.toString() + 'px ' + t.fontname; + }; + + Layout.ForceDirected.prototype.scaleZoomFactor = function(factor) { + let zoomFactor = this.zoomFactor * factor; + zoomFactor = Math.max(Math.min(zoomFactor, 12.0), 1.0); + if (this.zoomFactor !== zoomFactor) { + this.zoomFactor = zoomFactor; + return true; + } else { + return false; + } + }; + Layout.ForceDirected.prototype.setPinWeight = function(weight) { + this.pinWeight = weight; + }; + + Layout.ForceDirected.prototype.setExciteMethod = function(exciteMethod) { + this.exciteMethod = exciteMethod; + }; + + Layout.ForceDirected.prototype.setParameter = function(param) { + const t = this; + t.stiffness = param.stiffness || t.stiffness; // spring stiffness constant + t.repulsion = param.repulsion || t.repulsion; // repulsion constant + t.damping = param.damping || t.damping; // velocity damping factor + t.minEnergyThreshold = param.minEnergyThreshold || t.minEnergyThreshold; //threshold used to determine render stop + t.maxSpeed = param.maxSpeed || t.maxSpeed; // nodes aren't allowed to exceed this speed + t.eachSpring(function(e) { + e.k = t.stiffness; + }); + }; + // when nodes have many edges the calculation of forces causes shaking and jumping of the nodes. + // by adding a counter mass equal to the number of edges can stop that errors. + Layout.ForceDirected.prototype.optimizeMass = function(m) { + const c = (m !== undefined)? m : 1; + this.eachNode(function(node, point) { + point.c = c; + }); + this.eachSpring(function(spring){ + // apply mass to each end point + spring.point1.c += c;; + spring.point2.c += c;; + }); + }; + + let timeslice = 200000; + let loops_cnt = timeslice; + let sliceTimer = null; + function tic_fork (cnt, stage) { + loops_cnt -= cnt; + if (loops_cnt <= 0) { // DS: with 1,000 nodes we have 1,000,000 iterations, thats why i slice the time. + loops_cnt = timeslice; + // console.log('tic'+stage); + if (! sliceTimer) { + sliceTimer = window.setTimeout(function (){ + // console.log('tic-slice'+stage); + sliceTimer = null; + }, 1); + } + } + } // Physics stuff - Layout.ForceDirected.prototype.applyCoulombsLaw = function() { + Layout.ForceDirected.prototype.applyCoulombsLaw = boosted === 2 ? function() { + // Boosted method 2 -- hand written assembler code - loops variables are transformed into static memory array addresses + const len = this.graph.nodes.length; + let dir = new Float64Array(9); + dir[7] = this.repulsion; + // dir[0]=dir_x; dir[1]=dir_y; dir[2]=distance; dir[3]=force + // dir[4]=p1.x; dir[5]=p1.y; dir[6]=1/p1.m; dir[7]=repulsion + for(let n=0;n 0 && t.times[0] <= now - 10000) { + t.times.shift(); + } + t.times.push(now); + t.fps = t.times.length / 10; + if (do_update) { + t.tick(0.03); + } if (render !== undefined) { render(); } - + // console.log('toc'); // stop simulation when energy of the system goes below a threshold - if (t._stop || t.totalEnergy() < t.minEnergyThreshold) { + t.energy = t.totalEnergy(); + if (t._stop || t.energy < t.minEnergyThreshold) { t._started = false; if (onRenderStop !== undefined) { onRenderStop(); } + } else if (UA.isSafari()) { + window.setTimeout(function (){ + Springy.requestAnimationFrame(step); + }, 5); } else { Springy.requestAnimationFrame(step); } @@ -527,9 +781,11 @@ Layout.ForceDirected.prototype.tick = function(timestep) { this.applyCoulombsLaw(); this.applyHookesLaw(); + tic_fork (this.graph.edges.length, 2); this.attractToCentre(); this.updateVelocity(timestep); this.updatePosition(timestep); + tic_fork (this.graph.nodes.length * 3, 3); }; // Find the nearest point to a particular position @@ -548,6 +804,57 @@ return min; }; + Layout.ForceDirected.prototype.findNode = function(node_id) { + var min = null; + var pos = new Springy.Vector(0, 0); + var t = this; + this.graph.nodes.forEach(function(n){ + var point = t.point(n); + var distance = point.p.subtract(pos).magnitude(); + if (n.data.name === node_id) { + min = {node: n, point: point, distance: distance, inside:true}; + return min; + } + }); + + return min; + } + + Layout.ForceDirected.prototype.selectNode = function(node_id) { + var t = this; + t.selected = t.findNode(node_id); + return t.selected; + } + + Layout.ForceDirected.prototype.isSelectedNode = function(node_id) { + var t = this; + return (t.selected !== null && t.selected.node !== null + && (t.selected.node.id === node_id || node_id === null) + && t.selected.inside); + } + + Layout.ForceDirected.prototype.isExcitedNode = function(node_id) { + return this.nodePoints[node_id].e || false; + } + + Layout.ForceDirected.prototype.isSelectedEdge = function(edge) { + var t = this; + return (t.selected !== null && t.selected.node !== null + && (t.selected.node.id === edge.source.id || t.selected.node.id === edge.target.id) + && t.selected.inside) + || ( t.isExcitedNode(edge.source.id) + && t.isExcitedNode(edge.target.id)); + } + + Layout.ForceDirected.prototype.setNodeProperties = function(label, color, shape) { + var t = this; + if (t.isSelectedNode(null)) { + t.selected.node.data.label = label; + t.selected.node.data.color = color; + t.selected.node.data.shape = shape; + } + } + // returns [bottomleft, topright] Layout.ForceDirected.prototype.getBoundingBox = function() { var bottomleft = new Vector(-2,-2); @@ -568,7 +875,7 @@ } }); - var padding = topright.subtract(bottomleft).multiply(0.07); // ~5% padding + var padding = topright.subtract(bottomleft).multiply2(0.14, 0.07); // ~5% padding return {bottomleft: bottomleft.subtract(padding), topright: topright.add(padding)}; }; @@ -596,6 +903,10 @@ return new Vector(this.x * n, this.y * n); }; + Vector.prototype.multiply2 = function(n, m) { + return new Vector(this.x * n, this.y * m); + }; + Vector.prototype.divide = function(n) { return new Vector((this.x / n) || 0, (this.y / n) || 0); // Avoid divide by zero errors.. }; @@ -613,15 +924,18 @@ }; // Point - Layout.ForceDirected.Point = function(position, mass) { + Layout.ForceDirected.Point = function(position, mass, insulator) { this.p = position; // position this.m = mass; // mass + this.c = 1.0; // connections + this.i = insulator; // insulator + this.e = false; // excited this.v = new Vector(0, 0); // velocity this.a = new Vector(0, 0); // acceleration }; Layout.ForceDirected.Point.prototype.applyForce = function(force) { - this.a = this.a.add(force.divide(this.m)); + this.a = this.a.add(force.divide(this.m + this.c)); }; // Spring @@ -647,20 +961,97 @@ * @param onRenderStart optional callback function that gets executed whenever rendering starts. * @param onRenderFrame optional callback function that gets executed after each frame is rendered. */ - var Renderer = Springy.Renderer = function(layout, clear, drawEdge, drawNode, onRenderStop, onRenderStart, onRenderFrame) { + var Renderer = Springy.Renderer = function(layout, clear, drawEdge, drawNode, getCanvasPos, onRenderStop, onRenderStart, onRenderFrame, zoomCanvas, moveCanvas) { this.layout = layout; this.clear = clear; this.drawEdge = drawEdge; this.drawNode = drawNode; + this.getCanvasPos = getCanvasPos; this.onRenderStop = onRenderStop; this.onRenderStart = onRenderStart; this.onRenderFrame = onRenderFrame; - + this.zoomCanvas = zoomCanvas; + this.moveCanvas = moveCanvas; this.layout.graph.addGraphListener(this); } Renderer.prototype.graphChanged = function(e) { - this.start(); + this.start(true); + }; + + Renderer.prototype.propagateExcitement = function(method) { + this.propagateExcitement(method); + }; + + Renderer.prototype.selectNode = function(name) { + this.layout.selectNode(name); + this.layout.propagateExcitement(); + this.start(true); + }; + + Renderer.prototype.getNodePositions = function(e) { + return JSON.stringify(this.layout.getNodePositions()); + }; + + Renderer.prototype.getCanvasPos = function(e) { + return this.getCanvasPos(); + }; + + Renderer.prototype.scaleFontSize = function(factor) { + this.layout.scaleFontSize(factor); + this.start(true); + }; + + Renderer.prototype.scaleZoomFactor = function(factor) { + var t = this; + if (t.layout.scaleZoomFactor(factor)) { + t.layout.scaleFontSize(1.0); + if (t.zoomCanvas !== undefined) { t.zoomCanvas(factor); } + t.start(true); + } + }; + + Renderer.prototype.focusSelected = function() { + var result = false; + var t = this; + var factor = 1.0 / (t.layout.realFontsize / 10.0); // zoom to font size 10 + if (t.layout.scaleZoomFactor(factor)) { + t.layout.scaleFontSize(1.0); + if (t.zoomCanvas !== undefined) { t.zoomCanvas(factor); } + t.start(true); + result = true; + } + if (t.layout.selected) { + if (t.moveCanvas !== undefined) { t.moveCanvas(t.layout.selected.point.p); } + t.start(true); + result = true; + } + return result; // return true when position of zoom was uodated + }; + + Renderer.prototype.setPinWeight = function(weight) { + this.layout.setPinWeight(weight); + }; + + Renderer.prototype.setExciteMethod = function(exciteMethod) { + this.layout.setExciteMethod(exciteMethod); // none, downstream, upstream, connected + this.layout.propagateExcitement(); + this.start(true); + }; + + Renderer.prototype.setParameter = function(param) { + this.layout.setParameter(param); + this.start(true); + }; + + Renderer.prototype.optimizeMass = function(m) { + this.layout.optimizeMass(m); + this.start(true); + }; + + Renderer.prototype.setNodeProperties = function(label, color, shape) { + this.layout.setNodeProperties(label, color, shape); + this.start(true); }; /** @@ -673,7 +1064,25 @@ * @param done An optional callback function that gets executed when the springy algorithm stops, * either because it ended or because stop() was called. */ - Renderer.prototype.start = function(done) { + Renderer.prototype.start = boosted ? function(do_update) { + var t = this; + this.layout.start(function render() { + t.clear(); + for(let n=0;n height) { + width = width / 2 + height; + ctx.scale(1, height / width); + } else { + width = width / 2; + } + if (first) { + set_colors(); + } else { + ctx.fillStyle = color1; + } + ctx.beginPath(); + ctx.arc(0, 0, width, 0, Math.PI * 2, true); + ctx.fill(); + ctx.restore(); + ctx.stroke(); + }; + + var triangle = function(pos, width, height){ + var dim = width / 2 + height / 3 * 2; + var c1x = pos.x, + c1y = pos.y - height, + c2x = c1x - dim, + c2y = pos.y + 8, + c3x = c1x + dim, + c3y = c2y; + set_colors(); + ctx.beginPath(); + ctx.moveTo(c1x, c1y); + ctx.lineTo(c2x, c2y); + ctx.lineTo(c3x, c3y); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + }; + + var polygon = function(pos, width, height, n, even, first){ + var pix = 2*Math.PI/n; // angel in circle + var fy = (3*height)/(2*width); // deformation of circle + var dim = (n+1)*(3*height+2*width)/(4*n); // radius + ctx.save(); + ctx.translate(pos.x, pos.y); + ctx.scale(1, fy); + if (first) { + set_colors(); + } else { + ctx.fillStyle = color1; + } + if (even) { + ctx.rotate(pix/2 + Math.PI/2*even); // flat bottom line + } else { + ctx.rotate(Math.PI/2); // standing on corner + } + ctx.beginPath(); + ctx.moveTo(dim, 0); + while(n--) { + ctx.rotate(pix); + ctx.lineTo(dim, 0); + } + ctx.fill(); + ctx.restore(); + ctx.stroke(); + }; + + var star = function(pos, width, height){ + var dim = height * 4 / 3; + var pi5 = Math.PI / 5; + ctx.save(); + ctx.translate(pos.x, pos.y); + set_colors(); + ctx.beginPath(); + ctx.moveTo(dim, 0); + for (var i = 0; i < 9; i++) { + ctx.rotate(pi5); + if (i % 2 == 0) { + ctx.lineTo((dim / 0.525731) * 0.200811, 0); + } else { + ctx.lineTo(dim, 0); + } + } + ctx.closePath(); + ctx.fill(); + ctx.restore(); + ctx.stroke(); + }; + + // drag and drop var nearest = null; var dragged = null; + var point_clicked = null; + var inside_node = false; + var lastX=canvas.width/2, lastY=canvas.height/2; + var dragStart = null; + var canvas_dragged; + var tapedTwice = false; + + var mouse_inside_node = function(item, mp) { + if (item !== null && item.node !== null && typeof(item.inside) == 'undefined') { + var node = item.node; + var boxWidth = node.getWidth(layout); + var boxHeight = node.getHeight(layout); + var pos = toScreen(item.point.p); + var p = toScreen(mp); + var diffx = Math.abs(pos.x - p.x); + var diffy = Math.abs(pos.y - p.y); + + inside_node = (diffx <= boxWidth/2 && diffy <= boxHeight) ? true : false; + item.inside = inside_node; + } + }; - jQuery(canvas).mousedown(function(e) { - var pos = jQuery(this).offset(); - var p = fromScreen({x: e.pageX - pos.left, y: e.pageY - pos.top}); - selected = nearest = dragged = layout.nearest(p); - - if (selected.node !== null) { - dragged.point.m = 10000.0; + var snap_to_canvas = function() { + // move upper left corner and lower right corner inside canvas + var diffx = 0; + var diffy = 0; + var diffx2 = 0; + var diffy2 = 0; + + var xform = ctx.getTransform(); + var xsize = canvas.width * xform.a; + var ysize = canvas.height * xform.a; + var xoffset = xform.e; + var yoffset = xform.f; + + if (xoffset > 0) + diffx = -xoffset; + if (xoffset < 0 && xoffset + xsize < canvas.width) + diffx = canvas.width - (xoffset + xsize); + + if (yoffset > 0) + diffy = -yoffset; + if (yoffset < 0 && yoffset + ysize < canvas.height) + diffy = canvas.height - (yoffset + ysize); + ctx.translate(diffx, diffy); + }; + var mousedown = function(canvas, pageX, pageY, offsetX, offsetY){ + var pos = jQuery(canvas).offset(); // offsets in html window + var p1 = ctx.transformedPoint(pageX - pos.left, pageY - pos.top); // real canvas offsets + var p = fromScreen(p1); // internal offsets relative to current BB + layout.selected = nearest = dragged = layout.nearest(p); + point_clicked = p; + mouse_inside_node(layout.selected, p); + if (layout.selected.inside) { + // DS 13.Oct 2019 : pin or just move selected node depending on pinWeight + dragged.point.m = layout.pinWeight; + layout.propagateExcitement(); + if (nodeSelected) { - nodeSelected(selected.node); + nodeSelected(layout.selected.node, false); } + } else { + lastX = offsetX || (pageX - pos.left); + lastY = offsetY || (pageY - pos.top); + + dragStart = ctx.transformedPoint(lastX,lastY); + canvas_dragged = false; } - renderer.start(); + renderer.start(layout.selected.inside); + }; + + jQuery(canvas).mousedown(function(e) { + mousedown(this, e.pageX, e.pageY, e.offsetX, e.offsetY); }); // Basic double click handler - jQuery(canvas).dblclick(function(e) { - var pos = jQuery(this).offset(); - var p = fromScreen({x: e.pageX - pos.left, y: e.pageY - pos.top}); - selected = layout.nearest(p); - node = selected.node; - if (node && node.data && node.data.ondoubleclick) { - node.data.ondoubleclick(); + var doubleclick = function(canvas, pageX, pageY){ + var pos = jQuery(canvas).offset(); + var p = fromScreen({x: pageX - pos.left, y: pageY - pos.top}); + if (layout.selected && layout.selected.inside) { + var node = layout.selected.node; + if (node && node.data) { + if (node.data.ondoubleclick) { + node.data.ondoubleclick(); + } + if (nodeSelected) { + nodeSelected(layout.selected.node, true); + } + } } + }; + + jQuery(canvas).dblclick(function(e) { + doubleclick(this, e.pageX, e.pageY); }); - jQuery(canvas).mousemove(function(e) { - var pos = jQuery(this).offset(); - var p = fromScreen({x: e.pageX - pos.left, y: e.pageY - pos.top}); + var moveViewport = function(canvas, startx, starty, lastX, lastY) { + var pt = ctx.transformedPoint(lastX,lastY); + var diffx = pt.x-startx; + var diffy = pt.y-starty; + var xform = ctx.getTransform(); + var xsize = canvas.width * xform.a; + var ysize = canvas.height * xform.a; + var xoffset = xform.e; + var yoffset = xform.f; + // 0 limit left: + if (diffx > 0 && xoffset + diffx > 0) { + diffx = 0; + } + // 0 limit right: + if (diffx < 0 && (xoffset + diffx + xsize) < canvas.width) { + diffx = 0; + } + // 0 limit top: + if (diffy > 0 && yoffset+diffy > 0) { + diffy = 0; + } + // 0 limit bottom: + if (diffy < 0 && (yoffset + diffy + ysize) < canvas.height) { + diffy = 0; + } + ctx.translate(diffx, diffy); + snap_to_canvas(); + } + + var mousemove = function(canvas, pageX, pageY, offsetX, offsetY){ + var pos = $(canvas).offset(); + var p1 = ctx.transformedPoint(pageX - pos.left, pageY - pos.top); + var p = fromScreen(p1); nearest = layout.nearest(p); - - if (dragged !== null && dragged.node !== null) { + mouse_inside_node(nearest, p); + if (dragged !== null && dragged.node !== null && dragged.inside) { dragged.point.p.x = p.x; dragged.point.p.y = p.y; + } else { + lastX = offsetX || (pageX - pos.left); + lastY = offsetY || (pageY - pos.top); + canvas_dragged = true; + if (dragStart !== null){ + moveViewport (canvas, dragStart.x, dragStart.y, lastX, lastY); + } } + renderer.start(dragged !== null && dragged.inside); + }; - renderer.start(); + jQuery(canvas).mousemove(function(e) { + mousemove(this, e.pageX, e.pageY, e.offsetX, e.offsetY); + }); + + jQuery(canvas).on('touchstart', function(e){ + let t = e.changedTouches[0]; // first finger + if (!tapedTwice) { + tapedTwice = true; + setTimeout( function() { tapedTwice = false; }, 300 ); + + mousedown(this, t.pageX, t.pageY, t.offsetX, t.offsetY); + } else { + doubleclick(this, t.pageX, t.pageY); + } + e.preventDefault(); + }); + + jQuery(canvas).on('touchmove', function(e){ + let t = e.changedTouches[0]; // erster Finger + mousemove(this, t.pageX, t.pageY, t.offsetX, t.offsetY); + e.preventDefault(); + }); + + jQuery(canvas).on('touchend', function(e){ + nearest = null; + dragged = null; + dragStart = null; + renderer.start(true); + e.preventDefault(); + }); + + jQuery(canvas).mouseleave(function(e) { + nearest = null; + dragged = null; + dragStart = null; + renderer.start(true); }); jQuery(window).bind('mouseup',function(e) { dragged = null; + dragStart = null; }); + // ------------------------------------------------- + + var zoom = function(clicks){ + if (! inside_node) { + let factor = Math.pow(layout.scaleFactor,clicks); + let pt = ctx.transformedPoint(lastX,lastY); + let zoomFactor = layout.zoomFactor; + ctx.translate(pt.x,pt.y); + if (factor < 1) { + // avoid negative zoom + if (zoomFactor * factor < 1) factor = 1.0/zoomFactor; + ctx.scale(factor,factor); + zoomFactor = zoomFactor * factor; + } + if (factor > 1 && zoomFactor < 12) { + ctx.scale(factor,factor); + zoomFactor = zoomFactor * factor; + } + ctx.translate(-pt.x,-pt.y); + if (clicks < 0) + snap_to_canvas(); + layout.zoomFactor = zoomFactor; + layout.scaleFontSize(1.0); + } else { + let factor = Math.pow(layout.scaleFactor,clicks); + layout.scaleFontSize(factor); + } + renderer.start(true); + } + + var handleScroll = function(evt){ + var delta = evt.wheelDelta ? evt.wheelDelta/40 : evt.detail ? -evt.detail : 0; + if (delta) zoom(delta); + return evt.preventDefault() && false; + }; + + canvas.addEventListener('DOMMouseScroll',handleScroll,false); + canvas.addEventListener('mousewheel',handleScroll,false); + + var handleGesture = function(evt){ + var delta = evt.scale ? evt.scale / 20 : 0; + if (delta) zoom(delta); + return evt.preventDefault() && false; + }; + + canvas.addEventListener('gesturestart',handleGesture,false); + canvas.addEventListener('gesturechange',handleGesture,false); + canvas.addEventListener('gestureend',handleGesture,false); + // ------------------------------------------------- + + // Adds ctx.getTransform() - returns an SVGMatrix + // Adds ctx.transformedPoint(x,y) - returns an SVGPoint + function trackTransforms(ctx){ + var svg = document.createElementNS("http://www.w3.org/2000/svg",'svg'); + var xform = svg.createSVGMatrix(); + ctx.getTransform = function(){ return xform; }; + + var savedTransforms = []; + var save = ctx.save; + ctx.save = function(){ + savedTransforms.push(xform.translate(0,0)); + return save.call(ctx); + }; + var restore = ctx.restore; + ctx.restore = function(){ + xform = savedTransforms.pop(); + return restore.call(ctx); + }; - var getTextWidth = function(node) { + var scale = ctx.scale; + ctx.scale = function(sx,sy){ + xform = xform.scaleNonUniform(sx,sy); + return scale.call(ctx,sx,sy); + }; + var rotate = ctx.rotate; + ctx.rotate = function(radians){ + xform = xform.rotate(radians*180/Math.PI); + return rotate.call(ctx,radians); + }; + var translate = ctx.translate; + ctx.translate = function(dx,dy){ + xform = xform.translate(dx,dy); + return translate.call(ctx,dx,dy); + }; + var transform = ctx.transform; + ctx.transform = function(a,b,c,d,e,f){ + var m2 = svg.createSVGMatrix(); + m2.a=a; m2.b=b; m2.c=c; m2.d=d; m2.e=e; m2.f=f; + xform = xform.multiply(m2); + return transform.call(ctx,a,b,c,d,e,f); + }; + var setTransform = ctx.setTransform; + ctx.setTransform = function(a,b,c,d,e,f){ + xform.a = a; + xform.b = b; + xform.c = c; + xform.d = d; + xform.e = e; + xform.f = f; + return setTransform.call(ctx,a,b,c,d,e,f); + }; + var pt = svg.createSVGPoint(); + ctx.transformedPoint = function(x,y){ + pt.x=x; pt.y=y; + return pt.matrixTransform(xform.inverse()); + } + } + + var getTextWidth = boosted ? function(node, layout) { + var ctx = layout.ctx; + if (node._width && node.fontsize === layout.fontsize) + return node._width; + var text = (node.data.label !== undefined && node.data.label) ? node.data.label : node.id; + ctx.save(); + ctx.font = layout.nodeFont; + node._width = ctx.measureText(text).width; + node.fontsize = layout.fontsize; + ctx.restore(); + return node._width; + } : + function(node, layout) { + var ctx = layout.ctx; var text = (node.data.label !== undefined) ? node.data.label : node.id; - if (node._width && node._width[text]) + if (node._width && node.fontsize === layout.fontsize && node._width[text]) return node._width[text]; ctx.save(); - ctx.font = (node.data.font !== undefined) ? node.data.font : nodeFont; + ctx.font = layout.nodeFont; var width = ctx.measureText(text).width; ctx.restore(); node._width || (node._width = {}); node._width[text] = width; + node.fontsize = layout.fontsize; return width; }; - var getTextHeight = function(node) { - return 16; + var getTextHeight = function(node, layout) { + return layout.fontsize; // In a more modular world, this would actually read the font size, but I think leaving it a constant is sufficient for now. // If you change the font size, I'd adjust this too. }; @@ -156,10 +683,10 @@ jQuery.fn.springy = function(params) { return height; } - Springy.Node.prototype.getHeight = function() { + Springy.Node.prototype.getHeight = function(layout) { var height; if (this.data.image == undefined) { - height = getTextHeight(this); + height = layout.fontsize; } else { if (this.data.image.src in nodeImages && nodeImages[this.data.image.src].loaded) { height = getImageHeight(this); @@ -168,10 +695,10 @@ jQuery.fn.springy = function(params) { return height; } - Springy.Node.prototype.getWidth = function() { + Springy.Node.prototype.getWidth = function(layout) { var width; if (this.data.image == undefined) { - width = getTextWidth(this); + width = getTextWidth(this, layout); } else { if (this.data.image.src in nodeImages && nodeImages[this.data.image.src].loaded) { width = getImageWidth(this); @@ -185,21 +712,21 @@ jQuery.fn.springy = function(params) { ctx.clearRect(0,0,canvas.width,canvas.height); }, function drawEdge(edge, p1, p2) { - var x1 = toScreen(p1).x; - var y1 = toScreen(p1).y; - var x2 = toScreen(p2).x; - var y2 = toScreen(p2).y; + const x1 = toScreen(p1).x; + const y1 = toScreen(p1).y; + const x2 = toScreen(p2).x; + const y2 = toScreen(p2).y; - var direction = new Springy.Vector(x2-x1, y2-y1); - var normal = direction.normal().normalise(); + const direction = new Springy.Vector(x2-x1, y2-y1); + const normal = direction.normal().normalise(); - var from = graph.getEdges(edge.source, edge.target); - var to = graph.getEdges(edge.target, edge.source); + const from = graph.getEdges(edge.source, edge.target); + const to = graph.getEdges(edge.target, edge.source); - var total = from.length + to.length; + const total = from.length + to.length; // Figure out edge's position in relation to other edges between the same nodes - var n = 0; + let n = 0; for (var i=0; i Math.PI/2 || angle < -Math.PI/2)) { - displacement = 8; - angle += Math.PI; + if (edge.data.label !== undefined && edge.data.label.length) { + let text = edge.data.label; + if (layout.realEdgeFontsize > 2.4) { // DS: hide tiny label + ctx.save(); + ctx.textAlign = "center"; + ctx.font = layout.edgeFont; + if (edgeLabelBoxes) { + let boxWidth = ctx.measureText(text).width * 1.1; + let px = (x1+x2)/2; + let py = (y1+y2)/2 - layout.fontsize/2; + ctx.textBaseline = "top"; + ctx.fillStyle = "#EEEEEE"; // label background + ctx.fillRect(px-boxWidth/2, py, boxWidth, layout.fontsize); + + ctx.fillStyle = "darkred"; + ctx.fillText(text, px, py); + } else { + ctx.textBaseline = "middle"; + ctx.fillStyle = stroke; + let angle = Math.atan2(s2.y - s1.y, s2.x - s1.x); + let displacement = -(layout.edgeFontsize / 10.0); + if (edgeLabelsUpright && (angle > Math.PI/2 || angle < -Math.PI/2)) { + displacement = layout.edgeFontsize / 3.0; + angle += Math.PI; + } + let textPos = s1.add(s2).divide(2).add(normal.multiply(displacement)); + ctx.translate(textPos.x, textPos.y); + ctx.rotate(angle); + ctx.fillText(text, 0,-2); + } + ctx.restore(); } - var textPos = s1.add(s2).divide(2).add(normal.multiply(displacement)); - ctx.translate(textPos.x, textPos.y); - ctx.rotate(angle); - ctx.fillText(text, 0,-2); - ctx.restore(); } }, function drawNode(node, p) { - var s = toScreen(p); - - ctx.save(); - - // Pulled out the padding aspect sso that the size functions could be used in multiple places - // These should probably be settable by the user (and scoped higher) but this suffices for now - var paddingX = 6; - var paddingY = 6; - - var contentWidth = node.getWidth(); - var contentHeight = node.getHeight(); - var boxWidth = contentWidth + paddingX; - var boxHeight = contentHeight + paddingY; - - // clear background - ctx.clearRect(s.x - boxWidth/2, s.y - boxHeight/2, boxWidth, boxHeight); - + const s = toScreen(p); + const boxWidth = node.getWidth(layout); + const boxHeight = node.getHeight(layout) * 1.2; + var textColor = nodeTextColor; // fill background - if (selected !== null && selected.node !== null && selected.node.id === node.id) { - ctx.fillStyle = "#FFFFE0"; - } else if (nearest !== null && nearest.node !== null && nearest.node.id === node.id) { - ctx.fillStyle = "#EEEEEE"; + if (layout.isSelectedNode(node.id)) { + shadowColor = selectedShadowColor; + shadowOffset = activeShadowOffset; + } else if (layout.isExcitedNode(node.id)) { + shadowColor = activeShadowColor; + shadowOffset = activeShadowOffset; } else { - ctx.fillStyle = "#FFFFFF"; + shadowColor = passiveShadowColor; + shadowOffset = layout.realFontsize; + } + if (typeof(node.data.color) !== 'undefined') { + color1 = node.data.color; + color2 = node.data.color; + } else { + color1 = defaultColor1; + color2 = defaultColor2; } - ctx.fillRect(s.x - boxWidth/2, s.y - boxHeight/2, boxWidth, boxHeight); - if (node.data.image == undefined) { - ctx.textAlign = "left"; - ctx.textBaseline = "top"; - ctx.font = (node.data.font !== undefined) ? node.data.font : nodeFont; - ctx.fillStyle = (node.data.color !== undefined) ? node.data.color : "#000000"; - var text = (node.data.label !== undefined) ? node.data.label : node.id; - ctx.fillText(text, s.x - contentWidth/2, s.y - contentHeight/2); + ctx.save(); + ctx.lineWidth = layout.fontsize/12; + ctx.strokeStyle = nodestrokeStyle; + + /********* Draw Shape **********/ + /*******************************/ + var shape = typeof(node.data.shape) !== 'undefined' ? node.data.shape : 'box'; + switch(shape) { + case 'plaintext': + case 'none': + break; + case 'box': + case 'box3d': + case 'folder': + case 'note': + case 'tab': + case 'component': + case 'Msquare': + box_shape(s, boxWidth, boxHeight, shape, true); + break; + case 'doublebox': + box_shape(s, boxWidth*1.1, boxHeight*1.2, shape, true); + box_shape(s, boxWidth, boxHeight, shape, false); + break; + case 'house': + house(s, boxWidth, boxHeight, false); + break; + case 'invhouse': + house(s, boxWidth, boxHeight, true); + break; + case 'circle': + ellipse(s, boxWidth+boxHeight, boxWidth+boxHeight, true); + break; + case 'ellipse': + ellipse(s, boxWidth, boxHeight, true); + break; + case 'doublecircle': + ellipse(s, boxWidth*1.1, boxHeight*1.2, true); + ellipse(s, boxWidth, boxHeight, false); + break; + case 'point': + textColor = 'DarkGray'; + ellipse(s, boxHeight, boxHeight, true); + break; + case 'triangle': + triangle(s, boxWidth, boxHeight); + break; + case 'righttriangle': + polygon(s, boxWidth*1.5, boxHeight, 3, 2, true); + break; + case 'lefttriangle': + polygon(s, boxWidth*1.4, boxHeight, 3, 4, true); + break; + case 'invtriangle': + polygon(s, boxWidth, boxHeight, 3, false, true); + break; + case 'rectangle': + polygon(s, boxWidth, boxHeight, 4, true, true); + break; + case 'diamond': + polygon(s, boxWidth, boxHeight, 4, false, true); + break; + case 'pentagon': + polygon(s, boxWidth, boxHeight, 5, true, true); + break; + case 'hexagon': + polygon(s, boxWidth, boxHeight, 6, true, true); + break; + case 'septagon': + polygon(s, boxWidth, boxHeight, 7, true, true); + break; + case 'octagon': + polygon(s, boxWidth, boxHeight, 8, true, true); + break; + case 'doubleoctagon': + polygon(s, boxWidth*0.91, boxHeight*1.2, 8, true, true); + polygon(s, boxWidth*0.9, boxHeight, 8, true, false); + break; + case 'tripleoctagon': + polygon(s, boxWidth*1.05, boxHeight*1.2, 8, true, true); + polygon(s, boxWidth, boxHeight, 8, true, false); + polygon(s, boxWidth*0.97, boxHeight*0.8, 8, true, false); + break; + case 'star': + textColor = 'DarkGray'; + star(s, boxWidth, boxHeight); + break; + case 'trapezium': + trapezium(s, boxWidth, boxHeight, false); + break; + case 'invtrapezium': + trapezium(s, boxWidth, boxHeight, true); + break; + case 'parallelogram': + parallelogram(s, boxWidth, boxHeight*1.1); + break; + default: + box_shape(s, boxWidth, boxHeight, shape, true); + } + ctx.translate(s.x, s.y); + // Node Label Text + if (layout.realFontsize > 2.4) { // DS: hide tiny label + ctx.textAlign = nodeTextAlign; + ctx.textBaseline = nodeTextBaseline; + ctx.font = layout.nodeFont; + ctx.fillStyle = textColor; + let text = typeof(node.data.label) !== 'undefined' ? node.data.label : node.id; + ctx.fillText(text, 0, 0); + } + ctx.restore(); } else { // Currently we just ignore any labels if the image object is set. One might want to extend this logic to allow for both, or other composite nodes. var src = node.data.image.src; // There should probably be a sanity check here too, but un-src-ed images aren't exaclty a disaster. @@ -339,7 +988,7 @@ jQuery.fn.springy = function(params) { // First time seeing an image with this src address, so add it to our set of image objects // Note: we index images by their src to avoid making too many duplicates nodeImages[src] = {}; - var img = new Image(); + let img = new Image(); nodeImages[src].object = img; img.addEventListener("load", function () { // HTMLImageElement objects are very finicky about being used before they are loaded, so we set a flag when it is done @@ -348,11 +997,59 @@ jQuery.fn.springy = function(params) { img.src = src; } } - ctx.restore(); + }, + function getCanvasPos() { + var xform = ctx.getTransform(); + var canvasPos = {}; + canvasPos.fontsize = layout.fontsize; + canvasPos.zoomFactor = layout.zoomFactor; // = xform.e = xform.d + canvasPos.x_offset = xform.e / layout.zoomFactor; + canvasPos.y_offset = xform.f / layout.zoomFactor; + canvasPos.energy = layout.energy; + canvasPos.fps = layout.fps; + return canvasPos; + }, + function onRenderStop() { + if (RenderStopCall) + RenderStopCall(renderer); + }, + function onRenderStart() { + if (RenderStartCall) + RenderStartCall(renderer); + }, + function onRenderFrame() { + if (RenderFrameCall) + RenderFrameCall(renderer); + }, + function zoomCanvas(factor) { + let pt = ctx.transformedPoint(canvas.width/2,canvas.height/2); + ctx.translate(pt.x,pt.y); + ctx.scale(factor,factor); + ctx.translate(-pt.x,-pt.y); + if (factor < 1) { + ctx.clearRect(0,0,canvas.width,canvas.height); + snap_to_canvas(); + } + }, + function moveCanvas(p) { + // center view port over selected node + var pos = toScreen(p); // selected node point + var zoomFactor = layout.zoomFactor; + //console.log('moveCanvas - pos :'+(pos.x * zoomFactor)+', '+(pos.y * zoomFactor)); + //console.log('moveCanvas - canvas/2 :'+(canvas.width/2)+', '+(canvas.height/2) ); + var xform = ctx.getTransform(); + //console.log('moveCanvas - offset :'+xform.e+', '+xform.f+', factor '+layout.zoomFactor); + let diffx = -((pos.x * zoomFactor) - (canvas.width/2) + xform.e)/layout.zoomFactor; + let diffy = -((pos.y * zoomFactor) - (canvas.height/2) + xform.f)/layout.zoomFactor; + //console.log('moveCanvas - diff :'+diffx+', '+diffy); + ctx.translate(diffx, diffy); + ctx.clearRect(0,0,canvas.width,canvas.height); + snap_to_canvas(); } ); - - renderer.start(); + renderer.setExciteMethod(exciteMethod); + renderer.optimizeMass(1); + renderer.start(true); // helpers for figuring out where to draw arrows function intersect_line_line(p1, p2, p3, p4) { @@ -387,7 +1084,6 @@ jQuery.fn.springy = function(params) { return false; } - return this; }