Skip to content
Merged
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,9 @@ The arrow mark supports the [standard mark options](#marks). The **stroke** defa
* **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.

Expand Down
74 changes: 64 additions & 10 deletions src/marks/arrow.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,18 @@ const defaults = {

export class Arrow extends Mark {
constructor(data, options = {}) {
const {x1, y1, x2, y2, bend = 0, headAngle = 60, headLength = 8} = options;
const {
x1,
y1,
x2,
y2,
bend = 0,
headAngle = 60,
headLength = 8,
inset = 0,
insetStart = inset,
insetEnd = inset
} = options;
super(
data,
[
Expand All @@ -30,10 +41,12 @@ export class Arrow extends Mark {
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(I, {x, y}, channels) {
const {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1, SW} = channels;
const {dx, dy, strokeWidth, bend, headAngle, headLength} = this;
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
Expand All @@ -59,16 +72,48 @@ export class Arrow extends Mark {
.join("path")
.call(applyDirectStyles, this)
.attr("d", i => {
const x1 = X1[i], y1 = Y1[i], x2 = X2[i], y2 = Y2[i];
const dx = x2 - x1, dy = y2 - y1;
const lineLength = Math.hypot(dx, dy);
const lineAngle = Math.atan2(dy, dx);
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.
Expand All @@ -82,10 +127,6 @@ export class Arrow extends Mark {
const x4 = x2 - headLength * Math.cos(rightAngle);
const y4 = y2 - headLength * Math.sin(rightAngle);

// 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;

// 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}`;
Expand All @@ -95,6 +136,19 @@ export class Arrow extends Mark {
}
}

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));
Expand Down