|
"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 ''; |
|
} |