Skip to content

Commit

Permalink
arrow insets (#658)
Browse files Browse the repository at this point in the history
* arrow insets

* fix arrowhead angle adjustment on inset

* document arrow insets

Co-authored-by: Philippe Rivière <fil@rezo.net>
  • Loading branch information
mbostock and Fil committed Jan 16, 2022
1 parent ae24023 commit 47a6b3b
Show file tree
Hide file tree
Showing 2 changed files with 67 additions and 10 deletions.
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 @@ -14,7 +14,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 @@ -29,10 +40,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(index, {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 @@ -58,16 +71,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 @@ -81,10 +126,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 @@ -94,6 +135,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

0 comments on commit 47a6b3b

Please sign in to comment.