Skip to content

Commit 1869ffb

Browse files
mbostockFil
andauthored
* arrow * arrow options; swoop * bend: true instead of swoop * update README * update README * Update README * shorter * arrow insets (observablehq#658) * arrow insets * fix arrowhead angle adjustment on inset * document arrow insets Co-authored-by: Philippe Rivière <fil@rezo.net> Co-authored-by: Philippe Rivière <fil@rezo.net>
1 parent c77b252 commit 1869ffb

File tree

7 files changed

+393
-406
lines changed

7 files changed

+393
-406
lines changed

README.md

+33-1
Original file line numberDiff line numberDiff line change
@@ -703,6 +703,38 @@ Plot.areaY(aapl, {x: "Date", y: "Close"})
703703

704704
Returns a new area with the given *data* and *options*. This constructor is used when the baseline and topline share *x* values, as in a time-series area chart where time goes right→. If neither the **y1** nor **y2** option is specified, the **y** option may be specified as shorthand to apply an implicit [stackY transform](#plotstackystack-options); this is the typical configuration for an area chart with a baseline at *y* = 0. If the **y** option is not specified, it defaults to the identity function. The **x** option specifies the **x1** channel; and the **x1** and **x2** options are ignored.
705705

706+
### Arrow
707+
708+
[<img src="./img/arrow.png" width="320" height="198" alt="a scatterplot with arrows">](https://observablehq.com/@observablehq/plot-arrow)
709+
710+
[Source](./src/marks/arrow.js) · [Examples](https://observablehq.com/@observablehq/plot-arrow) · Draws arrows (possibly swoopy arrows) connecting pairs of points.
711+
712+
The following channels are required:
713+
714+
* **x1** - the starting horizontal position; bound to the *x* scale
715+
* **y1** - the starting vertical position; bound to the *y* scale
716+
* **x2** - the ending horizontal position; bound to the *x* scale
717+
* **y2** - the ending vertical position; bound to the *y* scale
718+
719+
The arrow mark supports the [standard mark options](#marks). The **stroke** defaults to currentColor. The **fill** defaults to none. The **strokeWidth** and **strokeMiterlimit** default to one. The following additional options are supported:
720+
721+
* **bend** - the bend angle, in degrees; defaults to zero
722+
* **headAngle** - the arrowhead angle, in degrees; defaults to 22.5°
723+
* **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
727+
728+
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.
729+
730+
#### Plot.arrow(*data*, *options*)
731+
732+
```js
733+
Plot.arrow(inequality, {x1: "POP_1980", y1: "R90_10_1980", x2: "POP_2015", y2: "R90_10_2015", bend: true})
734+
```
735+
736+
Returns a new arrow with the given *data* and *options*.
737+
706738
### Bar
707739

708740
[<img src="./img/bar.png" width="320" height="198" alt="a bar chart">](https://observablehq.com/@observablehq/plot-bar)
@@ -929,7 +961,7 @@ The following channels are required:
929961

930962
The link mark supports the [standard mark options](#marks). The **stroke** defaults to currentColor. The **fill** defaults to none. The **strokeWidth** and **strokeMiterlimit** default to one.
931963

932-
The link mark supports [curve options](#curves) to control interpolation between points. Since a link always has two points by definition, only the following curves (or a custom curve) are recommended: *linear*, *step*, *step-after*, *step-before*, *bump-x*, or *bump-y*. Note that the *linear* curve is incapable of showing a fill since a straight line has zero area.
964+
The link mark supports [curve options](#curves) to control interpolation between points. Since a link always has two points by definition, only the following curves (or a custom curve) are recommended: *linear*, *step*, *step-after*, *step-before*, *bump-x*, or *bump-y*. Note that the *linear* curve is incapable of showing a fill since a straight line has zero area. For a curved link, you can use a bent [arrow](#arrow) (with no arrowhead, if desired).
933965

934966
#### Plot.link(*data*, *options*)
935967

img/arrow.png

293 KB
Loading

src/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export {plot, Mark, marks} from "./plot.js";
22
export {Area, area, areaX, areaY} from "./marks/area.js";
3+
export {Arrow, arrow} from "./marks/arrow.js";
34
export {BarX, BarY, barX, barY} from "./marks/bar.js";
45
export {Cell, cell, cellX, cellY} from "./marks/cell.js";
56
export {Dot, dot, dotX, dotY} from "./marks/dot.js";

src/marks/arrow.js

+155
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import {create} from "d3";
2+
import {radians} from "../math.js";
3+
import {Mark} from "../plot.js";
4+
import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform, offset} from "../style.js";
5+
import {maybeSameValue} from "./link.js";
6+
7+
const defaults = {
8+
fill: "none",
9+
stroke: "currentColor",
10+
strokeLinecap: "round",
11+
strokeMiterlimit: 1,
12+
strokeWidth: 1.5
13+
};
14+
15+
export class Arrow extends Mark {
16+
constructor(data, options = {}) {
17+
const {
18+
x1,
19+
y1,
20+
x2,
21+
y2,
22+
bend = 0,
23+
headAngle = 60,
24+
headLength = 8,
25+
inset = 0,
26+
insetStart = inset,
27+
insetEnd = inset
28+
} = options;
29+
super(
30+
data,
31+
[
32+
{name: "x1", value: x1, scale: "x"},
33+
{name: "y1", value: y1, scale: "y"},
34+
{name: "x2", value: x2, scale: "x", optional: true},
35+
{name: "y2", value: y2, scale: "y", optional: true}
36+
],
37+
options,
38+
defaults
39+
);
40+
this.bend = bend === true ? 22.5 : Math.max(-90, Math.min(90, bend));
41+
this.headAngle = +headAngle;
42+
this.headLength = +headLength;
43+
this.insetStart = +insetStart;
44+
this.insetEnd = +insetEnd;
45+
}
46+
render(index, {x, y}, channels) {
47+
const {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1, SW} = channels;
48+
const {dx, dy, strokeWidth, bend, headAngle, headLength, insetStart, insetEnd} = this;
49+
const sw = SW ? i => SW[i] : () => strokeWidth;
50+
51+
// When bending, the offset between the straight line between the two points
52+
// and the outgoing tangent from the start point. (Also the negative
53+
// incoming tangent to the end point.) This must be within ±π/2. A positive
54+
// angle will produce a clockwise curve; a negative angle will produce a
55+
// counterclockwise curve; zero will produce a straight line.
56+
const bendAngle = bend * radians;
57+
58+
// The angle between the arrow’s shaft and one of the wings; the “head”
59+
// angle between the wings is twice this value.
60+
const wingAngle = headAngle * radians / 2;
61+
62+
// The length of the arrowhead’s “wings” (the line segments that extend from
63+
// the end point) relative to the stroke width.
64+
const wingScale = headLength / 1.5;
65+
66+
return create("svg:g")
67+
.call(applyIndirectStyles, this)
68+
.call(applyTransform, x, y, offset + dx, offset + dy)
69+
.call(g => g.selectAll()
70+
.data(index)
71+
.join("path")
72+
.call(applyDirectStyles, this)
73+
.attr("d", i => {
74+
let x1 = X1[i], y1 = Y1[i], x2 = X2[i], y2 = Y2[i];
75+
let lineAngle = Math.atan2(y2 - y1, x2 - x1);
76+
const lineLength = Math.hypot(x2 - x1, y2 - y1);
77+
78+
// We don’t allow the wing length to be too large relative to the
79+
// length of the arrow. (Plot.vector allows arbitrarily large
80+
// wings, but that’s okay since vectors are usually small.)
81+
const headLength = Math.min(wingScale * sw(i), lineLength / 3);
82+
83+
// The radius of the circle that intersects with the two endpoints
84+
// and has the specified bend angle.
85+
const r = Math.hypot(lineLength / Math.tan(bendAngle), lineLength) / 2;
86+
87+
// Apply insets.
88+
if (insetStart || insetEnd) {
89+
if (r < 1e5) {
90+
// For inset swoopy arrows, compute the circle-circle
91+
// intersection between a circle centered around the
92+
// respective arrow endpoint and the center of the circle
93+
// segment that forms the shaft of the arrow.
94+
const sign = Math.sign(bendAngle);
95+
const [cx, cy] = pointPointCenter([x1, y1], [x2, y2], r, sign);
96+
if (insetStart) {
97+
([x1, y1] = circleCircleIntersect([cx, cy, r], [x1, y1, insetStart], -sign * Math.sign(insetStart)));
98+
}
99+
// For the end inset, rotate the arrowhead so that it aligns
100+
// with the truncated end of the arrow. Since the arrow is a
101+
// segment of the circle centered at <cx,cy>, we can compute
102+
// the angular difference to the new endpoint.
103+
if (insetEnd) {
104+
const [x, y] = circleCircleIntersect([cx, cy, r], [x2, y2, insetEnd], sign * Math.sign(insetEnd));
105+
lineAngle += Math.atan2(y - cy, x - cx) - Math.atan2(y2 - cy, x2 - cx);
106+
x2 = x, y2 = y;
107+
}
108+
} else {
109+
// For inset straight arrows, offset along the straight line.
110+
const dx = x2 - x1, dy = y2 - y1, d = Math.hypot(dx, dy);
111+
if (insetStart) x1 += dx / d * insetStart, y1 += dy / d * insetStart;
112+
if (insetEnd) x2 -= dx / d * insetEnd, y2 -= dy / d * insetEnd;
113+
}
114+
}
115+
116+
// The angle of the arrow as it approaches the endpoint, and the
117+
// angles of the adjacent wings. Here “left” refers to if the
118+
// arrow is pointing up.
119+
const endAngle = lineAngle + bendAngle;
120+
const leftAngle = endAngle + wingAngle;
121+
const rightAngle = endAngle - wingAngle;
122+
123+
// The endpoints of the two wings.
124+
const x3 = x2 - headLength * Math.cos(leftAngle);
125+
const y3 = y2 - headLength * Math.sin(leftAngle);
126+
const x4 = x2 - headLength * Math.cos(rightAngle);
127+
const y4 = y2 - headLength * Math.sin(rightAngle);
128+
129+
// If the radius is very large (or even infinite, as when the bend
130+
// angle is zero), then render a straight line.
131+
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}`;
132+
})
133+
.call(applyChannelStyles, this, channels))
134+
.node();
135+
}
136+
}
137+
138+
function pointPointCenter([ax, ay], [bx, by], r, sign = 1) {
139+
const dx = bx - ax, dy = by - ay, d = Math.hypot(dx, dy);
140+
const k = sign * Math.sqrt(r * r - d * d / 4) / d;
141+
return [(ax + bx) / 2 - dy * k, (ay + by) / 2 + dx * k];
142+
}
143+
144+
function circleCircleIntersect([ax, ay, ar], [bx, by, br], sign = 1) {
145+
const dx = bx - ax, dy = by - ay, d = Math.hypot(dx, dy);
146+
const x = (dx * dx + dy * dy - br * br + ar * ar) / (2 * d);
147+
const y = sign * Math.sign(ay) * Math.sqrt(ar * ar - x * x);
148+
return [ax + (dx * x + dy * y) / d, ay + (dy * x - dx * y) / d];
149+
}
150+
151+
export function arrow(data, {x, x1, x2, y, y1, y2, ...options} = {}) {
152+
([x1, x2] = maybeSameValue(x, x1, x2));
153+
([y1, y2] = maybeSameValue(y, y1, y2));
154+
return new Arrow(data, {...options, x1, x2, y1, y2});
155+
}

src/marks/link.js

+6-6
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const defaults = {
1111

1212
export class Link extends Mark {
1313
constructor(data, options = {}) {
14-
const {x1, y1, x2, y2, curve} = options;
14+
const {x1, y1, x2, y2, curve, tension} = options;
1515
super(
1616
data,
1717
[
@@ -23,11 +23,11 @@ export class Link extends Mark {
2323
options,
2424
defaults
2525
);
26-
this.curve = Curve(curve);
26+
this.curve = Curve(curve, tension);
2727
}
2828
render(index, {x, y}, channels) {
2929
const {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1} = channels;
30-
const {dx, dy} = this;
30+
const {dx, dy, curve} = this;
3131
return create("svg:g")
3232
.call(applyIndirectStyles, this)
3333
.call(applyTransform, x, y, offset + dx, offset + dy)
@@ -37,12 +37,12 @@ export class Link extends Mark {
3737
.call(applyDirectStyles, this)
3838
.attr("d", i => {
3939
const p = path();
40-
const c = this.curve(p);
40+
const c = curve(p);
4141
c.lineStart();
4242
c.point(X1[i], Y1[i]);
4343
c.point(X2[i], Y2[i]);
4444
c.lineEnd();
45-
return `${p}`;
45+
return p;
4646
})
4747
.call(applyChannelStyles, this, channels))
4848
.node();
@@ -58,7 +58,7 @@ export function link(data, {x, x1, x2, y, y1, y2, ...options} = {}) {
5858
// If x1 and x2 are specified, return them as {x1, x2}.
5959
// If x and x1 and specified, or x and x2 are specified, return them as {x1, x2}.
6060
// If only x, x1, or x2 are specified, return it as {x1}.
61-
function maybeSameValue(x, x1, x2) {
61+
export function maybeSameValue(x, x1, x2) {
6262
if (x === undefined) {
6363
if (x1 === undefined) {
6464
if (x2 !== undefined) return [x2];

0 commit comments

Comments
 (0)