From cc88109271b14143ae6abe20948bdb731b09b5ba Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Fri, 10 Apr 2015 20:47:11 +0200 Subject: [PATCH] forcegraph: convert to canvas --- lib/forcegraph.js | 295 ++++++++++++++++++++++++++---------------- scss/_forcegraph.scss | 56 ++------ 2 files changed, 194 insertions(+), 157 deletions(-) diff --git a/lib/forcegraph.js b/lib/forcegraph.js index 92bd467..4a35ae5 100644 --- a/lib/forcegraph.js +++ b/lib/forcegraph.js @@ -1,7 +1,8 @@ define(["d3"], function (d3) { return function (config, linkScale, sidebar, router) { var self = this - var svg, vis, link, node, label + var svg, canvas, ctx + var svgNodes, svgLinks var nodesDict, linksDict var zoomBehavior var force @@ -10,6 +11,8 @@ define(["d3"], function (d3) { var intNodes = [] var intLinks = [] var highlight + var highlightedNodes = [] + var highlightedLinks = [] var LINK_DISTANCE = 70 @@ -57,23 +60,48 @@ define(["d3"], function (d3) { .on("dragend", dragend) function animatePanzoom(translate, scale) { - zoomBehavior.scale(scale) - zoomBehavior.translate(translate) + var translateP = zoomBehavior.translate() + var scaleP = zoomBehavior.scale() - var el = vis + if (!doAnimation) { + zoomBehavior.translate(translate) + zoomBehavior.scale(scale) + panzoom() + } else { + var start = {x: translateP[0], y: translateP[1], scale: scaleP} + var end = {x: translate[0], y: translate[1], scale: scale} - if (doAnimation) - el = el.transition().duration(500) + var interpolate = d3.interpolateObject(start, end) + var duration = 500 - el.attr("transform", "translate(" + translate + ") " + - "scale(" + scale + ")") + var ease = d3.ease("cubic-in-out") + + d3.timer(function (t) { + if (t >= duration) + return true + + var v = interpolate(ease(t / duration)) + zoomBehavior.translate([v.x, v.y]) + zoomBehavior.scale(v.scale) + panzoom() + + return false + }) + } } function panzoom() { var translate = zoomBehavior.translate() var scale = zoomBehavior.scale() - vis.attr("transform", "translate(" + translate + ") " + + + panzoomReal(translate, scale) + } + + function panzoomReal(translate, scale) { + svg.attr("transform", "translate(" + translate + ") " + "scale(" + scale + ")") + + redraw() } function getSize() { @@ -105,15 +133,15 @@ define(["d3"], function (d3) { } function updateHighlight(nopanzoom) { + highlightedNodes = [] + highlightedLinks = [] + if (highlight !== undefined) if (highlight.type === "node") { var n = nodesDict[highlight.o.nodeinfo.node_id] if (n) { - link.classed("highlight", false) - node.classed("highlight", function (e) { - return e.o.node === n.o.node && n.o.node !== undefined - }) + highlightedNodes = [n] if (!nopanzoom) panzoomTo([n.x, n.y], [n.x, n.y]) @@ -124,10 +152,7 @@ define(["d3"], function (d3) { var l = linksDict[highlight.o.id] if (l) { - node.classed("highlight", false) - link.classed("highlight", function (e) { - return e.o === l.o && l.o !== undefined - }) + highlightedLinks = [l] if (!nopanzoom) { var x = d3.extent([l.source, l.target], function (d) { return d.x }) @@ -139,129 +164,167 @@ define(["d3"], function (d3) { return } - node.classed("highlight", false) - link.classed("highlight", false) - if (!nopanzoom) panzoomTo([0, 0], force.size()) } function updateLinks(vis, data) { - var link = vis.selectAll("g.link") - .data(data, function (d) { return d.o.id }) + var link = vis.selectAll("line") + .data(data, function (d) { return d.o.id }) link.exit().remove() - var linkEnter = link.enter().append("g") - .attr("class", "link") - .on("click", function (d) { - if (!d3.event.defaultPrevented) - router.link(d.o)() - }) - - linkEnter.append("line") - .append("title") - - link.selectAll("line") - .style("stroke", function (d) { return linkScale(d.o.tq).hex() }) - - link.selectAll("title").text(function (d) { return showTq(d.o) }) + link.enter().append("line") + .on("click", function (d) { + if (!d3.event.defaultPrevented) + router.link(d.o)() + }) return link } function updateNodes(vis, data) { - var node = vis.selectAll(".node") + var node = vis.selectAll("circle") .data(data, function(d) { return d.o.id }) node.exit().remove() node.enter().append("circle") - .attr("r", 8) + .attr("r", 12) .on("click", function (d) { if (!d3.event.defaultPrevented) router.node(d.o.node)() }) .call(draggableNode) - node.attr("class", function (d) { - var s = ["node"] - if (!d.o.node) - s.push("unknown") - - return s.join(" ") - }) - return node } - function updateLabels(vis, data) { - var label = vis.selectAll("text") - .data(data, function(d) { return d.o.id }) + function drawLabel(d) { + var sum = d.neighbours.reduce(function (a, b) { + return [a[0] + b.x, a[1] + b.y] + }, [0, 0]) - label.exit().remove() + var sumCos = sum[0] - d.x * d.neighbours.length + var sumSin = sum[1] - d.y * d.neighbours.length - var labelEnter = label.enter().append("text") + var angle = Math.PI / 2 - label.text(nodeName) - .each(function (d) { - var bbox = this.getBBox() - d.labelHeight = bbox.height - d.labelWidth = bbox.width - }) + if (d.neighbours.length > 0) + angle = Math.PI + Math.atan2(sumSin, sumCos) - labelEnter.each(function (d) { - d.labelAngle = Math.PI / 2 - }) + var cos = Math.cos(angle) + var sin = Math.sin(angle) - return label + var x = d.x + d.labelA * Math.pow(Math.abs(cos), 2 / 5) * Math.sign(cos) + var y = d.y + d.labelB * Math.pow(Math.abs(sin), 2 / 5) * Math.sign(sin) + + ctx.fillText(d.label, x, y) } - function positionLabels() { - label.attr("transform", function(d) { - var neighbours = d.neighbours.map(function (n) { - var dx = n.x - d.x - var dy = n.y - d.y + function redraw() { + var translate = zoomBehavior.translate() + var scale = zoomBehavior.scale() + var nodes = intNodes.filter(function (d) { return d.o.node }) + var unknownNodes = intNodes.filter(function (d) { return !d.o.node }) + var links = intLinks + ctx.save() + ctx.font = "11px Roboto" + ctx.clearRect(0, 0, canvas.width, canvas.height) + ctx.translate(translate[0], translate[1]) + ctx.scale(scale, scale) - return (2 * Math.PI + Math.atan2(dy, dx)) % (2 * Math.PI) + if (highlightedLinks.length) { + ctx.save() + ctx.lineWidth = 16 + ctx.strokeStyle = "#FFD486" + + highlightedLinks.forEach(function (d) { + ctx.beginPath() + ctx.moveTo(d.source.x, d.source.y) + ctx.lineTo(d.target.x, d.target.y) + ctx.stroke() }) - var sumCos = neighbours.reduce(function (a, b) { - return a + Math.cos(b) - }, 0) + ctx.restore() + } - var sumSin = neighbours.reduce(function (a, b) { - return a + Math.sin(b) - }, 0) + ctx.lineWidth = 2.5 - if (neighbours.length > 0) - d.labelAngle = Math.PI + Math.atan2(sumSin, sumCos) - - var offset = 10 - - var a = offset + d.labelWidth / 2 - var b = offset + d.labelHeight / 2 - - var cos = Math.cos(d.labelAngle) - var sin = Math.sin(d.labelAngle) - - var x = d.x + a * Math.pow(Math.abs(cos), 2 / 5) * Math.sign(cos) - var y = d.y + b * Math.pow(Math.abs(sin), 2 / 5) * Math.sign(sin) - - return "translate(" + x + "," + y + ")" + links.forEach(function (d) { + ctx.beginPath() + ctx.moveTo(d.source.x, d.source.y) + ctx.lineTo(d.target.x, d.target.y) + ctx.strokeStyle = d.color + ctx.stroke() }) + + ctx.textAlign = "center" + ctx.textBaseline = "middle" + ctx.fillStyle = "rgba(0, 0, 0, 0.6)" + + intNodes.forEach(drawLabel) + + ctx.beginPath() + unknownNodes.forEach(function (d) { + ctx.moveTo(d.x + 8, d.y) + ctx.arc(d.x, d.y, 8, 0, 2 * Math.PI) + }) + + ctx.strokeStyle = "#d00000" + ctx.fillStyle = "#ffffff" + + ctx.fill() + ctx.stroke() + + ctx.beginPath() + nodes.forEach(function (d) { + ctx.moveTo(d.x + 8, d.y) + ctx.arc(d.x, d.y, 8, 0, 2 * Math.PI) + }) + + ctx.strokeStyle = "#AEC7E8" + ctx.fillStyle = "#ffffff" + + ctx.fill() + ctx.stroke() + + if (highlightedNodes.length) { + ctx.save() + ctx.strokeStyle = "#FFD486" + ctx.fillStyle = "orange" + ctx.lineWidth = 6 + + highlightedNodes.forEach(function (d) { + ctx.beginPath() + ctx.moveTo(d.x + 8, d.y) + ctx.arc(d.x, d.y, 8, 0, 2 * Math.PI) + ctx.fill() + ctx.stroke() + }) + + ctx.restore() + } + + ctx.restore() } function tickEvent() { - link.selectAll("line") - .attr("x1", function(d) { return d.source.x }) - .attr("y1", function(d) { return d.source.y }) - .attr("x2", function(d) { return d.target.x }) - .attr("y2", function(d) { return d.target.y }) + redraw() - node.attr("cx", function (d) { return d.x }) - .attr("cy", function (d) { return d.y }) - positionLabels() + svgLinks.attr("x1", function(d) { return d.source.x }) + .attr("y1", function(d) { return d.source.y }) + .attr("x2", function(d) { return d.target.x }) + .attr("y2", function(d) { return d.target.y }) + + svgNodes.attr("cx", function (d) { return d.x }) + .attr("cy", function (d) { return d.y }) + } + + function resizeCanvas() { + canvas.width = el.offsetWidth + canvas.height = el.offsetHeight + redraw() } el = document.createElement("div") @@ -273,15 +336,17 @@ define(["d3"], function (d3) { .on("zoom", panzoom) .translate([sidebar.getWidth(), 0]) + canvas = d3.select(el).append("canvas").node() + svg = d3.select(el).append("svg") .attr("pointer-events", "all") .call(zoomBehavior) + .append("g") - vis = svg.append("g") + var visLinks = svg.append("g") + var visNodes = svg.append("g") - var visLinks = vis.append("g").attr("class", "links") - var visLabels = vis.append("g").attr("class", "labels") - var visNodes = vis.append("g").attr("class", "nodes") + ctx = canvas.getContext("2d") force = d3.layout.force() .charge(-80) @@ -294,6 +359,8 @@ define(["d3"], function (d3) { .on("tick", tickEvent) .on("end", savePositions) + window.addEventListener("resize", resizeCanvas) + panzoom() self.setData = function (data) { @@ -339,6 +406,7 @@ define(["d3"], function (d3) { e.o = d e.source = newNodesDict[d.source.id] e.target = newNodesDict[d.target.id] + e.color = linkScale(d.tq).hex() return e }) @@ -351,6 +419,15 @@ define(["d3"], function (d3) { if (d.o.node) nodesDict[d.o.node.nodeinfo.node_id] = d + + d.label = nodeName(d) + + ctx.font = "11px Roboto" + var offset = 10 + var width = ctx.measureText(d.label).width + + d.labelA = offset + width / 2 + d.labelB = offset + 11 / 2 }) intLinks.forEach(function (d) { @@ -367,9 +444,8 @@ define(["d3"], function (d3) { }) }) - link = updateLinks(visLinks, intLinks) - node = updateNodes(visNodes, intNodes) - label = updateLabels(visLabels, intNodes) + svgLinks = updateLinks(visLinks, intLinks) + svgNodes = updateNodes(visNodes, intNodes) if (localStorageTest()) { var save = JSON.parse(localStorage.getItem("graph/nodeposition")) @@ -380,7 +456,7 @@ define(["d3"], function (d3) { nodePositions[d.id] = d }) - node.each( function (d) { + intNodes.forEach( function (d) { if (nodePositions[d.o.id] && (d.x === undefined || d.y === undefined)) { d.x = nodePositions[d.o.id].x d.y = nodePositions[d.o.id].y @@ -397,8 +473,8 @@ define(["d3"], function (d3) { updateHighlight(true) - if (node.enter().size() + link.enter().size() > 0) - force.start() + force.start() + resizeCanvas() } self.resetView = function () { @@ -421,14 +497,9 @@ define(["d3"], function (d3) { self.destroy = function () { force.stop() - node.remove() - link.remove() - svg.remove() + canvas.remove() force = null svg = null - vis = null - link = null - node = null } return self diff --git a/scss/_forcegraph.scss b/scss/_forcegraph.scss index 54a6555..719d8ef 100644 --- a/scss/_forcegraph.scss +++ b/scss/_forcegraph.scss @@ -3,58 +3,24 @@ width: 100%; background: url(img/gplaypattern.png); - svg { + canvas { display: block; width: 100%; height: 100%; } - .link { - stroke-opacity: 0.8; + svg { + display: block; + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; - line { - stroke-width: 2.5px; + circle, line { + opacity: 0; + stroke-width: 16px; cursor: pointer; } - - &.highlight line { - stroke-width: 7px; - stroke-dasharray: 10, 10; - opacity: 1; - } - } - - .node { - fill: #fff; - stroke-width: 2.5px; - stroke: #AEC7E8; - - &:not(.unknown) { - cursor: pointer; - } - - &.highlight { - stroke: #FFD486; - stroke-width: 6px; - fill: orange; - point-order: stroke; - } - - &.unknown { - stroke: #d00000; - } - } - - .labels { - text { - font-size: 7pt; - font-family: Roboto; - alignment-baseline: central; - text-anchor: middle; - fill: rgba(0, 0, 0, 0.6); - paint-order: stroke; - stroke-width: 3pt; - stroke: rgba(255, 255, 255, 0.5); - } } }