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..b1f94d4a32
--- /dev/null
+++ b/src/marks/halo.js
@@ -0,0 +1,39 @@
+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 null;
+ const {color, radius} = halo;
+ 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) {
+ 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..ca0d10535a 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,44 @@ 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 filter = applyHalo(g, this);
+ 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])
+ )
+ .attr("filter", filter)
+ .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..c8afded75a
--- /dev/null
+++ b/test/output/lineHalo.svg
@@ -0,0 +1,128 @@
+
\ No newline at end of file
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 @@
+
\ No newline at end of file
diff --git a/test/output/lineHaloStyles.svg b/test/output/lineHaloStyles.svg
new file mode 100644
index 0000000000..381e8f38c8
--- /dev/null
+++ b/test/output/lineHaloStyles.svg
@@ -0,0 +1,148 @@
+
\ 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..4f49ba7cf5
--- /dev/null
+++ b/test/plots/line-halo.ts
@@ -0,0 +1,115 @@
+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.lineY(
+ 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 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);
+ 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")}
+ });
+}