Skip to content
Merged

arrow #657

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,38 @@ Plot.areaY(aapl, {x: "Date", y: "Close"})

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.

### Arrow

[<img src="./img/arrow.png" width="320" height="198" alt="a scatterplot with arrows">](https://observablehq.com/@observablehq/plot-arrow)

[Source](./src/marks/arrow.js) · [Examples](https://observablehq.com/@observablehq/plot-arrow) · Draws arrows (possibly swoopy arrows) connecting pairs of points.

The following channels are required:

* **x1** - the starting horizontal position; bound to the *x* scale
* **y1** - the starting vertical position; bound to the *y* scale
* **x2** - the ending horizontal position; bound to the *x* scale
* **y2** - the ending vertical position; bound to the *y* scale

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:

* **bend** - the bend angle, in degrees; defaults to zero
* **headAngle** - the arrowhead angle, in degrees; defaults to 22.5°
* **headLength** - the arrowhead scale; defaults to 8
* **insetEnd** - inset at the end of the arrow (useful if the arrow points to a dot)
* **insetStart** - inset at the start of the arrow
* **inset** - shorthand for the two insets

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.

#### Plot.arrow(*data*, *options*)

```js
Plot.arrow(inequality, {x1: "POP_1980", y1: "R90_10_1980", x2: "POP_2015", y2: "R90_10_2015", bend: true})
```

Returns a new arrow with the given *data* and *options*.

### Bar

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

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.

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.
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).

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

Expand Down
Binary file added img/arrow.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export {plot, Mark, marks} from "./plot.js";
export {Area, area, areaX, areaY} from "./marks/area.js";
export {Arrow, arrow} from "./marks/arrow.js";
export {BarX, BarY, barX, barY} from "./marks/bar.js";
export {Cell, cell, cellX, cellY} from "./marks/cell.js";
export {Dot, dot, dotX, dotY} from "./marks/dot.js";
Expand Down
155 changes: 155 additions & 0 deletions src/marks/arrow.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import {create} from "d3";
import {radians} from "../math.js";
import {Mark} from "../plot.js";
import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform, offset} from "../style.js";
import {maybeSameValue} from "./link.js";

const defaults = {
fill: "none",
stroke: "currentColor",
strokeLinecap: "round",
strokeMiterlimit: 1,
strokeWidth: 1.5
};

export class Arrow extends Mark {
constructor(data, options = {}) {
const {
x1,
y1,
x2,
y2,
bend = 0,
headAngle = 60,
headLength = 8,
inset = 0,
insetStart = inset,
insetEnd = inset
} = options;
super(
data,
[
{name: "x1", value: x1, scale: "x"},
{name: "y1", value: y1, scale: "y"},
{name: "x2", value: x2, scale: "x", optional: true},
{name: "y2", value: y2, scale: "y", optional: true}
],
options,
defaults
);
this.bend = bend === true ? 22.5 : Math.max(-90, Math.min(90, bend));
this.headAngle = +headAngle;
this.headLength = +headLength;
this.insetStart = +insetStart;
this.insetEnd = +insetEnd;
}
render(index, {x, y}, channels) {
const {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1, SW} = channels;
const {dx, dy, strokeWidth, bend, headAngle, headLength, insetStart, insetEnd} = this;
const sw = SW ? i => SW[i] : () => strokeWidth;

// When bending, the offset between the straight line between the two points
// and the outgoing tangent from the start point. (Also the negative
// incoming tangent to the end point.) This must be within ±π/2. A positive
// angle will produce a clockwise curve; a negative angle will produce a
// counterclockwise curve; zero will produce a straight line.
const bendAngle = bend * radians;

// The angle between the arrow’s shaft and one of the wings; the “head”
// angle between the wings is twice this value.
const wingAngle = headAngle * radians / 2;

// The length of the arrowhead’s “wings” (the line segments that extend from
// the end point) relative to the stroke width.
const wingScale = headLength / 1.5;

return create("svg:g")
.call(applyIndirectStyles, this)
.call(applyTransform, x, y, offset + dx, offset + dy)
.call(g => g.selectAll()
.data(index)
.join("path")
.call(applyDirectStyles, this)
.attr("d", i => {
let x1 = X1[i], y1 = Y1[i], x2 = X2[i], y2 = Y2[i];
let lineAngle = Math.atan2(y2 - y1, x2 - x1);
const lineLength = Math.hypot(x2 - x1, y2 - y1);

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

// The radius of the circle that intersects with the two endpoints
// and has the specified bend angle.
const r = Math.hypot(lineLength / Math.tan(bendAngle), lineLength) / 2;

// Apply insets.
if (insetStart || insetEnd) {
if (r < 1e5) {
// For inset swoopy arrows, compute the circle-circle
// intersection between a circle centered around the
// respective arrow endpoint and the center of the circle
// segment that forms the shaft of the arrow.
const sign = Math.sign(bendAngle);
const [cx, cy] = pointPointCenter([x1, y1], [x2, y2], r, sign);
if (insetStart) {
([x1, y1] = circleCircleIntersect([cx, cy, r], [x1, y1, insetStart], -sign * Math.sign(insetStart)));
}
// For the end inset, rotate the arrowhead so that it aligns
// with the truncated end of the arrow. Since the arrow is a
// segment of the circle centered at <cx,cy>, we can compute
// the angular difference to the new endpoint.
if (insetEnd) {
const [x, y] = circleCircleIntersect([cx, cy, r], [x2, y2, insetEnd], sign * Math.sign(insetEnd));
lineAngle += Math.atan2(y - cy, x - cx) - Math.atan2(y2 - cy, x2 - cx);
x2 = x, y2 = y;
}
} else {
// For inset straight arrows, offset along the straight line.
const dx = x2 - x1, dy = y2 - y1, d = Math.hypot(dx, dy);
if (insetStart) x1 += dx / d * insetStart, y1 += dy / d * insetStart;
if (insetEnd) x2 -= dx / d * insetEnd, y2 -= dy / d * insetEnd;
}
}

// The angle of the arrow as it approaches the endpoint, and the
// angles of the adjacent wings. Here “left” refers to if the
// arrow is pointing up.
const endAngle = lineAngle + bendAngle;
const leftAngle = endAngle + wingAngle;
const rightAngle = endAngle - wingAngle;

// The endpoints of the two wings.
const x3 = x2 - headLength * Math.cos(leftAngle);
const y3 = y2 - headLength * Math.sin(leftAngle);
const x4 = x2 - headLength * Math.cos(rightAngle);
const y4 = y2 - headLength * Math.sin(rightAngle);

// If the radius is very large (or even infinite, as when the bend
// angle is zero), then render a straight line.
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}`;
})
.call(applyChannelStyles, this, channels))
.node();
}
}

function pointPointCenter([ax, ay], [bx, by], r, sign = 1) {
const dx = bx - ax, dy = by - ay, d = Math.hypot(dx, dy);
const k = sign * Math.sqrt(r * r - d * d / 4) / d;
return [(ax + bx) / 2 - dy * k, (ay + by) / 2 + dx * k];
}

function circleCircleIntersect([ax, ay, ar], [bx, by, br], sign = 1) {
const dx = bx - ax, dy = by - ay, d = Math.hypot(dx, dy);
const x = (dx * dx + dy * dy - br * br + ar * ar) / (2 * d);
const y = sign * Math.sign(ay) * Math.sqrt(ar * ar - x * x);
return [ax + (dx * x + dy * y) / d, ay + (dy * x - dx * y) / d];
}

export function arrow(data, {x, x1, x2, y, y1, y2, ...options} = {}) {
([x1, x2] = maybeSameValue(x, x1, x2));
([y1, y2] = maybeSameValue(y, y1, y2));
return new Arrow(data, {...options, x1, x2, y1, y2});
}
12 changes: 6 additions & 6 deletions src/marks/link.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const defaults = {

export class Link extends Mark {
constructor(data, options = {}) {
const {x1, y1, x2, y2, curve} = options;
const {x1, y1, x2, y2, curve, tension} = options;
super(
data,
[
Expand All @@ -23,11 +23,11 @@ export class Link extends Mark {
options,
defaults
);
this.curve = Curve(curve);
this.curve = Curve(curve, tension);
}
render(index, {x, y}, channels) {
const {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1} = channels;
const {dx, dy} = this;
const {dx, dy, curve} = this;
return create("svg:g")
.call(applyIndirectStyles, this)
.call(applyTransform, x, y, offset + dx, offset + dy)
Expand All @@ -37,12 +37,12 @@ export class Link extends Mark {
.call(applyDirectStyles, this)
.attr("d", i => {
const p = path();
const c = this.curve(p);
const c = curve(p);
c.lineStart();
c.point(X1[i], Y1[i]);
c.point(X2[i], Y2[i]);
c.lineEnd();
return `${p}`;
return p;
})
.call(applyChannelStyles, this, channels))
.node();
Expand All @@ -58,7 +58,7 @@ export function link(data, {x, x1, x2, y, y1, y2, ...options} = {}) {
// If x1 and x2 are specified, return them as {x1, x2}.
// If x and x1 and specified, or x and x2 are specified, return them as {x1, x2}.
// If only x, x1, or x2 are specified, return it as {x1}.
function maybeSameValue(x, x1, x2) {
export function maybeSameValue(x, x1, x2) {
if (x === undefined) {
if (x1 === undefined) {
if (x2 !== undefined) return [x2];
Expand Down
Loading