Skip to content

Instantly share code, notes, and snippets.

@madelfio
Last active August 29, 2015 14:01
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save madelfio/d0f24814d52ded666b05 to your computer and use it in GitHub Desktop.
Save madelfio/d0f24814d52ded666b05 to your computer and use it in GitHub Desktop.
Itineraries II

Visualization of a simulated annealing algorithm for laying out itineraries. Each rectangle contains an ordered set of stops (an "itinerary") to be rendered. The algorithm searches for an appropriate layout by modifying the curvature of lines between consecutive stops and the positions of the stop labels. Layouts are penalized for intersections between elements, extending outside of the bounding rectangle, and violating other aesthetic criteria. A new layout is accepted if it is better than the previous layout or with some probability that tends to 0 if it is worse.

Simulated annealing avoids the cost of an exhaustive brute-force approach, while increasing the odds of finding the global optimum compared to a greedy solution.

<!DOCTYPE html>
<meta charset="utf-8">
<style>
body {
text-align: center;
margin: 0;
}
path {
stroke: #999;
stroke-width: 2;
fill: none;
}
path.leader {
stroke: #bcb;
stroke-width: 1;
}
text {
font: 10px sans-serif;
pointer-events: none;
text-shadow: 0 1px 0 #fff, 1px 0 0 #fff, 0 -1px 0 #fff, -1px 0 0 #fff;
}
</style>
<body>
<div id="container">
</div>
<script src="http://d3js.org/d3.v3.min.js"></script>
<script src="./itineraries.js"></script>
</body>
"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 '';
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment