Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

arrow sweep #1740

Merged
merged 10 commits into from
Jul 9, 2023
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 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