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 @@ + + + + 12 + 6 + 3 + 10 + 5 + 16 + 8 + 4 + 2 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 @@ + + + + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + 13 + 14 + 15 + 16 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Myriel + Napoleon + Mlle.Baptistine + Mme.Magloire + CountessdeLo + Geborand + Champtercier + Cravatte + Count + OldMan + Labarre + Valjean + Marguerite + Mme.deR + Isabeau + Gervais + Tholomyes + Listolier + Fameuil + Blacheville + Favourite + Dahlia + Zephine + Fantine + Mme.Thenardier + Thenardier + Cosette + Javert + Fauchelevent + Bamatabois + Perpetue + Simplice + Scaufflaire + Woman1 + Judge + Champmathieu + Brevet + Chenildieu + Cochepaille + Pontmercy + Boulatruelle + Eponine + Anzelma + Woman2 + MotherInnocent + Gribier + Jondrette + Mme.Burgon + Gavroche + Gillenormand + Magnon + Mlle.Gillenormand + Mme.Pontmercy + Mlle.Vaubois + Lt.Gillenormand + Marius + BaronessT + Mabeuf + Enjolras + Combeferre + Prouvaire + Feuilly + Courfeyrac + Bahorel + Bossuet + Joly + Grantaire + MotherPlutarch + Gueulemer + Babet + Claquesous + Montparnasse + Toussaint + Child1 + Child2 + Brujon + Mme.Hucheloup + + \ 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";