From fda752ad39b058a89fdc5ca67d96974d7f95a677 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Sat, 8 Jul 2023 17:25:19 +0200 Subject: [PATCH 01/10] arrow sweep option; note that I also removed the arrow head if headLength is zero. --- src/marks/arrow.d.ts | 10 ++++++++++ src/marks/arrow.js | 22 +++++++++++++++++----- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/marks/arrow.d.ts b/src/marks/arrow.d.ts index d2bf620dc5..2cbd0f3a58 100644 --- a/src/marks/arrow.d.ts +++ b/src/marks/arrow.d.ts @@ -84,6 +84,16 @@ export interface ArrowOptions extends MarkOptions { * points to a dot. */ insetEnd?: number; + + /** + * The sweep order; defaults to null. If set to order-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 order-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). If set to order, applies + * an order-x sweep, and breaks ties with order-y. + */ + sweep?: null | "order-x" | "order-y" | "order"; } /** diff --git a/src/marks/arrow.js b/src/marks/arrow.js index ccb62256eb..62d93a8f95 100644 --- a/src/marks/arrow.js +++ b/src/marks/arrow.js @@ -1,7 +1,8 @@ +import {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 = null } = options; super( data, @@ -44,6 +46,7 @@ export class Arrow extends Mark { this.headLength = +headLength; this.insetStart = +insetStart; this.insetEnd = +insetEnd; + this.sweep = sweep == null ? sweep : keyword(sweep, "sweep", ["order-x", "order-y", "order"]); } render(index, scales, channels, dimensions, context) { const {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1, SW} = channels; @@ -139,11 +142,20 @@ export class Arrow extends Mark { const x4 = x2 - headLength * Math.cos(rightAngle); const y4 = y2 - headLength * Math.sin(rightAngle); + // Maybe flip the sweep flag. + const flip = + this.sweep == null + ? 1 + : this.sweep === "order-x" + ? descending(x1, x2) + : this.sweep === "order-y" + ? descending(y1, y2) + : descending(x1, x2) || descending(y1, y2); // "order" // 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,${flip * 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) ) From 230cdfa877bd652ca174f0393c87d4d59529abca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Sat, 8 Jul 2023 17:24:11 +0200 Subject: [PATCH 02/10] miserables.json --- test/data/README.md | 4 + test/data/miserables.json | 337 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 341 insertions(+) create mode 100644 test/data/miserables.json 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} + ] +} From b90ef0239d232cc568b9283ec619fed87c380406 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Sat, 8 Jul 2023 17:25:37 +0200 Subject: [PATCH 03/10] miserables arc diagram --- test/output/miserablesArcDiagram.svg | 430 +++++++++++++++++++++++++++ test/plots/index.ts | 1 + test/plots/miserables.ts | 62 ++++ 3 files changed, 493 insertions(+) create mode 100644 test/output/miserablesArcDiagram.svg create mode 100644 test/plots/miserables.ts diff --git a/test/output/miserablesArcDiagram.svg b/test/output/miserablesArcDiagram.svg new file mode 100644 index 0000000000..a310a317f1 --- /dev/null +++ b/test/output/miserablesArcDiagram.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/index.ts b/test/plots/index.ts index c81803dd82..fb883ec6d6 100644 --- a/test/plots/index.ts +++ b/test/plots/index.ts @@ -156,6 +156,7 @@ export * from "./metro-unemployment-ridgeline.js"; export * from "./metro-unemployment-slope.js"; export * from "./metro-unemployment-stroke.js"; export * from "./metro-unemployment.js"; +export * from "./miserables.js"; export * from "./moby-dick-faceted.js"; export * from "./moby-dick-letter-frequency.js"; export * from "./moby-dick-letter-pairs.js"; diff --git a/test/plots/miserables.ts b/test/plots/miserables.ts new file mode 100644 index 0000000000..eb18919bc6 --- /dev/null +++ b/test/plots/miserables.ts @@ -0,0 +1,62 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export async function miserablesArcDiagram() { + const {nodes, links} = await d3.json("data/miserables.json"); + // This render transform takes a rendered text mark and makes each text’s fill attribute darker. + function darker(i, s, v, d, c, next) { + const g = next(i, s, v, d, c); + for (const t of g.querySelectorAll("text")) { + const f = t.getAttribute("fill"); + if (f) t.setAttribute("fill", d3.lab(f).darker(2)); + } + return g; + } + 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: "order-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, { + frameAnchor: "left", + y: "id", + text: "id", + textAnchor: "end", + dx: -6, + fill: "group", + render: darker + }) + ] + }); +} From 96425d8829f72a0468287697865fa1173e867164 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Sat, 8 Jul 2023 17:51:45 +0200 Subject: [PATCH 04/10] document --- docs/marks/arrow.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/marks/arrow.md b/docs/marks/arrow.md index 07023ff507..9c59f2203a 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. If set to *order-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 *order-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). If set to *order*, applies an *order-x* sweep, and breaks ties with *order-y*. + ## arrow(*data*, *options*) ```js From 4b5f6792bf31eaae62b2d6eede63c105f93f3435 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Sat, 8 Jul 2023 18:49:53 +0200 Subject: [PATCH 05/10] the arrow head and insets computations depend on the flipped bend angle --- src/marks/arrow.js | 35 +++++++-------- test/output/collatzArcDiagram.svg | 51 ++++++++++++++++++++++ test/output/collatzArcDiagramUp.svg | 67 +++++++++++++++++++++++++++++ test/plots/collatz.ts | 64 +++++++++++++++++++++++++++ test/plots/index.ts | 1 + 5 files changed, 201 insertions(+), 17 deletions(-) create mode 100644 test/output/collatzArcDiagram.svg create mode 100644 test/output/collatzArcDiagramUp.svg create mode 100644 test/plots/collatz.ts diff --git a/src/marks/arrow.js b/src/marks/arrow.js index 62d93a8f95..43ba2dee5f 100644 --- a/src/marks/arrow.js +++ b/src/marks/arrow.js @@ -53,13 +53,6 @@ export class Arrow extends Mark { 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; @@ -94,6 +87,23 @@ export class Arrow extends Mark { // wings, but that’s okay since vectors are usually small.) const headLength = Math.min(wingScale * sw(i), lineLength / 3); + // Maybe flip the bending, if a sweep order is applied. + const flip = + this.sweep == null + ? 1 + : this.sweep === "order-x" + ? descending(x1, x2) + : this.sweep === "order-y" + ? descending(y1, y2) + : descending(x1, x2) || descending(y1, y2); // "order" + + // 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 = flip * 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; @@ -142,18 +152,9 @@ export class Arrow extends Mark { const x4 = x2 - headLength * Math.cos(rightAngle); const y4 = y2 - headLength * Math.sin(rightAngle); - // Maybe flip the sweep flag. - const flip = - this.sweep == null - ? 1 - : this.sweep === "order-x" - ? descending(x1, x2) - : this.sweep === "order-y" - ? descending(y1, y2) - : descending(x1, x2) || descending(y1, y2); // "order" // If the radius is very large (or even infinite, as when the bend // angle is zero), then render a straight line. - const a = r < 1e5 ? `A${r},${r} 0,0,${flip * bendAngle > 0 ? 1 : 0} ` : `L`; + 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}`; }) diff --git a/test/output/collatzArcDiagram.svg b/test/output/collatzArcDiagram.svg new file mode 100644 index 0000000000..e4c78eaf14 --- /dev/null +++ b/test/output/collatzArcDiagram.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/collatzArcDiagramUp.svg b/test/output/collatzArcDiagramUp.svg new file mode 100644 index 0000000000..140d26e2bb --- /dev/null +++ b/test/output/collatzArcDiagramUp.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/plots/collatz.ts b/test/plots/collatz.ts new file mode 100644 index 0000000000..59c0f6c02b --- /dev/null +++ b/test/plots/collatz.ts @@ -0,0 +1,64 @@ +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 collatzArcDiagram() { + 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 collatzArcDiagramUp() { + 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: "order", + stroke: ([a, b]) => `url(#gradient${+(a > b)})` + }), + () => + svg` + + + + + + + + ` + ] + }); +} diff --git a/test/plots/index.ts b/test/plots/index.ts index fb883ec6d6..9b2bc0c8ae 100644 --- a/test/plots/index.ts +++ b/test/plots/index.ts @@ -49,6 +49,7 @@ export * from "./cars-parcoords.js"; export * from "./channel-domain.js"; export * from "./clamp.js"; export * from "./collapsed-histogram.js"; +export * from "./collatz.js"; export * from "./color-piecewise.js"; export * from "./country-centroids.js"; export * from "./covid-ihme-projected-deaths.js"; From 9e09d25d3a5171a34bf3ef68e9ac0ead016227ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Sun, 9 Jul 2023 09:31:45 +0200 Subject: [PATCH 06/10] darker should be an initializer --- test/plots/miserables.ts | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/test/plots/miserables.ts b/test/plots/miserables.ts index eb18919bc6..72629f1a72 100644 --- a/test/plots/miserables.ts +++ b/test/plots/miserables.ts @@ -3,15 +3,14 @@ import * as d3 from "d3"; export async function miserablesArcDiagram() { const {nodes, links} = await d3.json("data/miserables.json"); - // This render transform takes a rendered text mark and makes each text’s fill attribute darker. - function darker(i, s, v, d, c, next) { - const g = next(i, s, v, d, c); - for (const t of g.querySelectorAll("text")) { - const f = t.getAttribute("fill"); - if (f) t.setAttribute("fill", d3.lab(f).darker(2)); - } - return g; - } + 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, @@ -48,15 +47,17 @@ export async function miserablesArcDiagram() { headLength: 0 }), Plot.dot(nodes, {frameAnchor: "left", y: "id", fill: "group"}), - Plot.text(nodes, { - frameAnchor: "left", - y: "id", - text: "id", - textAnchor: "end", - dx: -6, - fill: "group", - render: darker - }) + Plot.text( + nodes, + darker({ + frameAnchor: "left", + y: "id", + text: "id", + textAnchor: "end", + dx: -6, + fill: "group" + }) + ) ] }); } From 6bea43774b146ede48cad0bbcbde5aa537b1d989 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sun, 9 Jul 2023 11:41:39 -0400 Subject: [PATCH 07/10] functional sweep (#1741) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * functional sweep * ±[xy] --- src/marks/arrow.d.ts | 15 ++++++++------- src/marks/arrow.js | 34 ++++++++++++++++++++-------------- test/plots/collatz.ts | 2 +- test/plots/miserables.ts | 2 +- 4 files changed, 30 insertions(+), 23 deletions(-) diff --git a/src/marks/arrow.d.ts b/src/marks/arrow.d.ts index 2cbd0f3a58..4ecff4b87c 100644 --- a/src/marks/arrow.d.ts +++ b/src/marks/arrow.d.ts @@ -86,14 +86,15 @@ export interface ArrowOptions extends MarkOptions { insetEnd?: number; /** - * The sweep order; defaults to null. If set to order-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 order-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). If set to order, applies - * an order-x sweep, and breaks ties with order-y. + * 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?: null | "order-x" | "order-y" | "order"; + 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 43ba2dee5f..7d1bdaf217 100644 --- a/src/marks/arrow.js +++ b/src/marks/arrow.js @@ -1,4 +1,4 @@ -import {descending} from "d3"; +import {ascending, descending} from "d3"; import {create} from "../context.js"; import {Mark} from "../mark.js"; import {radians} from "../math.js"; @@ -28,7 +28,7 @@ export class Arrow extends Mark { inset = 0, insetStart = inset, insetEnd = inset, - sweep = null + sweep } = options; super( data, @@ -46,7 +46,7 @@ export class Arrow extends Mark { this.headLength = +headLength; this.insetStart = +insetStart; this.insetEnd = +insetEnd; - this.sweep = sweep == null ? sweep : keyword(sweep, "sweep", ["order-x", "order-y", "order"]); + this.sweep = maybeSweep(sweep); } render(index, scales, channels, dimensions, context) { const {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1, SW} = channels; @@ -87,22 +87,12 @@ export class Arrow extends Mark { // wings, but that’s okay since vectors are usually small.) const headLength = Math.min(wingScale * sw(i), lineLength / 3); - // Maybe flip the bending, if a sweep order is applied. - const flip = - this.sweep == null - ? 1 - : this.sweep === "order-x" - ? descending(x1, x2) - : this.sweep === "order-y" - ? descending(y1, y2) - : descending(x1, x2) || descending(y1, y2); // "order" - // 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 = flip * bend * radians; + 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. @@ -164,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/plots/collatz.ts b/test/plots/collatz.ts index 59c0f6c02b..277cf2ed77 100644 --- a/test/plots/collatz.ts +++ b/test/plots/collatz.ts @@ -46,7 +46,7 @@ export async function collatzArcDiagramUp() { dy: -3, bend: 70, inset: 4, - sweep: "order", + sweep: "-x", stroke: ([a, b]) => `url(#gradient${+(a > b)})` }), () => diff --git a/test/plots/miserables.ts b/test/plots/miserables.ts index 72629f1a72..59e012bee0 100644 --- a/test/plots/miserables.ts +++ b/test/plots/miserables.ts @@ -37,7 +37,7 @@ export async function miserablesArcDiagram() { x: 0, y1: "source", y2: "target", - sweep: "order-y", + sweep: "-y", bend: 90, stroke: samegroup, sort: samegroup, From 346b886cecaec84e3e748f4c246b3888852be566 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Sun, 9 Jul 2023 17:42:10 +0200 Subject: [PATCH 08/10] Update docs/marks/arrow.md Co-authored-by: Mike Bostock --- docs/marks/arrow.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/marks/arrow.md b/docs/marks/arrow.md index 9c59f2203a..4f3b6d6cdd 100644 --- a/docs/marks/arrow.md +++ b/docs/marks/arrow.md @@ -115,7 +115,7 @@ The arrow mark supports the [standard mark options](../features/marks.md#mark-op 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. If set to *order-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 *order-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). If set to *order*, applies an *order-x* sweep, and breaks ties with *order-y*. +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. If set to *order-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 *order-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). If set to *order*, applies an *order-x* sweep, and breaks ties with *order-y*. ## arrow(*data*, *options*) From e396cabfd2c668585722c1d6d9573d440838af6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Sun, 9 Jul 2023 17:46:16 +0200 Subject: [PATCH 09/10] documentation --- docs/marks/arrow.md | 2 +- src/marks/arrow.d.ts | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/marks/arrow.md b/docs/marks/arrow.md index 4f3b6d6cdd..991247711c 100644 --- a/docs/marks/arrow.md +++ b/docs/marks/arrow.md @@ -115,7 +115,7 @@ The arrow mark supports the [standard mark options](../features/marks.md#mark-op 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. If set to *order-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 *order-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). If set to *order*, applies an *order-x* sweep, and breaks ties with *order-y*. +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*) diff --git a/src/marks/arrow.d.ts b/src/marks/arrow.d.ts index 4ecff4b87c..1b6397eca5 100644 --- a/src/marks/arrow.d.ts +++ b/src/marks/arrow.d.ts @@ -88,11 +88,12 @@ export interface ArrowOptions extends MarkOptions { /** * 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. + * 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); } From d78146cab356b84ac817d339560b44856bb2ee38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Sun, 9 Jul 2023 17:50:03 +0200 Subject: [PATCH 10/10] rename tests --- .../{collatzArcDiagram.svg => arcCollatz.svg} | 0 ...llatzArcDiagramUp.svg => arcCollatzUp.svg} | 0 ...rablesArcDiagram.svg => arcMiserables.svg} | 0 test/plots/arc.ts | 125 ++++++++++++++++++ test/plots/collatz.ts | 64 --------- test/plots/index.ts | 3 +- test/plots/miserables.ts | 63 --------- 7 files changed, 126 insertions(+), 129 deletions(-) rename test/output/{collatzArcDiagram.svg => arcCollatz.svg} (100%) rename test/output/{collatzArcDiagramUp.svg => arcCollatzUp.svg} (100%) rename test/output/{miserablesArcDiagram.svg => arcMiserables.svg} (100%) create mode 100644 test/plots/arc.ts delete mode 100644 test/plots/collatz.ts delete mode 100644 test/plots/miserables.ts diff --git a/test/output/collatzArcDiagram.svg b/test/output/arcCollatz.svg similarity index 100% rename from test/output/collatzArcDiagram.svg rename to test/output/arcCollatz.svg diff --git a/test/output/collatzArcDiagramUp.svg b/test/output/arcCollatzUp.svg similarity index 100% rename from test/output/collatzArcDiagramUp.svg rename to test/output/arcCollatzUp.svg diff --git a/test/output/miserablesArcDiagram.svg b/test/output/arcMiserables.svg similarity index 100% rename from test/output/miserablesArcDiagram.svg rename to test/output/arcMiserables.svg 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/collatz.ts b/test/plots/collatz.ts deleted file mode 100644 index 277cf2ed77..0000000000 --- a/test/plots/collatz.ts +++ /dev/null @@ -1,64 +0,0 @@ -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 collatzArcDiagram() { - 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 collatzArcDiagramUp() { - 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` - - - - - - - - ` - ] - }); -} diff --git a/test/plots/index.ts b/test/plots/index.ts index 9b2bc0c8ae..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"; @@ -49,7 +50,6 @@ export * from "./cars-parcoords.js"; export * from "./channel-domain.js"; export * from "./clamp.js"; export * from "./collapsed-histogram.js"; -export * from "./collatz.js"; export * from "./color-piecewise.js"; export * from "./country-centroids.js"; export * from "./covid-ihme-projected-deaths.js"; @@ -157,7 +157,6 @@ export * from "./metro-unemployment-ridgeline.js"; export * from "./metro-unemployment-slope.js"; export * from "./metro-unemployment-stroke.js"; export * from "./metro-unemployment.js"; -export * from "./miserables.js"; export * from "./moby-dick-faceted.js"; export * from "./moby-dick-letter-frequency.js"; export * from "./moby-dick-letter-pairs.js"; diff --git a/test/plots/miserables.ts b/test/plots/miserables.ts deleted file mode 100644 index 59e012bee0..0000000000 --- a/test/plots/miserables.ts +++ /dev/null @@ -1,63 +0,0 @@ -import * as Plot from "@observablehq/plot"; -import * as d3 from "d3"; - -export async function miserablesArcDiagram() { - 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" - }) - ) - ] - }); -}