"use strict"; var π = Math.PI, margin = 5, padding = 15, width = 960, height = 500, iwidth = (width - margin) / 5 - margin, iheight = (height - margin) / 3 - margin, node_radius = 5, node_buffer = 1, label_buffer = 1, char_depth = 3, char_height = 9; var x = d3.scale.linear() .rangeRound([padding, iwidth - padding]); var y = d3.scale.linear() .rangeRound([padding, iheight - padding]); function randPoint() {return {x: x(Math.random()), y: y(Math.random())};} var stops = [ [{x:20,y:50},{x:95,y:65},{x:140,y:55},{x:100,y:95},{x:70,y:80},{x:20,y:50}], [{x:15,y:20},{x:20,y:30},{x:100,y:50},{x:95,y:60},{x:105,y:40},{x:60,y:20}], [{x:40,y:60},{x:50,y:70},{x:60,y:60},{x:70,y:70},{x:80,y:60},{x:90,y:80}], [{x:49,y:89},{x:100,y:110},{x:38,y:69},{x:115,y:97},{x:114,y:57},{x:27,y:57},{x:96,y:28}], [{x:28,y:87},{x:31,y:100},{x:129,y:127},{x:135,y:80},{x:137,y:96},{x:97,y:128},{x:97,y:74},{x:59,y:82},{x:57,y:125}], [{x:60,y:63},{x:114,y:25},{x:129,y:36},{x:74,y:19},{x:47,y:100},{x:32,y:29},{x:72,y:74}], d3.range(9).map(randPoint), d3.range(9).map(randPoint), d3.range(8).map(randPoint), d3.range(8).map(randPoint), d3.range(7).map(randPoint), d3.range(7).map(randPoint), d3.range(6).map(randPoint), d3.range(6).map(randPoint), d3.range(5).map(randPoint), ]; stops.forEach(function(sl) { sl.forEach(function(s, i) { s.name = 'Stop ' + 'ABCDEFGHIJKLMNOP'.split('')[i]; }); }); stops[0][5].name = stops[0][0].name; // general purpose geometry functions function rectsOverlap(r1, r2) { var r1x1, r1x2, r1y1, r1y2, r2x1, r2x2, r2y1, r2y2; r1x1 = r1.x; r1y1 = r1.y; if (r1.hasOwnProperty('w')) { r1x2 = r1.x + r1.w; r1y2 = r1.y + r1.h; } else if (r1.hasOwnProperty('x2')) { r1x2 = r1.x2; r1y2 = r1.y2; } r2x1 = r2.x; r2y1 = r2.y; if (r2.hasOwnProperty('w')) { r2x2 = r2.x + r2.w; r2y2 = r2.y + r2.h; } else if (r2.hasOwnProperty('x2')) { r2x2 = r2.x2; r2y2 = r2.y2; } return (r1x1 < r2x2 && r1x2 > r2x1 && r1y1 < r2y2 && r1y2 > r2y1); } function pointInRect(p, r) { if (r.hasOwnProperty('w')) { return (p.x > r.x && p.x < r.x + r.w && p.y > r.y && p.y < r.y + r.h); } else if (r.hasOwnProperty('x2')) { return (p.x > r.x && p.x < r.x2 && p.y > r.y && p.y < r.y2); } return false; } // given ray angle and w/h for rectangle, return point on rectangle that a ray // from the rectangle's center with the given angle would pass through function intersect(theta, w, h) { var dx = Math.abs(1/Math.tan(theta)) * (Math.cos(theta) > 0 ? 1 : -1), dy = Math.abs(Math.tan(theta)) * (Math.sin(theta) > 0 ? 1 : -1), x = dx * h/2, y = dy * w/2; return { x: x > w/2 ? w/2 : x < -w/2 ? -w/2 : x, y: y > h/2 ? h/2 : y < -h/2 ? -h/2 : y }; } // Itinerary takes an array of stops (with lat, lon, point name or x, y, point // name) and generates node, link, and label positions that have low energy // based on several criteria. The positions are found using simulated // annealing and incremental improvement. // // Parameters are the link "ratios" (how far from the midpoint along a link // the bezier control point is placed, measured in multipls of the link // length) and the r and theta values for the labels (theta is the direction // of the centroid of the label, r is the distance to the closest point). var Itinerary = function() { var itinerary = {}, event = d3.dispatch('start', 'tick', 'end'), size = [1, 1], nodes = [], links = []; var projection; var temperature, start_temperature = 100, temp_decay = 0.98, temp_accept = 0.00001, // energy = 1e6, // targets r_opt = 0.1, label_r_opt = 11; // energy var label_outside = 100, link_repulsion = 50, link_node_repulsion = 100, label_repulsion = 80, label_link_repulsion = 30, label_node_repulsion = 15, link_pair_repulsion = 10, link_ratio_dev = 5, label_dist = 1; var state_improves = 0, state_accepts = 0, state_rejects = 0; itinerary.debugState = function() { var candidates = createCandidates(); itinerary.augment(candidates.nodes, candidates.links); computeEnergy(candidates.nodes, candidates.links, true); computeEnergy(nodes, links, true); }; // computes energy of layout. optimal energy is 0. function computeEnergy(nodes, links, debug) { // link/link intersection (0/1) // link/node intersection (linear from node radius (0) to center (1)) // label/label intersections (1.0 for complete intersection, less for partial) // label/link intersections (0.5 for intersection with neighboring link) // label/node intersections (0/1) // angle between consecutive links (1/(theta/30° + 1)) // ratio deviation (Gaussian function on abs(r), peak at .25, 1. SD=.15) // [specifically: y = 1-e^(-(x-.25)^2/(2*.2^2))-e^(-(x+.25)^2/(2*.2^2))] // diff between consecutive edge ratios var console = window.console; if (!debug) {console = {log: function() {}};} console.log('Printing debug info...'); var i = 0; function pe() { console.log('Total energy (', i++, ':', e.toFixed(4), ')'); } var e = 0, d; nodes.forEach(function(n, i) { if (n.label.x < 0 || n.label.x + n.label.w > size[0] || n.label.y + char_depth - n.label.h < 0 || n.label.y + char_depth > size[1]) { console.log('Label', i, '(', n.name, ') is outside visible area'); e += label_outside; } }); pe(); links.forEach(function(l1, i) { links.forEach(function(l2, j) { if (j <= i) {return;} if (linksIntersect(l1, l2)) { console.log('Link', i, '(', l1.source.name, '-', l1.target.name, ') overlaps link', j, '(', l2.source.name, '-', l2.target.name, ')'); e += link_repulsion; } }); }); pe(); links.forEach(function(l, i) { nodes.forEach(function(n, j) { if (l.source.name == n.name || l.target.name == n.name) {return;} var d = linkNodeDistance(l, n); if (d <= node_radius + node_buffer) { console.log('Link', i, '(', l.source.name, '-', l.target.name, ') overlaps node', j, '(', n.name, ') at a distance of', d.toFixed(2)); e += (1 - d/(node_radius + node_buffer + 2)) * link_node_repulsion; } }); }); pe(); nodes.forEach(function(n1, i) { nodes.forEach(function(n2, j) { if (j <= i) {return;} var o = labelOverlap(n1, n2); if (o > 0) { console.log('Label', i, '(', n1.name, ') overlaps label', j, '(', n2.name, ') with weight', o); e += o * label_repulsion; } }); }); pe(); nodes.forEach(function(n, i) { links.forEach(function(l, j) { var lli = labelLinkIntersection(n, l); if (lli > 0) { console.log('Label', i, '(', n.name, ') overlaps link', j, '(', l.source.name, '-', l.target.name, ') with weight', lli); e += lli * label_link_repulsion; } }); }); pe(); nodes.forEach(function(n1, i) { nodes.forEach(function(n2, j) { var lni = labelNodeIntersection(n1, n2); if (lni > 0) { console.log('Label', i, '(', n1.name, ') overlaps node', j, '(', n2.name, ')'); e += label_node_repulsion; } }); }); pe(); links.forEach(function(l1, i) { if (!l1.next) {return;} var l2 = l1.next, a1 = Math.atan2(l1.q.y - l1.target.y, l1.q.x - l1.target.x), a2 = Math.atan2(l2.q.y - l2.source.y, l2.q.x - l2.source.x), delta = π - Math.abs(π - Math.abs(a2 - a1)), m = (1/(Math.abs(delta) / (π/15) + 1)); console.log('Link', i, '(', l1.source.name, '-', l1.target.name, ') repels next link (', l2.source.name, '-', l2.target.name, 'by', m); e += m * link_pair_repulsion; }); pe(); links.forEach(function(l) { e += Math.pow(10 * Math.abs(Math.abs(l.r) - 0.3), 2) * link_ratio_dev; }); pe(); nodes.forEach(function(n, i) { var m = Math.pow(Math.abs(n.label.r - label_r_opt) / 10, 2); console.log('Label', i, '(', n.name, ') distance adds energy:', m.toFixed(4)); e += m * label_dist; }); console.log('Total energy:', e.toFixed(4)); return e; } function bez(v1, v2, v3, t) { return (1-t) * ((1-t) * v1 + t * v2) + t * ((1 - t) * v2 + t * v3); } // linkLinkIntersect: test whether an link intersects with another link function linksIntersect(l1, l2) { // test if bounding boxes intersect. If no, return false. // If both bounding boxes have area < 9px^2, return true if // intersection is not at endpoitns, else return false // Otherwise, return disjunction of intersection tests of half-edges var area_threshold = 2; var l1xmin = Math.min(l1.source.x, l1.q.x, l1.target.x), l1xmax = Math.max(l1.source.x, l1.q.x, l1.target.x), l1ymin = Math.min(l1.source.y, l1.q.y, l1.target.y), l1ymax = Math.max(l1.source.y, l1.q.y, l1.target.y), l2xmin = Math.min(l2.source.x, l2.q.x, l2.target.x), l2xmax = Math.max(l2.source.x, l2.q.x, l2.target.x), l2ymin = Math.min(l2.source.y, l2.q.y, l2.target.y), l2ymax = Math.max(l2.source.y, l2.q.y, l2.target.y); var bb_intersect = (l1xmin < l2xmax && l1xmax > l2xmin && l1ymin < l2ymax && l1ymax > l2ymin), small_bb = ((l1xmax - l1xmin) * (l1ymax - l1ymin) < area_threshold && (l2xmax - l2xmin) * (l2ymax - l2ymin) < area_threshold), shared_endpoint = ((l1.source == l2.source) || (l1.source == l2.target) || (l1.target == l2.source) || (l1.target == l2.target)); if (!bb_intersect) {return false;} if (small_bb) { if (shared_endpoint) { return false; } return true; } var l11 = { source: l1.source, q: {x: (l1.source.x + l1.q.x) /2, y: (l1.source.y + l1.q.y) /2}, target: {x: (l1.source.x + 2 * l1.q.x + l1.target.x) / 4, y: (l1.source.y + 2 * l1.q.y + l1.target.y) / 4} }; var l12 = { source: l11.target, q: {x: (l1.target.x + l1.q.x) / 2, y: (l1.target.y + l1.q.y) / 2}, target: l1.target }; var l21 = { source: l2.source, q: {x: (l2.source.x + l2.q.x) /2, y: (l2.source.y + l2.q.y) /2}, target: {x: (l2.source.x + 2 * l2.q.x + l2.target.x) / 4, y: (l2.source.y + 2 * l2.q.y + l2.target.y) / 4} }; var l22 = { source: l21.target, q: {x: (l2.target.x + l2.q.x) / 2, y: (l2.target.y + l2.q.y) / 2}, target: l2.target }; return (linksIntersect(l11, l21) || linksIntersect(l11, l22) || linksIntersect(l12, l21) || linksIntersect(l12, l22)); } // linkNodeIntersect: test whether an link intersects with a node function linkNodeDistance(l, n) { var t1 = 0, t2 = 1, t_mid, ox, oy, pt1x, pt1y, pt2x, pt2y, diff = 1, d1, d2; // compute d1 (distance from p(t1) to n), d2 (from p(t2) to n) // if d1 < d2, set t2 = (t1+t2)/2, else set t1 = (t1+t2)/2 // repeat until p(t1) and p(t2) don't change pt1x = bez(l.source.x, l.q.x, l.target.x, t1); pt1y = bez(l.source.y, l.q.y, l.target.y, t1); pt2x = bez(l.source.x, l.q.x, l.target.x, t2); pt2y = bez(l.source.y, l.q.y, l.target.y, t2); while (diff > 0) { d1 = Math.pow(n.x - pt1x, 2) + Math.pow(n.y - pt1y, 2); d2 = Math.pow(n.x - pt2x, 2) + Math.pow(n.y - pt2y, 2); t_mid = (t1 + t2) / 2; if (d1 < d2) { t2 = t_mid; ox = pt2x; oy = pt2y; pt2x = bez(l.source.x, l.q.x, l.target.x, t2); pt2y = bez(l.source.y, l.q.y, l.target.y, t2); diff = Math.round(Math.abs(pt2x - ox) + Math.abs(pt2y - oy)); } else { t1 = t_mid; ox = pt1x; oy = pt1y; pt1x = bez(l.source.x, l.q.x, l.target.x, t1); pt1y = bez(l.source.y, l.q.y, l.target.y, t1); diff = Math.round(Math.abs(pt1x - ox) + Math.abs(pt1y - oy)); } } return Math.min(Math.sqrt(d1), Math.sqrt(d2)); } function labelOverlap(node1, node2) { return (node1.label.x - label_buffer < node2.label.x + node2.label.w + label_buffer && node1.label.y + char_depth - node1.label.h - label_buffer < node2.label.y + char_depth + label_buffer && node1.label.x + node1.label.w + label_buffer > node2.label.x - label_buffer && node1.label.y + char_depth + label_buffer > node2.label.y + char_depth - node2.label.h - label_buffer); } function labelLinkIntersection(label_node, link) { // test if bounding boxes intersect. If no, return false. // If link has point inside label bb, return true. // Otherwise, recurse on half-links var label = label_node.label, area_threshold = 9, label_rect = { x: label.x, y: label.y + char_depth - label.h, w: label.w, h: label.h }, link_rect = { x: Math.min(link.source.x, link.q.x, link.target.x), y: Math.min(link.source.y, link.q.y, link.target.y), x2: Math.max(link.source.x, link.q.x, link.target.x), y2: Math.max(link.source.y, link.q.y, link.target.y) }; var bb_intersect = rectsOverlap(link_rect, label_rect); if (!bb_intersect) {return false;} if ((link_rect.x2 - link_rect.x) * (link_rect.y2 - link_rect.y) < area_threshold) { return true; } if (pointInRect(link.source, label_rect)) {return true;} var link1 = { source: link.source, q: {x: (link.source.x + link.q.x) /2, y: (link.source.y + link.q.y) /2}, target: {x: (link.source.x + 2 * link.q.x + link.target.x) / 4, y: (link.source.y + 2 * link.q.y + link.target.y) / 4} }; var link2 = { source: link1.target, q: {x: (link.target.x + link.q.x) / 2, y: (link.target.y + link.q.y) / 2}, target: link.target }; return (labelLinkIntersection(label_node, link1) || labelLinkIntersection(label_node, link2)); } function labelNodeIntersection(label_node, node) { var s = node_radius + node_buffer; return (label_node.label.x - label_buffer < node.x + s && label_node.label.y + char_depth - label_node.label.h - label_buffer < node.y + s && label_node.label.x + label_node.label.w + label_buffer > node.x - s && label_node.label.y + char_depth + label_buffer > node.y - s); } // initialize from stops itinerary.stops = function(x) { nodes = x; links = []; nodes.forEach(function(n, i) { if (i < nodes.length - 1) {links.push({source: n, target: nodes[i+1], r: 0.3});} }); nodes.forEach(function(n) {n.label = n.label || {r: 10, θ: Math.random()*π};}); links.forEach(function(l, i) { l.next = links[i+1]; }); var first = nodes[0], last = nodes[nodes.length - 1]; if (first.x == last.x && first.y == last.y && first.name == last.name) { links[links.length - 1].next = links[0]; nodes.pop(); } return itinerary; }; itinerary.nodes = function(x) { if (!arguments.length) return nodes; nodes = x; itinerary.augment(nodes, links); return itinerary; }; itinerary.links = function(x) { if (!arguments.length) return links; links = x; itinerary.augment(nodes, links); return itinerary; }; itinerary.size = function(x) { if (!arguments.length) return size; size = x; return itinerary; }; itinerary.start = function() { itinerary.augment(nodes, links); if (!temperature) { event.start({ type: 'start', temperature: temperature = start_temperature }); d3.timer(itinerary.tick); } return itinerary; }; itinerary.stop = function() { temperature = 0; return itinerary; }; function createCandidates() { var c_nodes = nodes.map(function(n) { return { x: n.x, y: n.y, name: n.name, label: { r: n.label.r, θ: n.label.θ } }; }); var c_links = links.map(function(l) { return { source: {name: l.source.name, x: l.source.x, y: l.source.y}, target: {name: l.target.name, x: l.target.x, y: l.target.y}, next_idx: links.indexOf(l.next), r: l.r }; }); c_links.forEach(function(l) { l.next = c_links[l.next_idx]; }); return {nodes: c_nodes, links: c_links}; } itinerary.tick = function() { var i; if ((temperature *= temp_decay) < temp_accept) { event.end({ type: 'end', temperature: temperature = 0 }); return true; } // create candidate state as copy of current state var candidates = createCandidates(); // modify one label or link of candidate state var p = Math.random(), n, l; if (p < 0.45) { n = candidates.nodes[Math.floor(Math.random() * candidates.nodes.length)]; n.label = { r: n.label.r, θ: n.label.θ + (Math.random() - Math.random()) * π, }; n.dirty = true; } else if (p < 0.9) { l = candidates.links[Math.floor(Math.random() * candidates.links.length)]; l.r = l.r + (Math.random() - Math.random()); l.dirty = true; } else { n = candidates.nodes[Math.floor(Math.random() * candidates.nodes.length)]; n.label = { r: Math.max(n.label.r + (Math.random() - Math.random()) * 8, 0), θ: n.label.θ }; n.dirty = true; } // add derived attributes for candidate itinerary.augment(candidates.nodes, candidates.links); // evaluate change in energy for candidate var e = computeEnergy(candidates.nodes, candidates.links) / 2; var de = (e - energy); var z0, z, accept=false; // if de < 0 accept change // else if rand < e^(-de/T) then accept if (de < 0) { accept = true; state_improves++; } else if (Math.pow(Math.E, -de / temperature) > Math.random()) { accept = true; state_accepts++; } else { state_rejects++; } if (accept) { nodes.forEach(function(n, i) { var cn = candidates.nodes[i]; n.label.r = cn.label.r; n.label.θ = cn.label.θ; n.dirty = cn.dirty; }); links.forEach(function(l, i) { var cl = candidates.links[i]; l.r = cl.r; l.dirty = cl.dirty; }); itinerary.augment(nodes, links); energy = e; } event.tick({ type: 'tick', temperature: temperature, energy: energy, }); }; // augment uses label θ and r to compute x and y, and link r to compute q itinerary.augment = function(nodes, links) { nodes.forEach(function(n) { n.leader = { x: Math.cos(n.label.θ) * n.label.r + n.x, y: Math.sin(n.label.θ) * n.label.r + n.y }; n.label.w = 5.5 * n.name.length; n.label.h = char_depth + char_height; var rect_pt = intersect(n.label.θ, n.label.w, n.label.h); n.label.x = n.leader.x + rect_pt.x - n.label.w / 2; n.label.y = n.leader.y + rect_pt.y + n.label.h / 2 - char_depth; }); links.forEach(function(l) { var s = l.source, t = l.target; l.q = { x: Math.round((s.x + t.x) / 2 + (t.y - s.y) * l.r), y: Math.round((s.y + t.y) / 2 - (t.x - s.x) * l.r) }; }); }; return d3.rebind(itinerary, event, 'on'); }; var color = d3.scale.category10(); var svg = d3.select('#container') .append('svg') .attr('width', width) .attr('height', height); var itineraries = [], ticks = []; stops.forEach(function(s, idx) { var g = svg.append('g') .attr('transform', ('translate(' + (margin + (idx % 5) * (iwidth + margin)) + ',' + (margin + Math.floor(idx / 5) * (iheight + margin)) + ')')); var boundary = g.append('rect') .attr('class', 'boundary') .attr('width', iwidth) .attr('height', iheight) .attr('fill', 'none') .attr('stroke', 'black'); var itinerary = Itinerary() .stops(s) .size([iwidth, iheight]) .on('tick', tick) .start(); itineraries.push(itinerary); var leader = g.selectAll('path.leader').data(itinerary.nodes()) .enter().append('path') .attr('class', 'leader') .attr('d', leaderArc); var circle = g.selectAll('circle.stop').data(itinerary.nodes()) .enter().append('circle') .attr('class', 'stop') .attr('cx', function(d) {return d.x;}) .attr('cy', function(d) {return d.y;}) .attr('fill', function(d) {return color(idx);}) .attr('r', node_radius); var path = g.selectAll('path.link').data(itinerary.links()) .enter().append('path') .attr('class', 'link') .attr('d', linkArc); var text = g.selectAll('text.label').data(itinerary.nodes()) .enter().append('text') .attr('class', 'label') .attr('x', function(d) {return Math.round(d.label.x);}) .attr('y', function(d) {return Math.round(d.label.y);}) .text(function(d) {return d.name;}); g.on('click', function() { itinerary.debugState(); }); function tick(e) { path.filter(function(d) {return d.dirty;}) .transition().attr('d', linkArc); text.filter(function(d) {return d.dirty;}) .transition().attr('x', function(d) {return Math.round(d.label.x);}) .attr('y', function(d) {return Math.round(d.label.y);}); leader.filter(function(d) {return d.dirty;}) .transition().attr('d', leaderArc); itinerary.nodes().forEach(function(d) {d.dirty = false;}); itinerary.links().forEach(function(d) {d.dirty = false;}); } ticks.push(tick); }); function linkArc(d) { if (d.q) { return 'M' + d.source.x + ',' + d.source.y + 'Q' + d.q.x + ',' + d.q.y + ',' + d.target.x + ',' + d.target.y; } return 'M' + d.source.x + ',' + d.source.y + 'L' + d.target.x + ',' + d.target.y; } function leaderArc(d) { if (d.leader) { return 'M' + d.x + ',' + d.y + 'L' + d.leader.x + ',' + d.leader.y; } return ''; }