From 55e577ad61c819f7c2831dd808a70c20ca44283c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 12 Mar 2026 15:13:16 +0100 Subject: [PATCH 1/2] line halo --- docs/marks/line.md | 6 + src/marks/halo.js | 51 ++++++ src/marks/line.d.ts | 17 ++ src/marks/line.js | 61 ++++--- test/data/us-gdp.csv | 302 +++++++++++++++++++++++++++++++++ test/output/lineHalo.svg | 164 ++++++++++++++++++ test/output/lineHaloStyles.svg | 256 ++++++++++++++++++++++++++++ test/plot.js | 16 ++ test/plots/index.ts | 1 + test/plots/line-halo.ts | 106 ++++++++++++ 10 files changed, 956 insertions(+), 24 deletions(-) create mode 100644 src/marks/halo.js create mode 100644 test/data/us-gdp.csv create mode 100644 test/output/lineHalo.svg create mode 100644 test/output/lineHaloStyles.svg create mode 100644 test/plots/line-halo.ts diff --git a/docs/marks/line.md b/docs/marks/line.md index 398ab4cf1d..9aaf7ec9d9 100644 --- a/docs/marks/line.md +++ b/docs/marks/line.md @@ -361,6 +361,12 @@ Points along the line are connected in input order. Likewise, if there are multi The line mark supports [curve options](../features/curves.md) to control interpolation between points, and [marker options](../features/markers.md) to add a marker (such as a dot or an arrowhead) on each of the control points. The default curve is *auto*, which is equivalent to *linear* if there is no [projection](../features/projections.md), and otherwise uses the associated projection. If any of the **x** or **y** values are invalid (undefined, null, or NaN), the line will be interrupted, resulting in a break that divides the line shape into multiple segments. (See [d3-shape’s *line*.defined](https://d3js.org/d3-shape/line#line_defined) for more.) If a line segment consists of only a single point, it may appear invisible unless rendered with rounded or square line caps. In addition, some curves such as *cardinal-open* only render a visible segment if it contains multiple points. +The line mark supports a **halo** option that draws an outline around each series, increasing legibility when lines overlap. The following halo options are supported: + +* **halo** - if true, draws a halo; if a color, sets the halo color; if a number, sets the halo radius +* **haloColor** - the halo color; defaults to *var(--plot-background)* +* **haloRadius** - the halo radius in pixels; defaults to 2 + ## line(*data*, *options*) {#line} ```js diff --git a/src/marks/halo.js b/src/marks/halo.js new file mode 100644 index 0000000000..d1eb0c528d --- /dev/null +++ b/src/marks/halo.js @@ -0,0 +1,51 @@ +import {isColor} from "../options.js"; + +const defaultColor = "var(--plot-background)"; +const defaultRadius = 2; + +let nextHaloId = 0; + +function getHaloId() { + return `plot-halo-${++nextHaloId}`; +} + +export function applyHalo(selection, {halo}) { + if (!halo) return; + const {color, radius} = halo; + const filters = new WeakMap(); + selection.attr("filter", function () { + const id = getHaloId(); + filters.set(this, id); + return `url(#${id})`; + }); + selection + .append("filter") + .attr("id", function () { + return filters.get(this.parentNode); + }) + .call((filter) => + filter + .append("feMorphology") + .attr("in", "SourceAlpha") + .attr("result", "dilated") + .attr("operator", "dilate") + .attr("radius", radius) + ) + .call((filter) => filter.append("feFlood").style("flood-color", color)) + .call((filter) => filter.append("feComposite").attr("in2", "dilated").attr("operator", "in")) + .append("feMerge") + .call((merge) => { + merge.append("feMergeNode"); + merge.append("feMergeNode").attr("in", "SourceGraphic"); + }); +} + +export function maybeHalo(halo, color, radius) { + if (halo === undefined) halo = color !== undefined || radius !== undefined; + if (!halo) return false; + if (color === undefined) color = isColor(halo) ? halo : defaultColor; + else if (!isColor(color)) throw new Error(`Unsupported halo color: ${color}`); + if (radius === undefined) radius = typeof halo === "number" && !isNaN(halo) ? halo : defaultRadius; + else if (isNaN(+radius)) throw new Error(`Unsupported halo radius: ${radius}`); + return {color, radius}; +} diff --git a/src/marks/line.d.ts b/src/marks/line.d.ts index 0f1692a978..89ef12da7c 100644 --- a/src/marks/line.d.ts +++ b/src/marks/line.d.ts @@ -22,6 +22,23 @@ export interface LineOptions extends MarkOptions, MarkerOptions, CurveAutoOption * **fill** if a channel, or **stroke** if a channel. */ z?: ChannelValue; + + /** + * Draw a halo around the line to help separate overlapping lines. If true, + * draws a halo with the plot background color and a 2px radius. If a color, + * uses that color. If a number, uses that radius. + */ + halo?: boolean | string | number; + + /** + * The halo’s color; defaults to background color. + */ + haloColor?: string; + + /** + * The halo’s radius in pixels; defaults to 2. + */ + haloRadius?: number; } /** Options for the lineX mark. */ diff --git a/src/marks/line.js b/src/marks/line.js index 74e4b19fe5..b8f9f09333 100644 --- a/src/marks/line.js +++ b/src/marks/line.js @@ -1,4 +1,4 @@ -import {line as shapeLine} from "d3"; +import {group, line as shapeLine} from "d3"; import {create} from "../context.js"; import {curveAuto, maybeCurveAuto} from "../curve.js"; import {Mark} from "../mark.js"; @@ -12,6 +12,7 @@ import { groupIndex } from "../style.js"; import {maybeDenseIntervalX, maybeDenseIntervalY} from "../transforms/bin.js"; +import {applyHalo, maybeHalo} from "./halo.js"; const defaults = { ariaLabel: "line", @@ -25,7 +26,7 @@ const defaults = { export class Line extends Mark { constructor(data, options = {}) { - const {x, y, z, curve, tension} = options; + const {x, y, z, curve, tension, halo, haloColor, haloRadius} = options; super( data, { @@ -38,6 +39,7 @@ export class Line extends Mark { ); this.z = z; this.curve = maybeCurveAuto(curve, tension); + this.halo = maybeHalo(halo, haloColor, haloRadius); markers(this, options); } filter(index) { @@ -50,32 +52,43 @@ export class Line extends Mark { } } render(index, scales, channels, dimensions, context) { - const {x: X, y: Y} = channels; + const {x: X, y: Y, z: Z} = channels; const {curve} = this; - return create("svg:g", context) + const g = create("svg:g", context) .call(applyIndirectStyles, this, dimensions, context) - .call(applyTransform, this, scales) - .call((g) => - g + .call(applyTransform, this, scales); + + // When adding a halo to multiple series, nest by series so each + // gets its own halo filter; otherwise render paths directly into g. + const segments = groupIndex(index, [X, Y], this, channels); + (this.halo && Z + ? g .selectAll() - .data(groupIndex(index, [X, Y], this, channels)) + .data(group(segments, (I) => Z[I.find((i) => i >= 0)])) .enter() - .append("path") - .call(applyDirectStyles, this) - .call(applyGroupedChannelStyles, this, channels) - .call(applyGroupedMarkers, this, channels, context) - .attr( - "d", - curve === curveAuto && context.projection - ? sphereLine(context.path(), X, Y) - : shapeLine() - .curve(curve) - .defined((i) => i >= 0) - .x((i) => X[i]) - .y((i) => Y[i]) - ) - ) - .node(); + .append("g") + : g.datum([, segments]) + ) + .call(applyHalo, this) + .selectAll() + .data(([, d]) => d) + .enter() + .append("path") + .call(applyDirectStyles, this) + .call(applyGroupedChannelStyles, this, channels) + .call(applyGroupedMarkers, this, channels, context) + .attr( + "d", + curve === curveAuto && context.projection + ? sphereLine(context.path(), X, Y) + : shapeLine() + .curve(curve) + .defined((i) => i >= 0) + .x((i) => X[i]) + .y((i) => Y[i]) + ); + + return g.node(); } } diff --git a/test/data/us-gdp.csv b/test/data/us-gdp.csv new file mode 100644 index 0000000000..d46c25da6a --- /dev/null +++ b/test/data/us-gdp.csv @@ -0,0 +1,302 @@ +date,gdpc1 +1947-01-01,2034.45 +1947-04-01,2029.024 +1947-07-01,2024.834 +1947-10-01,2056.508 +1948-01-01,2087.442 +1948-04-01,2121.899 +1948-07-01,2134.056 +1948-10-01,2136.44 +1949-01-01,2107.001 +1949-04-01,2099.814 +1949-07-01,2121.493 +1949-10-01,2103.688 +1950-01-01,2186.365 +1950-04-01,2253.045 +1950-07-01,2340.112 +1950-10-01,2384.92 +1951-01-01,2417.311 +1951-04-01,2459.196 +1951-07-01,2509.88 +1951-10-01,2515.408 +1952-01-01,2542.286 +1952-04-01,2547.762 +1952-07-01,2566.153 +1952-10-01,2650.431 +1953-01-01,2699.699 +1953-04-01,2720.566 +1953-07-01,2705.258 +1953-10-01,2664.302 +1954-01-01,2651.566 +1954-04-01,2654.456 +1954-07-01,2684.434 +1954-10-01,2736.96 +1955-01-01,2815.134 +1955-04-01,2860.942 +1955-07-01,2899.578 +1955-10-01,2916.985 +1956-01-01,2905.656 +1956-04-01,2929.666 +1956-07-01,2927.034 +1956-10-01,2975.209 +1957-01-01,2994.259 +1957-04-01,2987.699 +1957-07-01,3016.979 +1957-10-01,2985.775 +1958-01-01,2908.281 +1958-04-01,2927.395 +1958-07-01,2995.112 +1958-10-01,3065.141 +1959-01-01,3123.978 +1959-04-01,3194.429 +1959-07-01,3196.683 +1959-10-01,3205.79 +1960-01-01,3277.847 +1960-04-01,3260.177 +1960-07-01,3276.133 +1960-10-01,3234.087 +1961-01-01,3255.914 +1961-04-01,3311.181 +1961-07-01,3374.742 +1961-10-01,3440.924 +1962-01-01,3502.298 +1962-04-01,3533.947 +1962-07-01,3577.362 +1962-10-01,3589.128 +1963-01-01,3628.306 +1963-04-01,3669.02 +1963-07-01,3749.681 +1963-10-01,3774.264 +1964-01-01,3853.835 +1964-04-01,3895.793 +1964-07-01,3956.657 +1964-10-01,3968.878 +1965-01-01,4064.915 +1965-04-01,4116.267 +1965-07-01,4207.782 +1965-10-01,4304.731 +1966-01-01,4409.518 +1966-04-01,4424.581 +1966-07-01,4462.053 +1966-10-01,4498.66 +1967-01-01,4538.498 +1967-04-01,4541.28 +1967-07-01,4584.246 +1967-10-01,4618.812 +1968-01-01,4713.013 +1968-04-01,4791.758 +1968-07-01,4828.892 +1968-10-01,4847.885 +1969-01-01,4923.76 +1969-04-01,4938.728 +1969-07-01,4971.349 +1969-10-01,4947.104 +1970-01-01,4939.759 +1970-04-01,4946.77 +1970-07-01,4992.357 +1970-10-01,4938.857 +1971-01-01,5072.996 +1971-04-01,5100.447 +1971-07-01,5142.422 +1971-10-01,5154.547 +1972-01-01,5249.337 +1972-04-01,5368.485 +1972-07-01,5419.184 +1972-10-01,5509.926 +1973-01-01,5646.286 +1973-04-01,5707.755 +1973-07-01,5677.738 +1973-10-01,5731.632 +1974-01-01,5682.353 +1974-04-01,5695.859 +1974-07-01,5642.025 +1974-10-01,5620.126 +1975-01-01,5551.713 +1975-04-01,5591.382 +1975-07-01,5687.087 +1975-10-01,5763.665 +1976-01-01,5893.276 +1976-04-01,5936.515 +1976-07-01,5969.089 +1976-10-01,6012.356 +1977-01-01,6083.391 +1977-04-01,6201.659 +1977-07-01,6313.559 +1977-10-01,6313.697 +1978-01-01,6333.848 +1978-04-01,6578.605 +1978-07-01,6644.754 +1978-10-01,6734.069 +1979-01-01,6746.176 +1979-04-01,6753.389 +1979-07-01,6803.558 +1979-10-01,6820.572 +1980-01-01,6842.024 +1980-04-01,6701.046 +1980-07-01,6693.082 +1980-10-01,6817.903 +1981-01-01,6951.495 +1981-04-01,6899.98 +1981-07-01,6982.609 +1981-10-01,6906.529 +1982-01-01,6799.233 +1982-04-01,6830.251 +1982-07-01,6804.139 +1982-10-01,6806.857 +1983-01-01,6896.561 +1983-04-01,7053.5 +1983-07-01,7194.504 +1983-10-01,7344.597 +1984-01-01,7488.167 +1984-04-01,7617.547 +1984-07-01,7690.985 +1984-10-01,7754.117 +1985-01-01,7829.26 +1985-04-01,7898.194 +1985-07-01,8018.809 +1985-10-01,8078.415 +1986-01-01,8153.829 +1986-04-01,8190.552 +1986-07-01,8268.935 +1986-10-01,8313.338 +1987-01-01,8375.274 +1987-04-01,8465.63 +1987-07-01,8539.075 +1987-10-01,8685.694 +1988-01-01,8730.569 +1988-04-01,8845.28 +1988-07-01,8897.107 +1988-10-01,9015.661 +1989-01-01,9107.314 +1989-04-01,9176.827 +1989-07-01,9244.816 +1989-10-01,9263.033 +1990-01-01,9364.259 +1990-04-01,9398.243 +1990-07-01,9404.494 +1990-10-01,9318.876 +1991-01-01,9275.276 +1991-04-01,9347.597 +1991-07-01,9394.834 +1991-10-01,9427.581 +1992-01-01,9540.444 +1992-04-01,9643.893 +1992-07-01,9739.185 +1992-10-01,9840.753 +1993-01-01,9857.185 +1993-04-01,9914.565 +1993-07-01,9961.873 +1993-10-01,10097.362 +1994-01-01,10195.338 +1994-04-01,10333.495 +1994-07-01,10393.898 +1994-10-01,10512.962 +1995-01-01,10550.251 +1995-04-01,10581.723 +1995-07-01,10671.738 +1995-10-01,10744.203 +1996-01-01,10824.674 +1996-04-01,11005.217 +1996-07-01,11103.935 +1996-10-01,11219.238 +1997-01-01,11291.665 +1997-04-01,11479.33 +1997-07-01,11622.911 +1997-10-01,11722.722 +1998-01-01,11839.876 +1998-04-01,11949.492 +1998-07-01,12099.191 +1998-10-01,12294.737 +1999-01-01,12410.778 +1999-04-01,12514.408 +1999-07-01,12679.977 +1999-10-01,12888.281 +2000-01-01,12935.252 +2000-04-01,13170.749 +2000-07-01,13183.89 +2000-10-01,13262.25 +2001-01-01,13219.251 +2001-04-01,13301.394 +2001-07-01,13248.142 +2001-10-01,13284.881 +2002-01-01,13394.91 +2002-04-01,13477.356 +2002-07-01,13531.741 +2002-10-01,13549.421 +2003-01-01,13619.434 +2003-04-01,13741.107 +2003-07-01,13970.157 +2003-10-01,14131.379 +2004-01-01,14212.34 +2004-04-01,14323.017 +2004-07-01,14457.832 +2004-10-01,14605.595 +2005-01-01,14767.846 +2005-04-01,14839.707 +2005-07-01,14956.291 +2005-10-01,15041.232 +2006-01-01,15244.088 +2006-04-01,15281.525 +2006-07-01,15304.517 +2006-10-01,15433.643 +2007-01-01,15478.956 +2007-04-01,15577.779 +2007-07-01,15671.605 +2007-10-01,15767.146 +2008-01-01,15702.906 +2008-04-01,15792.773 +2008-07-01,15709.562 +2008-10-01,15366.607 +2009-01-01,15187.475 +2009-04-01,15161.772 +2009-07-01,15216.647 +2009-10-01,15379.155 +2010-01-01,15456.059 +2010-04-01,15605.628 +2010-07-01,15726.282 +2010-10-01,15807.995 +2011-01-01,15769.911 +2011-04-01,15876.839 +2011-07-01,15870.684 +2011-10-01,16048.702 +2012-01-01,16179.968 +2012-04-01,16253.726 +2012-07-01,16282.151 +2012-10-01,16300.035 +2013-01-01,16441.485 +2013-04-01,16464.402 +2013-07-01,16594.743 +2013-10-01,16712.76 +2014-01-01,16654.247 +2014-04-01,16868.109 +2014-07-01,17064.616 +2014-10-01,17141.235 +2015-01-01,17280.647 +2015-04-01,17380.875 +2015-07-01,17437.08 +2015-10-01,17462.579 +2016-01-01,17565.465 +2016-04-01,17618.581 +2016-07-01,17724.489 +2016-10-01,17812.56 +2017-01-01,17896.623 +2017-04-01,17996.802 +2017-07-01,18126.226 +2017-10-01,18296.685 +2018-01-01,18436.262 +2018-04-01,18590.004 +2018-07-01,18679.599 +2018-10-01,18721.281 +2019-01-01,18833.195 +2019-04-01,18982.528 +2019-07-01,19112.653 +2019-10-01,19202.31 +2020-01-01,18951.992 +2020-04-01,17258.205 +2020-07-01,18560.774 +2020-10-01,18767.778 +2021-01-01,19055.655 +2021-04-01,19368.31 +2021-07-01,19478.893 +2021-10-01,19806.29 +2022-01-01,19735.895 \ No newline at end of file diff --git a/test/output/lineHalo.svg b/test/output/lineHalo.svg new file mode 100644 index 0000000000..42a93106af --- /dev/null +++ b/test/output/lineHalo.svg @@ -0,0 +1,164 @@ + + + + + + + + −4 + −2 + 0 + +2 + +4 + +6 + +8 + +10 + + + ↑ cumulative change in GDP from the start of the last 5 recessions (%) + + + + + + + + + 4 + + + + 8 + + + + 12 + + + + 16 + + + + + + + + + + ← Final quarter before recession + ← 9 quarters into recession + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1980 + 1990 + 2001 + 2008 + 2020 + + \ No newline at end of file diff --git a/test/output/lineHaloStyles.svg b/test/output/lineHaloStyles.svg new file mode 100644 index 0000000000..d75a0bc080 --- /dev/null +++ b/test/output/lineHaloStyles.svg @@ -0,0 +1,256 @@ + + + + + 0.90 + 0.92 + 0.94 + 0.96 + 0.98 + 1.00 + 1.02 + 1.04 + 1.06 + 1.08 + 1.10 + + + ↑ gdpc1 + + + + 0 + 2 + 4 + 6 + 8 + 10 + 12 + 14 + 16 + + + quarters → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plot.js b/test/plot.js index d9c238dd6a..cfea0aa4cb 100644 --- a/test/plot.js +++ b/test/plot.js @@ -22,6 +22,7 @@ for (const [name, plot] of Object.entries(plots)) { reindexMarker(root); reindexClip(root); reindexPattern(root); + reindexHalo(root); let expected; let actual = normalizeHtml(root.outerHTML); const outfile = path.resolve("./test/output", `${path.basename(name, ".js")}.${ext}`); @@ -138,6 +139,21 @@ function reindexPattern(root) { } } +function reindexHalo(root) { + let index = 0; + const map = new Map(); + for (const node of root.querySelectorAll("[id^=plot-halo-]")) { + let id = node.getAttribute("id"); + if (map.has(id)) id = map.get(id); + else map.set(id, (id = `plot-halo-${++index}`)); + node.setAttribute("id", id); + } + for (const node of root.querySelectorAll("[filter]")) { + let id = node.getAttribute("filter").slice(5, -1); + if (map.has(id)) node.setAttribute("filter", `url(#${map.get(id)})`); + } +} + const imageRe = /data:image\/png;base64,[^"]+/g; function stripImages(string) { diff --git a/test/plots/index.ts b/test/plots/index.ts index 3c019e678f..d45b8d55bc 100644 --- a/test/plots/index.ts +++ b/test/plots/index.ts @@ -154,6 +154,7 @@ export * from "./letter-frequency-lollipop.js"; export * from "./letter-frequency-wheel.js"; export * from "./libor-projections.js"; export * from "./likert-survey.js"; +export * from "./line-halo.js"; export * from "./linear-regression-cars.js"; export * from "./linear-regression-mtcars.js"; export * from "./linear-regression-penguins.js"; diff --git a/test/plots/line-halo.ts b/test/plots/line-halo.ts new file mode 100644 index 0000000000..62068f0812 --- /dev/null +++ b/test/plots/line-halo.ts @@ -0,0 +1,106 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export async function lineHalo() { + const gdp = await d3.csv("data/us-gdp.csv", d3.autoType); + const recession = ["1980-04-01", "1990-10-01", "2001-04-01", "2008-01-01", "2020-01-01"].map(d3.isoParse); + const quarters = 16; + const fredSeries = recession.flatMap((start) => { + const min = d3.utcMonth.offset(start!, -3); + const max = d3.utcMonth.offset(start!, quarters * 3); + return gdp + .filter((d: any) => d.date >= min && d.date < max) + .map((d: any) => ({ + ...d, + start, + quarters: d3.utcMonth.every(3)!.range(d3.utcMonth.offset(start!, -3), d.date).length + })); + }); + return Plot.plot({ + width: 600, + height: 350, + marginRight: 30, + x: { + insetLeft: 20, + insetRight: 0, + ticks: quarters, + tickFormat: (d) => (d === 0 ? "" : d % 4 ? "" : `${d}`), + label: null, + line: true + }, + y: { + type: "log", + grid: true, + tickSize: 0, + tickFormat: (d1) => (d1 === 1 ? "0" : d3.format("+")(Math.round(100 * (d1 - 1)))), + label: "\u2191 cumulative change in GDP from the start of the last 5 recessions (%)", + insetTop: -10, + insetBottom: 15 + }, + color: {range: d3.schemeBlues[6].slice(-4).concat("red")}, + marks: [ + Plot.ruleY([1], {strokeWidth: 0.5}), + Plot.ruleX([0, 9], {strokeDasharray: "2,4"}), + Plot.text([0, 9], { + x: [0, 9], + y: [1 + 7 / 100, 1 - 7 / 100], + textAnchor: "start", + text: ["\u2190 Final quarter before recession", "\u2190 9 quarters into recession"], + dx: 4 + }), + Plot.line( + fredSeries, + Plot.normalizeY({ + x: "quarters", + y: "gdpc1", + stroke: "start", + halo: true + }) + ), + Plot.text( + fredSeries, + Plot.selectMaxX( + Plot.normalizeY({ + x: "quarters", + y: "gdpc1", + z: "start", + textAnchor: "start", + dx: 5, + text: (d: any) => String(d.start.getUTCFullYear()), + fill: (d: any) => String(d.start.getUTCFullYear()) + }) + ) + ) + ] + }); +} + +export async function lineHaloStyles() { + const gdp = await d3.csv("data/us-gdp.csv", d3.autoType); + const recession = ["1980-04-01", "1990-10-01", "2001-04-01", "2008-01-01", "2020-01-01"].map(d3.isoParse); + const quarters = 16; + const fredSeries = recession.flatMap((start) => { + const min = d3.utcMonth.offset(start!, -3); + const max = d3.utcMonth.offset(start!, quarters * 3); + return gdp + .filter((d: any) => d.date >= min && d.date < max) + .map((d: any) => ({ + ...d, + start, + quarters: d3.utcMonth.every(3)!.range(d3.utcMonth.offset(start!, -3), d.date).length + })); + }); + return Plot.plot({ + width: 600, + height: 350, + marks: [ + Plot.line(fredSeries, Plot.normalizeY({x: "quarters", y: "gdpc1", stroke: "start", halo: "lightblue"})), + Plot.line(fredSeries, Plot.normalizeY({x: "quarters", y: "gdpc1", stroke: "start", halo: 7})), + Plot.line( + fredSeries, + Plot.normalizeY({x: "quarters", y: "gdpc1", stroke: "start", haloColor: "pink", haloRadius: 1}) + ) + ], + color: {range: d3.schemeBlues[6].slice(-4).concat("red")} + }); +} From 8347fea57c3a5f0a125d280b300b771c797200c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 13 Mar 2026 08:29:05 +0100 Subject: [PATCH 2/2] leaner (one filter is shared by all lines) --- src/marks/halo.js | 42 +++---- src/marks/line.js | 3 +- test/output/lineHalo.svg | 62 ++-------- test/output/lineHaloSingle.svg | 214 +++++++++++++++++++++++++++++++++ test/output/lineHaloStyles.svg | 190 +++++++---------------------- test/plots/line-halo.ts | 11 +- 6 files changed, 295 insertions(+), 227 deletions(-) create mode 100644 test/output/lineHaloSingle.svg diff --git a/src/marks/halo.js b/src/marks/halo.js index d1eb0c528d..b1f94d4a32 100644 --- a/src/marks/halo.js +++ b/src/marks/halo.js @@ -10,34 +10,22 @@ function getHaloId() { } export function applyHalo(selection, {halo}) { - if (!halo) return; + if (!halo) return null; const {color, radius} = halo; - const filters = new WeakMap(); - selection.attr("filter", function () { - const id = getHaloId(); - filters.set(this, id); - return `url(#${id})`; - }); - selection - .append("filter") - .attr("id", function () { - return filters.get(this.parentNode); - }) - .call((filter) => - filter - .append("feMorphology") - .attr("in", "SourceAlpha") - .attr("result", "dilated") - .attr("operator", "dilate") - .attr("radius", radius) - ) - .call((filter) => filter.append("feFlood").style("flood-color", color)) - .call((filter) => filter.append("feComposite").attr("in2", "dilated").attr("operator", "in")) - .append("feMerge") - .call((merge) => { - merge.append("feMergeNode"); - merge.append("feMergeNode").attr("in", "SourceGraphic"); - }); + const id = getHaloId(); + const filter = selection.append("filter").attr("id", id); + filter + .append("feMorphology") + .attr("in", "SourceAlpha") + .attr("result", "dilated") + .attr("operator", "dilate") + .attr("radius", radius); + filter.append("feFlood").style("flood-color", color); + filter.append("feComposite").attr("in2", "dilated").attr("operator", "in"); + const merge = filter.append("feMerge"); + merge.append("feMergeNode"); + merge.append("feMergeNode").attr("in", "SourceGraphic"); + return `url(#${id})`; } export function maybeHalo(halo, color, radius) { diff --git a/src/marks/line.js b/src/marks/line.js index b8f9f09333..ca0d10535a 100644 --- a/src/marks/line.js +++ b/src/marks/line.js @@ -60,6 +60,7 @@ export class Line extends Mark { // When adding a halo to multiple series, nest by series so each // gets its own halo filter; otherwise render paths directly into g. + const filter = applyHalo(g, this); const segments = groupIndex(index, [X, Y], this, channels); (this.halo && Z ? g @@ -69,7 +70,7 @@ export class Line extends Mark { .append("g") : g.datum([, segments]) ) - .call(applyHalo, this) + .attr("filter", filter) .selectAll() .data(([, d]) => d) .enter() diff --git a/test/output/lineHalo.svg b/test/output/lineHalo.svg index 42a93106af..c8afded75a 100644 --- a/test/output/lineHalo.svg +++ b/test/output/lineHalo.svg @@ -93,64 +93,28 @@ ← 9 quarters into recession + + + + + + + + + - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - + - - - - - - - - - - + - - - - - - - - - - + diff --git a/test/output/lineHaloSingle.svg b/test/output/lineHaloSingle.svg new file mode 100644 index 0000000000..1f31a0dee1 --- /dev/null +++ b/test/output/lineHaloSingle.svg @@ -0,0 +1,214 @@ + + + + + 40 + 60 + 80 + 100 + 120 + 140 + 160 + 180 + 200 + + + ↑ Close + + + + 2014 + 2015 + 2016 + 2017 + 2018 + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/lineHaloStyles.svg b/test/output/lineHaloStyles.svg index d75a0bc080..381e8f38c8 100644 --- a/test/output/lineHaloStyles.svg +++ b/test/output/lineHaloStyles.svg @@ -68,188 +68,80 @@ quarters → + + + + + + + + + - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - + - - - - - - - - - - + - - - - - - - - - - + - - - - - - - - - - + + + + + + + + + + - - - - - - - - - - + - - - - - - - - - - + - - - - - - - - - - + - - - - - - - - - - + - - - - - - - - - - + + + + + + + + + + - - - - - - - - - - + - - - - - - - - - - + - - - - - - - - - - + - - - - - - - - - - + diff --git a/test/plots/line-halo.ts b/test/plots/line-halo.ts index 62068f0812..4f49ba7cf5 100644 --- a/test/plots/line-halo.ts +++ b/test/plots/line-halo.ts @@ -48,7 +48,7 @@ export async function lineHalo() { text: ["\u2190 Final quarter before recession", "\u2190 9 quarters into recession"], dx: 4 }), - Plot.line( + Plot.lineY( fredSeries, Plot.normalizeY({ x: "quarters", @@ -75,6 +75,15 @@ export async function lineHalo() { }); } +export async function lineHaloSingle() { + const aapl = await d3.csv("data/aapl.csv", d3.autoType); + return Plot.plot({ + x: {nice: 100}, + y: {nice: true}, + marks: [Plot.gridX({ticks: 100}), Plot.gridY({tickSpacing: 5}), Plot.lineY(aapl, {x: "Date", y: "Close", halo: 4})] + }); +} + export async function lineHaloStyles() { const gdp = await d3.csv("data/us-gdp.csv", d3.autoType); const recession = ["1980-04-01", "1990-10-01", "2001-04-01", "2008-01-01", "2020-01-01"].map(d3.isoParse);