Skip to content

Commit

Permalink
arrow sweep (#1740)
Browse files Browse the repository at this point in the history
* arrow sweep option; note that I also removed the arrow head if headLength is zero.
* miserables.json
* miserables arc diagram
* the arrow head and insets computations depend on the flipped bend angle
* darker initializer
* functional sweep (#1741)
* ±[xy]

---------

Co-authored-by: Mike Bostock <[email protected]>
  • Loading branch information
Fil and mbostock authored Jul 9, 2023
1 parent 7b92d54 commit e44dd3d
Show file tree
Hide file tree
Showing 10 changed files with 1,061 additions and 12 deletions.
3 changes: 3 additions & 0 deletions docs/marks/arrow.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,12 @@ The arrow mark supports the [standard mark options](../features/marks.md#mark-op
* **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
* **sweep** - the sweep order

The **bend** option sets the angle between the straight line connecting 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.

The **sweep** option can be used to make arrows bend in the same direction, independently of the relative positions of the starting and ending points. It defaults to 1 indicating a positive (clockwise) bend angle; -1 indicates a negative (anticlockwise) bend angle. 0 effectively clears the bend angle. If set to *-x*, the bend angle is flipped when the ending point is to the left of the starting point — ensuring all arrows bulge up (down if bend is negative); if set to *-y*, the bend angle is flipped when the ending point is above the starting point — ensuring all arrows bulge right (left if bend is negative); the sign is negated for *+x* and *+y*.

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

```js
Expand Down
12 changes: 12 additions & 0 deletions src/marks/arrow.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,18 @@ export interface ArrowOptions extends MarkOptions {
* points to a dot.
*/
insetEnd?: number;

/**
* The sweep order; defaults to 1 indicating a positive (clockwise) bend
* angle; -1 indicates a negative (anticlockwise) bend angle; 0 effectively
* clears the bend angle. If set to *-x*, the bend angle is flipped when the
* ending point is to the left of the starting point — ensuring all arrows
* bulge up (down if bend is negative); if set to *-y*, the bend angle is
* flipped when the ending point is above the starting point — ensuring all
* arrows bulge right (left if bend is negative); the sign is negated for *+x*
* and *+y*.
*/
sweep?: number | "+x" | "-x" | "+y" | "-y" | ((x1: number, y1: number, x2: number, y2: number) => number);
}

/**
Expand Down
43 changes: 31 additions & 12 deletions src/marks/arrow.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {ascending, descending} from "d3";
import {create} from "../context.js";
import {Mark} from "../mark.js";
import {radians} from "../math.js";
import {constant} from "../options.js";
import {constant, keyword} from "../options.js";
import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform} from "../style.js";
import {maybeSameValue} from "./link.js";

Expand All @@ -26,7 +27,8 @@ export class Arrow extends Mark {
headLength = 8, // Disable the arrow with headLength = 0; or, use Plot.link.
inset = 0,
insetStart = inset,
insetEnd = inset
insetEnd = inset,
sweep
} = options;
super(
data,
Expand All @@ -44,19 +46,13 @@ export class Arrow extends Mark {
this.headLength = +headLength;
this.insetStart = +insetStart;
this.insetEnd = +insetEnd;
this.sweep = maybeSweep(sweep);
}
render(index, scales, channels, dimensions, context) {
const {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1, SW} = channels;
const {strokeWidth, bend, headAngle, headLength, insetStart, insetEnd} = this;
const sw = SW ? (i) => SW[i] : constant(strokeWidth === undefined ? 1 : 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;
Expand Down Expand Up @@ -91,6 +87,13 @@ export class Arrow extends Mark {
// wings, but that’s okay since vectors are usually small.)
const headLength = Math.min(wingScale * sw(i), lineLength / 3);

// 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 = this.sweep(x1, y1, x2, y2) * bend * radians;

// 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;
Expand Down Expand Up @@ -141,16 +144,32 @@ export class Arrow extends Mark {

// 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}`;
const a = r < 1e5 ? `A${r},${r} 0,0,${bendAngle > 0 ? 1 : 0} ` : `L`;
const h = headLength ? `M${x3},${y3}L${x2},${y2}L${x4},${y4}` : "";
return `M${x1},${y1}${a}${x2},${y2}${h}`;
})
.call(applyChannelStyles, this, channels)
)
.node();
}
}

// Maybe flip the bend angle, depending on the arrow orientation.
function maybeSweep(sweep = 1) {
if (typeof sweep === "number") return constant(Math.sign(sweep));
if (typeof sweep === "function") return (x1, y1, x2, y2) => Math.sign(sweep(x1, y1, x2, y2));
switch (keyword(sweep, "sweep", ["+x", "-x", "+y", "-y"])) {
case "+x":
return (x1, y1, x2) => ascending(x1, x2);
case "-x":
return (x1, y1, x2) => descending(x1, x2);
case "+y":
return (x1, y1, x2, y2) => ascending(y1, y2);
case "-y":
return (x1, y1, x2, y2) => descending(y1, y2);
}
}

// Returns the center of a circle that goes through the two given points ⟨ax,ay⟩
// and ⟨bx,by⟩ and has radius r. There are two such points; use the sign +1 or
// -1 to choose between them. Returns [NaN, NaN] if r is too small.
Expand Down
4 changes: 4 additions & 0 deletions test/data/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ https://observablehq.com/@tophtucker/examples-of-bitemporal-charts
The New York Times
https://www.nytimes.com/2019/12/02/upshot/wealth-poverty-divide-american-cities.html

## miserables.json
Character interactions in the chapters of “Les Miserables”, Donald Knuth, Stanford Graph Base
https://www-cs-faculty.stanford.edu/~knuth/sgb.html

## mtcars.csv
1974 *Motor Trend* US magazine
https://www.rdocumentation.org/packages/datasets/versions/3.6.2/topics/mtcars
Expand Down
Loading

0 comments on commit e44dd3d

Please sign in to comment.