diff --git a/docs/marks/arrow.md b/docs/marks/arrow.md
index 07023ff507..991247711c 100644
--- a/docs/marks/arrow.md
+++ b/docs/marks/arrow.md
@@ -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
diff --git a/src/marks/arrow.d.ts b/src/marks/arrow.d.ts
index d2bf620dc5..1b6397eca5 100644
--- a/src/marks/arrow.d.ts
+++ b/src/marks/arrow.d.ts
@@ -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);
}
/**
diff --git a/src/marks/arrow.js b/src/marks/arrow.js
index ccb62256eb..7d1bdaf217 100644
--- a/src/marks/arrow.js
+++ b/src/marks/arrow.js
@@ -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";
@@ -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,
@@ -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;
@@ -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;
@@ -141,9 +144,9 @@ 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)
)
@@ -151,6 +154,22 @@ export class Arrow extends Mark {
}
}
+// 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.
diff --git a/test/data/README.md b/test/data/README.md
index 2fa8480ed8..23c68ca50f 100644
--- a/test/data/README.md
+++ b/test/data/README.md
@@ -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
diff --git a/test/data/miserables.json b/test/data/miserables.json
new file mode 100644
index 0000000000..7db92850f8
--- /dev/null
+++ b/test/data/miserables.json
@@ -0,0 +1,337 @@
+{
+ "nodes": [
+ {"id": "Myriel", "group": 1},
+ {"id": "Napoleon", "group": 1},
+ {"id": "Mlle.Baptistine", "group": 1},
+ {"id": "Mme.Magloire", "group": 1},
+ {"id": "CountessdeLo", "group": 1},
+ {"id": "Geborand", "group": 1},
+ {"id": "Champtercier", "group": 1},
+ {"id": "Cravatte", "group": 1},
+ {"id": "Count", "group": 1},
+ {"id": "OldMan", "group": 1},
+ {"id": "Labarre", "group": 2},
+ {"id": "Valjean", "group": 2},
+ {"id": "Marguerite", "group": 3},
+ {"id": "Mme.deR", "group": 2},
+ {"id": "Isabeau", "group": 2},
+ {"id": "Gervais", "group": 2},
+ {"id": "Tholomyes", "group": 3},
+ {"id": "Listolier", "group": 3},
+ {"id": "Fameuil", "group": 3},
+ {"id": "Blacheville", "group": 3},
+ {"id": "Favourite", "group": 3},
+ {"id": "Dahlia", "group": 3},
+ {"id": "Zephine", "group": 3},
+ {"id": "Fantine", "group": 3},
+ {"id": "Mme.Thenardier", "group": 4},
+ {"id": "Thenardier", "group": 4},
+ {"id": "Cosette", "group": 5},
+ {"id": "Javert", "group": 4},
+ {"id": "Fauchelevent", "group": 0},
+ {"id": "Bamatabois", "group": 2},
+ {"id": "Perpetue", "group": 3},
+ {"id": "Simplice", "group": 2},
+ {"id": "Scaufflaire", "group": 2},
+ {"id": "Woman1", "group": 2},
+ {"id": "Judge", "group": 2},
+ {"id": "Champmathieu", "group": 2},
+ {"id": "Brevet", "group": 2},
+ {"id": "Chenildieu", "group": 2},
+ {"id": "Cochepaille", "group": 2},
+ {"id": "Pontmercy", "group": 4},
+ {"id": "Boulatruelle", "group": 6},
+ {"id": "Eponine", "group": 4},
+ {"id": "Anzelma", "group": 4},
+ {"id": "Woman2", "group": 5},
+ {"id": "MotherInnocent", "group": 0},
+ {"id": "Gribier", "group": 0},
+ {"id": "Jondrette", "group": 7},
+ {"id": "Mme.Burgon", "group": 7},
+ {"id": "Gavroche", "group": 8},
+ {"id": "Gillenormand", "group": 5},
+ {"id": "Magnon", "group": 5},
+ {"id": "Mlle.Gillenormand", "group": 5},
+ {"id": "Mme.Pontmercy", "group": 5},
+ {"id": "Mlle.Vaubois", "group": 5},
+ {"id": "Lt.Gillenormand", "group": 5},
+ {"id": "Marius", "group": 8},
+ {"id": "BaronessT", "group": 5},
+ {"id": "Mabeuf", "group": 8},
+ {"id": "Enjolras", "group": 8},
+ {"id": "Combeferre", "group": 8},
+ {"id": "Prouvaire", "group": 8},
+ {"id": "Feuilly", "group": 8},
+ {"id": "Courfeyrac", "group": 8},
+ {"id": "Bahorel", "group": 8},
+ {"id": "Bossuet", "group": 8},
+ {"id": "Joly", "group": 8},
+ {"id": "Grantaire", "group": 8},
+ {"id": "MotherPlutarch", "group": 9},
+ {"id": "Gueulemer", "group": 4},
+ {"id": "Babet", "group": 4},
+ {"id": "Claquesous", "group": 4},
+ {"id": "Montparnasse", "group": 4},
+ {"id": "Toussaint", "group": 5},
+ {"id": "Child1", "group": 10},
+ {"id": "Child2", "group": 10},
+ {"id": "Brujon", "group": 4},
+ {"id": "Mme.Hucheloup", "group": 8}
+ ],
+ "links": [
+ {"source": "Napoleon", "target": "Myriel", "value": 1},
+ {"source": "Mlle.Baptistine", "target": "Myriel", "value": 8},
+ {"source": "Mme.Magloire", "target": "Myriel", "value": 10},
+ {"source": "Mme.Magloire", "target": "Mlle.Baptistine", "value": 6},
+ {"source": "CountessdeLo", "target": "Myriel", "value": 1},
+ {"source": "Geborand", "target": "Myriel", "value": 1},
+ {"source": "Champtercier", "target": "Myriel", "value": 1},
+ {"source": "Cravatte", "target": "Myriel", "value": 1},
+ {"source": "Count", "target": "Myriel", "value": 2},
+ {"source": "OldMan", "target": "Myriel", "value": 1},
+ {"source": "Valjean", "target": "Labarre", "value": 1},
+ {"source": "Valjean", "target": "Mme.Magloire", "value": 3},
+ {"source": "Valjean", "target": "Mlle.Baptistine", "value": 3},
+ {"source": "Valjean", "target": "Myriel", "value": 5},
+ {"source": "Marguerite", "target": "Valjean", "value": 1},
+ {"source": "Mme.deR", "target": "Valjean", "value": 1},
+ {"source": "Isabeau", "target": "Valjean", "value": 1},
+ {"source": "Gervais", "target": "Valjean", "value": 1},
+ {"source": "Listolier", "target": "Tholomyes", "value": 4},
+ {"source": "Fameuil", "target": "Tholomyes", "value": 4},
+ {"source": "Fameuil", "target": "Listolier", "value": 4},
+ {"source": "Blacheville", "target": "Tholomyes", "value": 4},
+ {"source": "Blacheville", "target": "Listolier", "value": 4},
+ {"source": "Blacheville", "target": "Fameuil", "value": 4},
+ {"source": "Favourite", "target": "Tholomyes", "value": 3},
+ {"source": "Favourite", "target": "Listolier", "value": 3},
+ {"source": "Favourite", "target": "Fameuil", "value": 3},
+ {"source": "Favourite", "target": "Blacheville", "value": 4},
+ {"source": "Dahlia", "target": "Tholomyes", "value": 3},
+ {"source": "Dahlia", "target": "Listolier", "value": 3},
+ {"source": "Dahlia", "target": "Fameuil", "value": 3},
+ {"source": "Dahlia", "target": "Blacheville", "value": 3},
+ {"source": "Dahlia", "target": "Favourite", "value": 5},
+ {"source": "Zephine", "target": "Tholomyes", "value": 3},
+ {"source": "Zephine", "target": "Listolier", "value": 3},
+ {"source": "Zephine", "target": "Fameuil", "value": 3},
+ {"source": "Zephine", "target": "Blacheville", "value": 3},
+ {"source": "Zephine", "target": "Favourite", "value": 4},
+ {"source": "Zephine", "target": "Dahlia", "value": 4},
+ {"source": "Fantine", "target": "Tholomyes", "value": 3},
+ {"source": "Fantine", "target": "Listolier", "value": 3},
+ {"source": "Fantine", "target": "Fameuil", "value": 3},
+ {"source": "Fantine", "target": "Blacheville", "value": 3},
+ {"source": "Fantine", "target": "Favourite", "value": 4},
+ {"source": "Fantine", "target": "Dahlia", "value": 4},
+ {"source": "Fantine", "target": "Zephine", "value": 4},
+ {"source": "Fantine", "target": "Marguerite", "value": 2},
+ {"source": "Fantine", "target": "Valjean", "value": 9},
+ {"source": "Mme.Thenardier", "target": "Fantine", "value": 2},
+ {"source": "Mme.Thenardier", "target": "Valjean", "value": 7},
+ {"source": "Thenardier", "target": "Mme.Thenardier", "value": 13},
+ {"source": "Thenardier", "target": "Fantine", "value": 1},
+ {"source": "Thenardier", "target": "Valjean", "value": 12},
+ {"source": "Cosette", "target": "Mme.Thenardier", "value": 4},
+ {"source": "Cosette", "target": "Valjean", "value": 31},
+ {"source": "Cosette", "target": "Tholomyes", "value": 1},
+ {"source": "Cosette", "target": "Thenardier", "value": 1},
+ {"source": "Javert", "target": "Valjean", "value": 17},
+ {"source": "Javert", "target": "Fantine", "value": 5},
+ {"source": "Javert", "target": "Thenardier", "value": 5},
+ {"source": "Javert", "target": "Mme.Thenardier", "value": 1},
+ {"source": "Javert", "target": "Cosette", "value": 1},
+ {"source": "Fauchelevent", "target": "Valjean", "value": 8},
+ {"source": "Fauchelevent", "target": "Javert", "value": 1},
+ {"source": "Bamatabois", "target": "Fantine", "value": 1},
+ {"source": "Bamatabois", "target": "Javert", "value": 1},
+ {"source": "Bamatabois", "target": "Valjean", "value": 2},
+ {"source": "Perpetue", "target": "Fantine", "value": 1},
+ {"source": "Simplice", "target": "Perpetue", "value": 2},
+ {"source": "Simplice", "target": "Valjean", "value": 3},
+ {"source": "Simplice", "target": "Fantine", "value": 2},
+ {"source": "Simplice", "target": "Javert", "value": 1},
+ {"source": "Scaufflaire", "target": "Valjean", "value": 1},
+ {"source": "Woman1", "target": "Valjean", "value": 2},
+ {"source": "Woman1", "target": "Javert", "value": 1},
+ {"source": "Judge", "target": "Valjean", "value": 3},
+ {"source": "Judge", "target": "Bamatabois", "value": 2},
+ {"source": "Champmathieu", "target": "Valjean", "value": 3},
+ {"source": "Champmathieu", "target": "Judge", "value": 3},
+ {"source": "Champmathieu", "target": "Bamatabois", "value": 2},
+ {"source": "Brevet", "target": "Judge", "value": 2},
+ {"source": "Brevet", "target": "Champmathieu", "value": 2},
+ {"source": "Brevet", "target": "Valjean", "value": 2},
+ {"source": "Brevet", "target": "Bamatabois", "value": 1},
+ {"source": "Chenildieu", "target": "Judge", "value": 2},
+ {"source": "Chenildieu", "target": "Champmathieu", "value": 2},
+ {"source": "Chenildieu", "target": "Brevet", "value": 2},
+ {"source": "Chenildieu", "target": "Valjean", "value": 2},
+ {"source": "Chenildieu", "target": "Bamatabois", "value": 1},
+ {"source": "Cochepaille", "target": "Judge", "value": 2},
+ {"source": "Cochepaille", "target": "Champmathieu", "value": 2},
+ {"source": "Cochepaille", "target": "Brevet", "value": 2},
+ {"source": "Cochepaille", "target": "Chenildieu", "value": 2},
+ {"source": "Cochepaille", "target": "Valjean", "value": 2},
+ {"source": "Cochepaille", "target": "Bamatabois", "value": 1},
+ {"source": "Pontmercy", "target": "Thenardier", "value": 1},
+ {"source": "Boulatruelle", "target": "Thenardier", "value": 1},
+ {"source": "Eponine", "target": "Mme.Thenardier", "value": 2},
+ {"source": "Eponine", "target": "Thenardier", "value": 3},
+ {"source": "Anzelma", "target": "Eponine", "value": 2},
+ {"source": "Anzelma", "target": "Thenardier", "value": 2},
+ {"source": "Anzelma", "target": "Mme.Thenardier", "value": 1},
+ {"source": "Woman2", "target": "Valjean", "value": 3},
+ {"source": "Woman2", "target": "Cosette", "value": 1},
+ {"source": "Woman2", "target": "Javert", "value": 1},
+ {"source": "MotherInnocent", "target": "Fauchelevent", "value": 3},
+ {"source": "MotherInnocent", "target": "Valjean", "value": 1},
+ {"source": "Gribier", "target": "Fauchelevent", "value": 2},
+ {"source": "Mme.Burgon", "target": "Jondrette", "value": 1},
+ {"source": "Gavroche", "target": "Mme.Burgon", "value": 2},
+ {"source": "Gavroche", "target": "Thenardier", "value": 1},
+ {"source": "Gavroche", "target": "Javert", "value": 1},
+ {"source": "Gavroche", "target": "Valjean", "value": 1},
+ {"source": "Gillenormand", "target": "Cosette", "value": 3},
+ {"source": "Gillenormand", "target": "Valjean", "value": 2},
+ {"source": "Magnon", "target": "Gillenormand", "value": 1},
+ {"source": "Magnon", "target": "Mme.Thenardier", "value": 1},
+ {"source": "Mlle.Gillenormand", "target": "Gillenormand", "value": 9},
+ {"source": "Mlle.Gillenormand", "target": "Cosette", "value": 2},
+ {"source": "Mlle.Gillenormand", "target": "Valjean", "value": 2},
+ {"source": "Mme.Pontmercy", "target": "Mlle.Gillenormand", "value": 1},
+ {"source": "Mme.Pontmercy", "target": "Pontmercy", "value": 1},
+ {"source": "Mlle.Vaubois", "target": "Mlle.Gillenormand", "value": 1},
+ {"source": "Lt.Gillenormand", "target": "Mlle.Gillenormand", "value": 2},
+ {"source": "Lt.Gillenormand", "target": "Gillenormand", "value": 1},
+ {"source": "Lt.Gillenormand", "target": "Cosette", "value": 1},
+ {"source": "Marius", "target": "Mlle.Gillenormand", "value": 6},
+ {"source": "Marius", "target": "Gillenormand", "value": 12},
+ {"source": "Marius", "target": "Pontmercy", "value": 1},
+ {"source": "Marius", "target": "Lt.Gillenormand", "value": 1},
+ {"source": "Marius", "target": "Cosette", "value": 21},
+ {"source": "Marius", "target": "Valjean", "value": 19},
+ {"source": "Marius", "target": "Tholomyes", "value": 1},
+ {"source": "Marius", "target": "Thenardier", "value": 2},
+ {"source": "Marius", "target": "Eponine", "value": 5},
+ {"source": "Marius", "target": "Gavroche", "value": 4},
+ {"source": "BaronessT", "target": "Gillenormand", "value": 1},
+ {"source": "BaronessT", "target": "Marius", "value": 1},
+ {"source": "Mabeuf", "target": "Marius", "value": 1},
+ {"source": "Mabeuf", "target": "Eponine", "value": 1},
+ {"source": "Mabeuf", "target": "Gavroche", "value": 1},
+ {"source": "Enjolras", "target": "Marius", "value": 7},
+ {"source": "Enjolras", "target": "Gavroche", "value": 7},
+ {"source": "Enjolras", "target": "Javert", "value": 6},
+ {"source": "Enjolras", "target": "Mabeuf", "value": 1},
+ {"source": "Enjolras", "target": "Valjean", "value": 4},
+ {"source": "Combeferre", "target": "Enjolras", "value": 15},
+ {"source": "Combeferre", "target": "Marius", "value": 5},
+ {"source": "Combeferre", "target": "Gavroche", "value": 6},
+ {"source": "Combeferre", "target": "Mabeuf", "value": 2},
+ {"source": "Prouvaire", "target": "Gavroche", "value": 1},
+ {"source": "Prouvaire", "target": "Enjolras", "value": 4},
+ {"source": "Prouvaire", "target": "Combeferre", "value": 2},
+ {"source": "Feuilly", "target": "Gavroche", "value": 2},
+ {"source": "Feuilly", "target": "Enjolras", "value": 6},
+ {"source": "Feuilly", "target": "Prouvaire", "value": 2},
+ {"source": "Feuilly", "target": "Combeferre", "value": 5},
+ {"source": "Feuilly", "target": "Mabeuf", "value": 1},
+ {"source": "Feuilly", "target": "Marius", "value": 1},
+ {"source": "Courfeyrac", "target": "Marius", "value": 9},
+ {"source": "Courfeyrac", "target": "Enjolras", "value": 17},
+ {"source": "Courfeyrac", "target": "Combeferre", "value": 13},
+ {"source": "Courfeyrac", "target": "Gavroche", "value": 7},
+ {"source": "Courfeyrac", "target": "Mabeuf", "value": 2},
+ {"source": "Courfeyrac", "target": "Eponine", "value": 1},
+ {"source": "Courfeyrac", "target": "Feuilly", "value": 6},
+ {"source": "Courfeyrac", "target": "Prouvaire", "value": 3},
+ {"source": "Bahorel", "target": "Combeferre", "value": 5},
+ {"source": "Bahorel", "target": "Gavroche", "value": 5},
+ {"source": "Bahorel", "target": "Courfeyrac", "value": 6},
+ {"source": "Bahorel", "target": "Mabeuf", "value": 2},
+ {"source": "Bahorel", "target": "Enjolras", "value": 4},
+ {"source": "Bahorel", "target": "Feuilly", "value": 3},
+ {"source": "Bahorel", "target": "Prouvaire", "value": 2},
+ {"source": "Bahorel", "target": "Marius", "value": 1},
+ {"source": "Bossuet", "target": "Marius", "value": 5},
+ {"source": "Bossuet", "target": "Courfeyrac", "value": 12},
+ {"source": "Bossuet", "target": "Gavroche", "value": 5},
+ {"source": "Bossuet", "target": "Bahorel", "value": 4},
+ {"source": "Bossuet", "target": "Enjolras", "value": 10},
+ {"source": "Bossuet", "target": "Feuilly", "value": 6},
+ {"source": "Bossuet", "target": "Prouvaire", "value": 2},
+ {"source": "Bossuet", "target": "Combeferre", "value": 9},
+ {"source": "Bossuet", "target": "Mabeuf", "value": 1},
+ {"source": "Bossuet", "target": "Valjean", "value": 1},
+ {"source": "Joly", "target": "Bahorel", "value": 5},
+ {"source": "Joly", "target": "Bossuet", "value": 7},
+ {"source": "Joly", "target": "Gavroche", "value": 3},
+ {"source": "Joly", "target": "Courfeyrac", "value": 5},
+ {"source": "Joly", "target": "Enjolras", "value": 5},
+ {"source": "Joly", "target": "Feuilly", "value": 5},
+ {"source": "Joly", "target": "Prouvaire", "value": 2},
+ {"source": "Joly", "target": "Combeferre", "value": 5},
+ {"source": "Joly", "target": "Mabeuf", "value": 1},
+ {"source": "Joly", "target": "Marius", "value": 2},
+ {"source": "Grantaire", "target": "Bossuet", "value": 3},
+ {"source": "Grantaire", "target": "Enjolras", "value": 3},
+ {"source": "Grantaire", "target": "Combeferre", "value": 1},
+ {"source": "Grantaire", "target": "Courfeyrac", "value": 2},
+ {"source": "Grantaire", "target": "Joly", "value": 2},
+ {"source": "Grantaire", "target": "Gavroche", "value": 1},
+ {"source": "Grantaire", "target": "Bahorel", "value": 1},
+ {"source": "Grantaire", "target": "Feuilly", "value": 1},
+ {"source": "Grantaire", "target": "Prouvaire", "value": 1},
+ {"source": "MotherPlutarch", "target": "Mabeuf", "value": 3},
+ {"source": "Gueulemer", "target": "Thenardier", "value": 5},
+ {"source": "Gueulemer", "target": "Valjean", "value": 1},
+ {"source": "Gueulemer", "target": "Mme.Thenardier", "value": 1},
+ {"source": "Gueulemer", "target": "Javert", "value": 1},
+ {"source": "Gueulemer", "target": "Gavroche", "value": 1},
+ {"source": "Gueulemer", "target": "Eponine", "value": 1},
+ {"source": "Babet", "target": "Thenardier", "value": 6},
+ {"source": "Babet", "target": "Gueulemer", "value": 6},
+ {"source": "Babet", "target": "Valjean", "value": 1},
+ {"source": "Babet", "target": "Mme.Thenardier", "value": 1},
+ {"source": "Babet", "target": "Javert", "value": 2},
+ {"source": "Babet", "target": "Gavroche", "value": 1},
+ {"source": "Babet", "target": "Eponine", "value": 1},
+ {"source": "Claquesous", "target": "Thenardier", "value": 4},
+ {"source": "Claquesous", "target": "Babet", "value": 4},
+ {"source": "Claquesous", "target": "Gueulemer", "value": 4},
+ {"source": "Claquesous", "target": "Valjean", "value": 1},
+ {"source": "Claquesous", "target": "Mme.Thenardier", "value": 1},
+ {"source": "Claquesous", "target": "Javert", "value": 1},
+ {"source": "Claquesous", "target": "Eponine", "value": 1},
+ {"source": "Claquesous", "target": "Enjolras", "value": 1},
+ {"source": "Montparnasse", "target": "Javert", "value": 1},
+ {"source": "Montparnasse", "target": "Babet", "value": 2},
+ {"source": "Montparnasse", "target": "Gueulemer", "value": 2},
+ {"source": "Montparnasse", "target": "Claquesous", "value": 2},
+ {"source": "Montparnasse", "target": "Valjean", "value": 1},
+ {"source": "Montparnasse", "target": "Gavroche", "value": 1},
+ {"source": "Montparnasse", "target": "Eponine", "value": 1},
+ {"source": "Montparnasse", "target": "Thenardier", "value": 1},
+ {"source": "Toussaint", "target": "Cosette", "value": 2},
+ {"source": "Toussaint", "target": "Javert", "value": 1},
+ {"source": "Toussaint", "target": "Valjean", "value": 1},
+ {"source": "Child1", "target": "Gavroche", "value": 2},
+ {"source": "Child2", "target": "Gavroche", "value": 2},
+ {"source": "Child2", "target": "Child1", "value": 3},
+ {"source": "Brujon", "target": "Babet", "value": 3},
+ {"source": "Brujon", "target": "Gueulemer", "value": 3},
+ {"source": "Brujon", "target": "Thenardier", "value": 3},
+ {"source": "Brujon", "target": "Gavroche", "value": 1},
+ {"source": "Brujon", "target": "Eponine", "value": 1},
+ {"source": "Brujon", "target": "Claquesous", "value": 1},
+ {"source": "Brujon", "target": "Montparnasse", "value": 1},
+ {"source": "Mme.Hucheloup", "target": "Bossuet", "value": 1},
+ {"source": "Mme.Hucheloup", "target": "Joly", "value": 1},
+ {"source": "Mme.Hucheloup", "target": "Grantaire", "value": 1},
+ {"source": "Mme.Hucheloup", "target": "Bahorel", "value": 1},
+ {"source": "Mme.Hucheloup", "target": "Courfeyrac", "value": 1},
+ {"source": "Mme.Hucheloup", "target": "Gavroche", "value": 1},
+ {"source": "Mme.Hucheloup", "target": "Enjolras", "value": 1}
+ ]
+}
diff --git a/test/output/arcCollatz.svg b/test/output/arcCollatz.svg
new file mode 100644
index 0000000000..e4c78eaf14
--- /dev/null
+++ b/test/output/arcCollatz.svg
@@ -0,0 +1,51 @@
+
\ No newline at end of file
diff --git a/test/output/arcCollatzUp.svg b/test/output/arcCollatzUp.svg
new file mode 100644
index 0000000000..140d26e2bb
--- /dev/null
+++ b/test/output/arcCollatzUp.svg
@@ -0,0 +1,67 @@
+
\ No newline at end of file
diff --git a/test/output/arcMiserables.svg b/test/output/arcMiserables.svg
new file mode 100644
index 0000000000..a310a317f1
--- /dev/null
+++ b/test/output/arcMiserables.svg
@@ -0,0 +1,430 @@
+
\ No newline at end of file
diff --git a/test/plots/arc.ts b/test/plots/arc.ts
new file mode 100644
index 0000000000..2968049097
--- /dev/null
+++ b/test/plots/arc.ts
@@ -0,0 +1,125 @@
+import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
+import {svg} from "htl";
+
+function* collatz(n) {
+ yield n;
+ while (n > 1) {
+ n = n % 2 ? 3 * n + 1 : n >> 1;
+ yield n;
+ }
+}
+
+export async function arcCollatz() {
+ return Plot.plot({
+ height: 520,
+ axis: null,
+ inset: 10,
+ y: {domain: [-1, 1]},
+ marks: [
+ Plot.text(collatz(12), {x: Plot.identity, text: Plot.identity, y: 0, fill: "currentColor"}),
+ Plot.arrow(d3.pairs(collatz(12)), {
+ x1: "0",
+ x2: "1",
+ y: 0,
+ bend: 90,
+ headLength: 4,
+ insetEnd: 18,
+ insetStart: 14
+ }),
+ Plot.dot(collatz(12), {x: Plot.identity, r: 10})
+ ]
+ });
+}
+
+export async function arcCollatzUp() {
+ return Plot.plot({
+ height: 260,
+ x: {ticks: 20, tickSize: 0},
+ y: {domain: [0, 1], axis: null},
+ marks: [
+ Plot.dot(collatz(12), {x: Plot.identity, y: 0, fill: "currentColor"}),
+ Plot.arrow(d3.pairs(collatz(12)), {
+ x1: ([d]) => d - (d === 12 ? 0 : 0.07),
+ x2: ([, d]) => d + (d === 1 ? 0 : 0.07),
+ y: 0,
+ dy: -3,
+ bend: 70,
+ inset: 4,
+ sweep: "-x",
+ stroke: ([a, b]) => `url(#gradient${+(a > b)})`
+ }),
+ () =>
+ svg`
+
+
+
+
+
+
+
+ `
+ ]
+ });
+}
+
+export async function arcMiserables() {
+ const {nodes, links} = await d3.json("data/miserables.json");
+ const darker = (options) =>
+ Plot.initializer(options, (data, facets, {fill: {value: F}}, {color}) => ({
+ data,
+ facets,
+ channels: {
+ fill: {value: Plot.valueof(F as number[], (d) => d3.lab(color(d)).darker(2))}
+ }
+ }));
+ const orderByGroup = d3
+ .sort(
+ nodes,
+ ({group}) => group,
+ ({id}) => id
+ )
+ .map(({id}) => id);
+ const groups = new Map(nodes.map((d) => [d.id, d.group]));
+ const samegroup = ({source, target}) => (groups.get(source) === groups.get(target) ? groups.get(source) : null);
+ return Plot.plot({
+ width: 640,
+ height: 1080,
+ marginLeft: 100,
+ x: {domain: [0, 1]}, // see https://github.com/observablehq/plot/issues/1541
+ y: {domain: orderByGroup},
+ axis: null,
+ color: {
+ domain: d3.sort(new Set(Plot.valueof(nodes, "group"))),
+ scheme: "Category10",
+ unknown: "#aaa"
+ },
+ marks: [
+ Plot.arrow(links, {
+ x: 0,
+ y1: "source",
+ y2: "target",
+ sweep: "-y",
+ bend: 90,
+ stroke: samegroup,
+ sort: samegroup,
+ reverse: true, // put group links on top
+ strokeWidth: 1.5,
+ strokeOpacity: 0.6,
+ headLength: 0
+ }),
+ Plot.dot(nodes, {frameAnchor: "left", y: "id", fill: "group"}),
+ Plot.text(
+ nodes,
+ darker({
+ frameAnchor: "left",
+ y: "id",
+ text: "id",
+ textAnchor: "end",
+ dx: -6,
+ fill: "group"
+ })
+ )
+ ]
+ });
+}
diff --git a/test/plots/index.ts b/test/plots/index.ts
index c81803dd82..8ae9fa9a02 100644
--- a/test/plots/index.ts
+++ b/test/plots/index.ts
@@ -8,6 +8,7 @@ export * from "./aapl-monthly.js";
export * from "./aapl-volume-rect.js";
export * from "./aapl-volume.js";
export * from "./anscombe-quartet.js";
+export * from "./arc.js";
export * from "./armadillo.js";
export * from "./aspectRatio.js";
export * from "./athletes-bins-colors.js";