From 0a0176be38950cffe7c93e69cc47ae2c45e0a3c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Mon, 17 Jun 2024 22:47:57 +0200 Subject: [PATCH 1/2] for lines with variable aesthetics we want to maintain the higher-level semantics of markers: - markerStart matches the start of the line - markerMid matches the points which are not at the start or the end - markerEnd matches the end of the line Since these lines are implemented as multiple paths, we have change the low-level implementation of markers: - markerStart only applies to the first segment of a line - markerMid applies to all the segments, complemented by the start of all but the first segments - markerEnd only applies to the last segment of a line closes #2093 --- src/marker.js | 35 +- test/output/groupMarker.svg | 58 ++++ test/output/groupMarkerEnd.svg | 533 +++++++++++++++++++++++++++++++ test/output/groupMarkerMid.svg | 58 ++++ test/output/groupMarkerStart.svg | 533 +++++++++++++++++++++++++++++++ test/plots/group-markers.ts | 70 ++++ test/plots/index.ts | 1 + 7 files changed, 1280 insertions(+), 8 deletions(-) create mode 100644 test/output/groupMarker.svg create mode 100644 test/output/groupMarkerEnd.svg create mode 100644 test/output/groupMarkerMid.svg create mode 100644 test/output/groupMarkerStart.svg create mode 100644 test/plots/group-markers.ts diff --git a/src/marker.js b/src/marker.js index 5c793cdea4..8df580a80d 100644 --- a/src/marker.js +++ b/src/marker.js @@ -1,3 +1,4 @@ +import {select, selectAll, group} from "d3"; import {create} from "./context.js"; export function markers(mark, {marker, markerStart = marker, markerMid = marker, markerEnd = marker} = {}) { @@ -100,16 +101,15 @@ function markerTick(orient) { let nextMarkerId = 0; export function applyMarkers(path, mark, {stroke: S}, context) { - return applyMarkersColor(path, mark, S && ((i) => S[i]), context); + return applyMarkersColor(path, mark, S && ((i) => S[i]), null, context); } -export function applyGroupedMarkers(path, mark, {stroke: S}, context) { - return applyMarkersColor(path, mark, S && (([i]) => S[i]), context); +export function applyGroupedMarkers(path, mark, {stroke: S, z: Z}, context) { + return applyMarkersColor(path, mark, S && (([i]) => S[i]), Z, context); } -function applyMarkersColor(path, {markerStart, markerMid, markerEnd, stroke}, strokeof = () => stroke, context) { +function applyMarkersColor(path, {markerStart, markerMid, markerEnd, stroke}, strokeof = () => stroke, Z, context) { const iriByMarkerColor = new Map(); - function applyMarker(marker) { return function (i) { const color = strokeof(i); @@ -126,7 +126,26 @@ function applyMarkersColor(path, {markerStart, markerMid, markerEnd, stroke}, st }; } - if (markerStart) path.attr("marker-start", applyMarker(markerStart)); - if (markerMid) path.attr("marker-mid", applyMarker(markerMid)); - if (markerEnd) path.attr("marker-end", applyMarker(markerEnd)); + if (!(markerStart || markerMid || markerEnd)) return; + + const start = markerStart && applyMarker(markerStart); + const mid = markerMid && applyMarker(markerMid); + const end = markerEnd && applyMarker(markerEnd); + if (Z) { + for (const g of group( + path.filter((i) => i.length >= 2), + (d) => Z[select(d).datum()[0]] + ).values()) { + if (start) select(g.at(0)).attr("marker-start", start); + if (mid) { + selectAll(g.slice(1)).attr("marker-start", mid); + selectAll(g).attr("marker-mid", mid); + } + if (end) select(g.at(-1)).attr("marker-end", end); + } + } else { + if (start) path.attr("marker-start", start); + if (mid) path.attr("marker-mid", mid); + if (end) path.attr("marker-end", end); + } } diff --git a/test/output/groupMarker.svg b/test/output/groupMarker.svg new file mode 100644 index 0000000000..bf7d2454d4 --- /dev/null +++ b/test/output/groupMarker.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/groupMarkerEnd.svg b/test/output/groupMarkerEnd.svg new file mode 100644 index 0000000000..555af41001 --- /dev/null +++ b/test/output/groupMarkerEnd.svg @@ -0,0 +1,533 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/groupMarkerMid.svg b/test/output/groupMarkerMid.svg new file mode 100644 index 0000000000..8be8a4512f --- /dev/null +++ b/test/output/groupMarkerMid.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/groupMarkerStart.svg b/test/output/groupMarkerStart.svg new file mode 100644 index 0000000000..91564afef4 --- /dev/null +++ b/test/output/groupMarkerStart.svg @@ -0,0 +1,533 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/group-markers.ts b/test/plots/group-markers.ts new file mode 100644 index 0000000000..44b319561a --- /dev/null +++ b/test/plots/group-markers.ts @@ -0,0 +1,70 @@ +import * as Plot from "@observablehq/plot"; +import {range} from "d3"; + +export async function groupMarker() { + return Plot.plot({ + aspectRatio: 1, + axis: null, + inset: 30, + marks: [ + Plot.line(range(20, 200), { + x: (i) => i * Math.sin(i / 40 + ((i % 5) * 2 * Math.PI) / 5), + y: (i) => i * Math.cos(i / 40 + ((i % 5) * 2 * Math.PI) / 5), + stroke: (i) => `arrow ${i % 5}`, + strokeWidth: (i) => Math.round(1 + i / 40), + marker: "dot" + }) + ] + }); +} + +export async function groupMarkerStart() { + return Plot.plot({ + aspectRatio: 1, + axis: null, + inset: 30, + marks: [ + Plot.line(range(500, 0, -1), { + x: (i) => i * Math.sin(i / 100 + ((i % 5) * 2 * Math.PI) / 5), + y: (i) => i * Math.cos(i / 100 + ((i % 5) * 2 * Math.PI) / 5), + stroke: (i) => `arrow ${i % 5}`, + strokeWidth: (i) => i / 100, + markerStart: "circle-stroke" + }) + ] + }); +} + +export async function groupMarkerMid() { + return Plot.plot({ + aspectRatio: 1, + axis: null, + inset: 30, + marks: [ + Plot.line(range(20, 200), { + x: (i) => i * Math.sin(i / 40 + ((i % 5) * 2 * Math.PI) / 5), + y: (i) => i * Math.cos(i / 40 + ((i % 5) * 2 * Math.PI) / 5), + stroke: (i) => `arrow ${i % 5}`, + strokeWidth: (i) => Math.round(i / 40), + markerMid: "dot" + }) + ] + }); +} + +export async function groupMarkerEnd() { + return Plot.plot({ + aspectRatio: 1, + axis: null, + inset: 30, + marks: [ + Plot.line(range(500), { + x: (i) => i * Math.sin(i / 100 + ((i % 5) * 2 * Math.PI) / 5), + y: (i) => i * Math.cos(i / 100 + ((i % 5) * 2 * Math.PI) / 5), + stroke: (i) => `arrow ${i % 5}`, + strokeWidth: (i) => i / 100, + markerEnd: "arrow" + }) + ] + }); +} diff --git a/test/plots/index.ts b/test/plots/index.ts index f1b253323e..11da15346b 100644 --- a/test/plots/index.ts +++ b/test/plots/index.ts @@ -112,6 +112,7 @@ export * from "./graticule.js"; export * from "./greek-gods.js"; export * from "./grid-choropleth.js"; export * from "./grouped-rects.js"; +export * from "./group-markers.js"; export * from "./hadcrut-warming-stripes.js"; export * from "./heatmap.js"; export * from "./hexbin-oranges.js"; From e7f25c909c794057e075cb0d522cb9cc50f18cbb Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 26 Jun 2024 11:32:52 -0400 Subject: [PATCH 2/2] better marker strategy (#2095) --- src/marker.js | 72 ++++++++++++++++++++++++++++++++------------------ src/memoize.js | 2 +- 2 files changed, 48 insertions(+), 26 deletions(-) diff --git a/src/marker.js b/src/marker.js index 8df580a80d..6d76ed9ff2 100644 --- a/src/marker.js +++ b/src/marker.js @@ -1,5 +1,6 @@ -import {select, selectAll, group} from "d3"; import {create} from "./context.js"; +import {unset} from "./memoize.js"; +import {keyof} from "./options.js"; export function markers(mark, {marker, markerStart = marker, markerMid = marker, markerEnd = marker} = {}) { mark.markerStart = maybeMarker(markerStart); @@ -108,10 +109,49 @@ export function applyGroupedMarkers(path, mark, {stroke: S, z: Z}, context) { return applyMarkersColor(path, mark, S && (([i]) => S[i]), Z, context); } +const START = 1; +const END = 2; + +/** + * When rendering lines or areas with variable aesthetics, a single series + * produces multiple path elements. The first path element is a START segment; + * the last path element is an END segment. When there is only a single path + * element, it is both a START and an END segment. + */ +function getGroupedOrientation(path, Z) { + const O = new Uint8Array(Z.length); + const D = path.data().filter((I) => I.length > 1); + const n = D.length; + + // Forward pass to find start segments. + for (let i = 0, z = unset; i < n; ++i) { + const I = D[i]; + if (I.length > 1) { + const i = I[0]; + if (z !== (z = keyof(Z[i]))) O[i] |= START; + } + } + + // Backwards pass to find end segments. + for (let i = n - 1, z = unset; i >= 0; --i) { + const I = D[i]; + if (I.length > 1) { + const i = I[0]; + if (z !== (z = keyof(Z[i]))) O[i] |= END; + } + } + + return ([i]) => O[i]; +} + function applyMarkersColor(path, {markerStart, markerMid, markerEnd, stroke}, strokeof = () => stroke, Z, context) { + if (!markerStart && !markerMid && !markerEnd) return; const iriByMarkerColor = new Map(); - function applyMarker(marker) { + const orient = Z && getGroupedOrientation(path, Z); + + function applyMarker(name, marker, filter) { return function (i) { + if (filter && !filter(i)) return; const color = strokeof(i); let iriByColor = iriByMarkerColor.get(marker); if (!iriByColor) iriByMarkerColor.set(marker, (iriByColor = new Map())); @@ -122,30 +162,12 @@ function applyMarkersColor(path, {markerStart, markerMid, markerEnd, stroke}, st node.setAttribute("id", id); iriByColor.set(color, (iri = `url(#${id})`)); } - return iri; + this.setAttribute(name, iri); }; } - if (!(markerStart || markerMid || markerEnd)) return; - - const start = markerStart && applyMarker(markerStart); - const mid = markerMid && applyMarker(markerMid); - const end = markerEnd && applyMarker(markerEnd); - if (Z) { - for (const g of group( - path.filter((i) => i.length >= 2), - (d) => Z[select(d).datum()[0]] - ).values()) { - if (start) select(g.at(0)).attr("marker-start", start); - if (mid) { - selectAll(g.slice(1)).attr("marker-start", mid); - selectAll(g).attr("marker-mid", mid); - } - if (end) select(g.at(-1)).attr("marker-end", end); - } - } else { - if (start) path.attr("marker-start", start); - if (mid) path.attr("marker-mid", mid); - if (end) path.attr("marker-end", end); - } + if (markerStart) path.each(applyMarker("marker-start", markerStart, orient && ((i) => orient(i) & START))); + if (markerMid && orient) path.each(applyMarker("marker-start", markerMid, (i) => !(orient(i) & START))); + if (markerMid) path.each(applyMarker("marker-mid", markerMid)); + if (markerEnd) path.each(applyMarker("marker-end", markerEnd, orient && ((i) => orient(i) & END))); } diff --git a/src/memoize.js b/src/memoize.js index eceeac7d07..36233c68bb 100644 --- a/src/memoize.js +++ b/src/memoize.js @@ -1,4 +1,4 @@ -const unset = Symbol("unset"); +export const unset = Symbol("unset"); export function memoize1(compute) { return (compute.length === 1 ? memoize1Arg : memoize1Args)(compute);