diff --git a/README.md b/README.md
index 999a9ab832..fddeb855d8 100644
--- a/README.md
+++ b/README.md
@@ -3273,6 +3273,15 @@ For each pixel in the raster grid, initiates a random walk, stopping when either
+
+## Halo
+
+The line mark support a halo filter, allowing to better separate multiple lines by adding a thick white background underneath each line, [a technique described by Sara Soueidan](https://tympanus.net/codrops/2019/01/22/svg-filter-effects-outline-text-with-femorphology/). The halo options can be specified as:
+
+* *haloColor* - the halo’s color, defaults to white
+* *haloRadius* - the halo’s radius, which defaults to 2px
+* *halo* - if true, activates the halo filter; if specified as a color, defines the halo’s color; if specified as a number, defines the halo’s radius
+
## Markers
A [marker](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/marker) defines a graphic drawn on vertices of a [line](#line) or a [link](#link) mark. The supported marker options are:
diff --git a/src/marks/halo.js b/src/marks/halo.js
new file mode 100644
index 0000000000..e12ecfe934
--- /dev/null
+++ b/src/marks/halo.js
@@ -0,0 +1,33 @@
+import {isColor} from "../options.js";
+
+let nextHaloId = 0;
+
+export function applyHalo(g, {color, radius}) {
+ const id = `plot-linehalo-${nextHaloId++}`;
+ g.selectChildren().style("filter", `url(#${id})`);
+ g.append("filter").attr("id", id).html(`
+
+
+
+
+
+
+ `);
+}
+
+export function maybeHalo(halo, color, radius) {
+ if (halo === undefined) halo = color !== undefined || radius !== undefined;
+ if (!halo) return false;
+ const defaults = {color: "white", radius: 2};
+ if (color === undefined) {
+ color = isColor(halo) ? halo : defaults.color;
+ } else if (!isColor(color)) {
+ throw new Error(`Unsupported halo color: ${color}`);
+ }
+ if (radius === undefined) {
+ radius = !isNaN(+halo) ? +halo : defaults.radius;
+ } else if (isNaN(+radius)) {
+ throw new Error(`Unsupported halo radius: ${radius}`);
+ }
+ return {color, radius};
+}
diff --git a/src/marks/line.js b/src/marks/line.js
index 77a7d080ad..61cc75e42f 100644
--- a/src/marks/line.js
+++ b/src/marks/line.js
@@ -1,4 +1,4 @@
-import {curveLinear, geoPath, line as shapeLine} from "d3";
+import {curveLinear, geoPath, group, line as shapeLine} from "d3";
import {create} from "../context.js";
import {Curve} from "../curve.js";
import {Mark} from "../mark.js";
@@ -13,6 +13,7 @@ import {
} from "../style.js";
import {maybeDenseIntervalX, maybeDenseIntervalY} from "../transforms/bin.js";
import {applyGroupedMarkers, markers} from "./marker.js";
+import {applyHalo, maybeHalo} from "./halo.js";
const defaults = {
ariaLabel: "line",
@@ -39,7 +40,7 @@ function LineCurve({curve = curveAuto, tension}) {
export class Line extends Mark {
constructor(data, options = {}) {
- const {x, y, z} = options;
+ const {x, y, z, halo, haloColor, haloRadius} = options;
super(
data,
{
@@ -52,6 +53,7 @@ export class Line extends Mark {
);
this.z = z;
this.curve = LineCurve(options);
+ this.halo = maybeHalo(halo, haloColor, haloRadius);
markers(this, options);
}
filter(index) {
@@ -66,8 +68,8 @@ export class Line extends Mark {
render(index, scales, channels, dimensions, context) {
const {x: X, y: Y} = channels;
const {curve} = this;
- return create("svg:g", context)
- .call(applyIndirectStyles, this, dimensions, context)
+ const g = create("svg:g", context)
+ .call(applyIndirectStyles, this, scales, dimensions)
.call(applyTransform, this, scales)
.call((g) =>
g
@@ -88,8 +90,25 @@ export class Line extends Mark {
.x((i) => X[i])
.y((i) => Y[i])
)
- )
- .node();
+ );
+
+ if (this.halo) {
+ // With variable aesthetics, we need to regroup segments by line
+ let line = -1;
+ let segmented = false;
+ const groups = group(g.selectAll("path"), (d) =>
+ d.__data__.segment === undefined ? ++line : ((segmented = true), line)
+ );
+ if (segmented) {
+ for (const [, paths] of groups) {
+ const l = g.append("g").node();
+ for (const p of paths) l.appendChild(p);
+ }
+ }
+ applyHalo(g, this.halo);
+ }
+
+ return g.node();
}
}
diff --git a/src/style.js b/src/style.js
index 083c5754ab..a3bc167f65 100644
--- a/src/style.js
+++ b/src/style.js
@@ -263,6 +263,7 @@ export function* groupIndex(I, position, {z}, channels) {
for (const G of Z ? groupZ(I, Z, z) : [I]) {
let Ag; // the A-values (aesthetics) of the current group, if any
let Gg; // the current group index (a subset of G, and I), if any
+ let segment = 0; // counter of sub-groups for the current z
out: for (const i of G) {
// If any channel has an undefined value for this index, skip it.
for (const c of C) {
@@ -275,8 +276,9 @@ export function* groupIndex(I, position, {z}, channels) {
// Otherwise, if this is a new group, record the aesthetics for this
// group. Yield the current group and start a new one.
if (Ag === undefined) {
- if (Gg) yield Gg;
- (Ag = A.map((c) => keyof(c[i]))), (Gg = [i]);
+ if (Gg) segment++ ? Object.assign(Gg, {segment}) : Gg;
+ Ag = A.map((c) => keyof(c[i]));
+ Gg = [i];
continue;
}
@@ -287,15 +289,16 @@ export function* groupIndex(I, position, {z}, channels) {
for (let j = 0; j < A.length; ++j) {
const k = keyof(A[j][i]);
if (k !== Ag[j]) {
- yield Gg;
- (Ag = A.map((c) => keyof(c[i]))), (Gg = [i]);
+ yield segment++ ? Object.assign(Gg, {segment}) : Gg;
+ Ag = A.map((c) => keyof(c[i]));
+ Gg = [i];
continue out;
}
}
}
// Yield the current group, if any.
- if (Gg) yield Gg;
+ if (Gg) yield segment++ ? Object.assign(Gg, {segment}) : Gg;
}
}
diff --git a/test/data/README.md b/test/data/README.md
index e4e77a68a3..ae0fade967 100644
--- a/test/data/README.md
+++ b/test/data/README.md
@@ -154,6 +154,11 @@ https://github.com/topojson/us-atlas
U.S. Bureau of Labor Statistics, 2016
https://www.bls.gov/lau/tables.htm
+## us-gdp.csv
+U.S. Gross Domestic Product, chained dollars, Jan 1947-Jan 2022
+Federal Reserve Economic Data
+https://fred.stlouisfed.org/
+
## us-state-capitals.csv
Unknown origin
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/recession.svg b/test/output/recession.svg
new file mode 100644
index 0000000000..cfa3649b42
--- /dev/null
+++ b/test/output/recession.svg
@@ -0,0 +1,120 @@
+
\ No newline at end of file
diff --git a/test/output/recessionHalos.svg b/test/output/recessionHalos.svg
new file mode 100644
index 0000000000..03d155b888
--- /dev/null
+++ b/test/output/recessionHalos.svg
@@ -0,0 +1,160 @@
+
\ No newline at end of file
diff --git a/test/plots/index.js b/test/plots/index.js
index dafea94656..720b7b15b9 100644
--- a/test/plots/index.js
+++ b/test/plots/index.js
@@ -296,4 +296,5 @@ export * from "./raster-ca55.js";
export * from "./raster-penguins.js";
export * from "./raster-vapor.js";
export * from "./raster-walmart.js";
+export * from "./recession.js";
export * from "./volcano.js";
diff --git a/test/plots/recession.js b/test/plots/recession.js
new file mode 100644
index 0000000000..8544f19f0a
--- /dev/null
+++ b/test/plots/recession.js
@@ -0,0 +1,174 @@
+import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
+
+export async function recession() {
+ 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) => d.date >= min && d.date < max)
+ .map((d) => ({...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: "↑ 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: ["← Final quarter before recession", "← 9 quarters into recession"],
+ dx: 4
+ }),
+ Plot.line(
+ fredSeries,
+ Plot.normalizeY({
+ x: "quarters",
+ y: "gdpc1",
+ stroke: "start",
+ marker: true,
+ halo: true
+ })
+ ),
+ Plot.text(
+ fredSeries,
+ Plot.selectMaxX(
+ Plot.normalizeY({
+ x: "quarters",
+ y: "gdpc1",
+ z: "start",
+ textAnchor: "start",
+ dx: 5,
+ text: (d) => String(d.start.getUTCFullYear()),
+ fill: (d) => String(d.start.getUTCFullYear())
+ })
+ )
+ )
+ ]
+ });
+}
+
+export async function recessionHalos() {
+ 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) => d.date >= min && d.date < max)
+ .map((d) => ({...d, start, quarters: d3.utcMonth.every(3).range(d3.utcMonth.offset(start, -3), d.date).length}));
+ });
+ const random = d3.randomLcg(42);
+ return Plot.plot({
+ height: 350,
+ width: 600,
+ marginRight: 30,
+ 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: ["← Final quarter before recession", "← 9 quarters into recession"],
+ dx: 4
+ }),
+ Plot.line(
+ fredSeries,
+ Plot.normalizeY({
+ filter: (d) => d.start === recession[0],
+ x: "quarters",
+ y: "gdpc1",
+ stroke: "start",
+ halo: "lightblue"
+ })
+ ),
+ Plot.line(
+ fredSeries,
+ Plot.normalizeY({
+ filter: (d) => d.start === recession[1],
+ x: "quarters",
+ y: "gdpc1",
+ stroke: "start",
+ halo: true
+ })
+ ),
+ Plot.line(
+ fredSeries,
+ Plot.normalizeY({
+ filter: (d) => d.start === recession[2],
+ x: "quarters",
+ y: "gdpc1",
+ stroke: "start",
+ strokeWidth: () => 3 + 2 * random(),
+ halo: true
+ })
+ ),
+ Plot.line(
+ fredSeries,
+ Plot.normalizeY({
+ filter: (d) => d.start === recession[3],
+ x: "quarters",
+ y: "gdpc1",
+ stroke: "start",
+ halo: 7
+ })
+ ),
+ Plot.line(
+ fredSeries,
+ Plot.normalizeY({
+ filter: (d) => d.start === recession[4],
+ x: "quarters",
+ y: "gdpc1",
+ stroke: "start",
+ strokeDasharray: "2% 1.5%",
+ haloColor: "pink",
+ haloRadius: 1
+ })
+ )
+ ],
+ 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: "↑ 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")}
+ });
+}