Skip to content

Commit 61f74eb

Browse files
mbostockFil
andauthored
arrow insets (#658)
* arrow insets * fix arrowhead angle adjustment on inset * document arrow insets Co-authored-by: Philippe Rivière <[email protected]>
1 parent 42a7bed commit 61f74eb

File tree

2 files changed

+67
-10
lines changed

2 files changed

+67
-10
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,9 @@ The arrow mark supports the [standard mark options](#marks). The **stroke** defa
721721
* **bend** - the bend angle, in degrees; defaults to zero
722722
* **headAngle** - the arrowhead angle, in degrees; defaults to 22.5°
723723
* **headLength** - the arrowhead scale; defaults to 8
724+
* **insetEnd** - inset at the end of the arrow (useful if the arrow points to a dot)
725+
* **insetStart** - inset at the start of the arrow
726+
* **inset** - shorthand for the two insets
724727

725728
The **bend** option sets the angle between the straight line between the two points and the outgoing direction of the arrow from the start point. It must be within ±90°. A positive angle will produce a clockwise curve; a negative angle will produce a counterclockwise curve; zero will produce a straight line. The **headAngle** determines how pointy the arrowhead is; it is typically between 0° and 180°. The **headLength** determines the scale of the arrowhead relative to the stroke width. Assuming the default of stroke width 1.5px, the **headLength** is the length of the arrowhead’s side in pixels.
726729

src/marks/arrow.js

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,18 @@ const defaults = {
1515

1616
export class Arrow extends Mark {
1717
constructor(data, options = {}) {
18-
const {x1, y1, x2, y2, bend = 0, headAngle = 60, headLength = 8} = options;
18+
const {
19+
x1,
20+
y1,
21+
x2,
22+
y2,
23+
bend = 0,
24+
headAngle = 60,
25+
headLength = 8,
26+
inset = 0,
27+
insetStart = inset,
28+
insetEnd = inset
29+
} = options;
1930
super(
2031
data,
2132
[
@@ -30,10 +41,12 @@ export class Arrow extends Mark {
3041
this.bend = bend === true ? 22.5 : Math.max(-90, Math.min(90, bend));
3142
this.headAngle = +headAngle;
3243
this.headLength = +headLength;
44+
this.insetStart = +insetStart;
45+
this.insetEnd = +insetEnd;
3346
}
3447
render(I, {x, y}, channels) {
3548
const {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1, SW} = channels;
36-
const {dx, dy, strokeWidth, bend, headAngle, headLength} = this;
49+
const {dx, dy, strokeWidth, bend, headAngle, headLength, insetStart, insetEnd} = this;
3750
const sw = SW ? i => SW[i] : () => strokeWidth;
3851

3952
// When bending, the offset between the straight line between the two points
@@ -59,16 +72,48 @@ export class Arrow extends Mark {
5972
.join("path")
6073
.call(applyDirectStyles, this)
6174
.attr("d", i => {
62-
const x1 = X1[i], y1 = Y1[i], x2 = X2[i], y2 = Y2[i];
63-
const dx = x2 - x1, dy = y2 - y1;
64-
const lineLength = Math.hypot(dx, dy);
65-
const lineAngle = Math.atan2(dy, dx);
75+
let x1 = X1[i], y1 = Y1[i], x2 = X2[i], y2 = Y2[i];
76+
let lineAngle = Math.atan2(y2 - y1, x2 - x1);
77+
const lineLength = Math.hypot(x2 - x1, y2 - y1);
6678

6779
// We don’t allow the wing length to be too large relative to the
6880
// length of the arrow. (Plot.vector allows arbitrarily large
6981
// wings, but that’s okay since vectors are usually small.)
7082
const headLength = Math.min(wingScale * sw(i), lineLength / 3);
7183

84+
// The radius of the circle that intersects with the two endpoints
85+
// and has the specified bend angle.
86+
const r = Math.hypot(lineLength / Math.tan(bendAngle), lineLength) / 2;
87+
88+
// Apply insets.
89+
if (insetStart || insetEnd) {
90+
if (r < 1e5) {
91+
// For inset swoopy arrows, compute the circle-circle
92+
// intersection between a circle centered around the
93+
// respective arrow endpoint and the center of the circle
94+
// segment that forms the shaft of the arrow.
95+
const sign = Math.sign(bendAngle);
96+
const [cx, cy] = pointPointCenter([x1, y1], [x2, y2], r, sign);
97+
if (insetStart) {
98+
([x1, y1] = circleCircleIntersect([cx, cy, r], [x1, y1, insetStart], -sign * Math.sign(insetStart)));
99+
}
100+
// For the end inset, rotate the arrowhead so that it aligns
101+
// with the truncated end of the arrow. Since the arrow is a
102+
// segment of the circle centered at <cx,cy>, we can compute
103+
// the angular difference to the new endpoint.
104+
if (insetEnd) {
105+
const [x, y] = circleCircleIntersect([cx, cy, r], [x2, y2, insetEnd], sign * Math.sign(insetEnd));
106+
lineAngle += Math.atan2(y - cy, x - cx) - Math.atan2(y2 - cy, x2 - cx);
107+
x2 = x, y2 = y;
108+
}
109+
} else {
110+
// For inset straight arrows, offset along the straight line.
111+
const dx = x2 - x1, dy = y2 - y1, d = Math.hypot(dx, dy);
112+
if (insetStart) x1 += dx / d * insetStart, y1 += dy / d * insetStart;
113+
if (insetEnd) x2 -= dx / d * insetEnd, y2 -= dy / d * insetEnd;
114+
}
115+
}
116+
72117
// The angle of the arrow as it approaches the endpoint, and the
73118
// angles of the adjacent wings. Here “left” refers to if the
74119
// arrow is pointing up.
@@ -82,10 +127,6 @@ export class Arrow extends Mark {
82127
const x4 = x2 - headLength * Math.cos(rightAngle);
83128
const y4 = y2 - headLength * Math.sin(rightAngle);
84129

85-
// The radius of the circle that intersects with the two endpoints
86-
// and has the specified bend angle.
87-
const r = Math.hypot(lineLength / Math.tan(bendAngle), lineLength) / 2;
88-
89130
// If the radius is very large (or even infinite, as when the bend
90131
// angle is zero), then render a straight line.
91132
return `M${x1},${y1}${r < 1e5 ? `A${r},${r} 0,0,${bendAngle > 0 ? 1 : 0} ` : `L`}${x2},${y2}M${x3},${y3}L${x2},${y2}L${x4},${y4}`;
@@ -95,6 +136,19 @@ export class Arrow extends Mark {
95136
}
96137
}
97138

139+
function pointPointCenter([ax, ay], [bx, by], r, sign = 1) {
140+
const dx = bx - ax, dy = by - ay, d = Math.hypot(dx, dy);
141+
const k = sign * Math.sqrt(r * r - d * d / 4) / d;
142+
return [(ax + bx) / 2 - dy * k, (ay + by) / 2 + dx * k];
143+
}
144+
145+
function circleCircleIntersect([ax, ay, ar], [bx, by, br], sign = 1) {
146+
const dx = bx - ax, dy = by - ay, d = Math.hypot(dx, dy);
147+
const x = (dx * dx + dy * dy - br * br + ar * ar) / (2 * d);
148+
const y = sign * Math.sign(ay) * Math.sqrt(ar * ar - x * x);
149+
return [ax + (dx * x + dy * y) / d, ay + (dy * x - dx * y) / d];
150+
}
151+
98152
export function arrow(data, {x, x1, x2, y, y1, y2, ...options} = {}) {
99153
([x1, x2] = maybeSameValue(x, x1, x2));
100154
([y1, y2] = maybeSameValue(y, y1, y2));

0 commit comments

Comments
 (0)