Skip to content

Commit cf2ebf4

Browse files
committed
line halo
1 parent 33a3ea1 commit cf2ebf4

10 files changed

Lines changed: 957 additions & 24 deletions

File tree

docs/marks/line.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,12 @@ Points along the line are connected in input order. Likewise, if there are multi
361361

362362
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.
363363

364+
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:
365+
366+
* **halo** - if true, draws a halo; if a color, sets the halo color; if a number, sets the halo radius
367+
* **haloColor** - the halo color; defaults to *var(--plot-background)*
368+
* **haloRadius** - the halo radius in pixels; defaults to 2
369+
364370
## line(*data*, *options*) {#line}
365371

366372
```js

src/marks/halo.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import {isColor} from "../options.js";
2+
3+
const defaultColor = "var(--plot-background)";
4+
const defaultRadius = 2;
5+
6+
let nextHaloId = 0;
7+
8+
function getHaloId() {
9+
return `plot-halo-${++nextHaloId}`;
10+
}
11+
12+
export function applyHalo(selection, {halo}) {
13+
if (!halo) return;
14+
const {color, radius} = halo;
15+
const filters = new WeakMap();
16+
selection.attr("filter", function () {
17+
const id = getHaloId();
18+
filters.set(this, id);
19+
return `url(#${id})`;
20+
});
21+
selection
22+
.append("filter")
23+
.attr("id", function () {
24+
return filters.get(this.parentNode);
25+
})
26+
.call((filter) =>
27+
filter
28+
.append("feMorphology")
29+
.attr("in", "SourceAlpha")
30+
.attr("result", "dilated")
31+
.attr("operator", "dilate")
32+
.attr("radius", radius)
33+
)
34+
.call((filter) => filter.append("feFlood").style("flood-color", color))
35+
.call((filter) => filter.append("feComposite").attr("in2", "dilated").attr("operator", "in"))
36+
.append("feMerge")
37+
.call((merge) => {
38+
merge.append("feMergeNode");
39+
merge.append("feMergeNode").attr("in", "SourceGraphic");
40+
});
41+
}
42+
43+
export function maybeHalo(halo, color, radius) {
44+
if (halo === undefined) halo = color !== undefined || radius !== undefined;
45+
if (!halo) return false;
46+
if (color === undefined) color = isColor(halo) ? halo : defaultColor;
47+
else if (!isColor(color)) throw new Error(`Unsupported halo color: ${color}`);
48+
if (radius === undefined) radius = typeof halo === "number" && !isNaN(halo) ? halo : defaultRadius;
49+
else if (isNaN(+radius)) throw new Error(`Unsupported halo radius: ${radius}`);
50+
return {color, radius};
51+
}

src/marks/line.d.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,23 @@ export interface LineOptions extends MarkOptions, MarkerOptions, CurveAutoOption
2222
* **fill** if a channel, or **stroke** if a channel.
2323
*/
2424
z?: ChannelValue;
25+
26+
/**
27+
* Draw a halo around the line to help separate overlapping lines. If true,
28+
* draws a halo with the plot background color and a 2px radius. If a color,
29+
* uses that color. If a number, uses that radius.
30+
*/
31+
halo?: boolean | string | number;
32+
33+
/**
34+
* The halo’s color; defaults to background color.
35+
*/
36+
haloColor?: string;
37+
38+
/**
39+
* The halo’s radius in pixels; defaults to 2.
40+
*/
41+
haloRadius?: number;
2542
}
2643

2744
/** Options for the lineX mark. */

src/marks/line.js

Lines changed: 38 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {line as shapeLine} from "d3";
1+
import {group, line as shapeLine} from "d3";
22
import {create} from "../context.js";
33
import {curveAuto, maybeCurveAuto} from "../curve.js";
44
import {Mark} from "../mark.js";
@@ -12,6 +12,7 @@ import {
1212
groupIndex
1313
} from "../style.js";
1414
import {maybeDenseIntervalX, maybeDenseIntervalY} from "../transforms/bin.js";
15+
import {applyHalo, maybeHalo} from "./halo.js";
1516

1617
const defaults = {
1718
ariaLabel: "line",
@@ -25,7 +26,7 @@ const defaults = {
2526

2627
export class Line extends Mark {
2728
constructor(data, options = {}) {
28-
const {x, y, z, curve, tension} = options;
29+
const {x, y, z, curve, tension, halo, haloColor, haloRadius} = options;
2930
super(
3031
data,
3132
{
@@ -38,6 +39,7 @@ export class Line extends Mark {
3839
);
3940
this.z = z;
4041
this.curve = maybeCurveAuto(curve, tension);
42+
this.halo = maybeHalo(halo, haloColor, haloRadius);
4143
markers(this, options);
4244
}
4345
filter(index) {
@@ -50,32 +52,44 @@ export class Line extends Mark {
5052
}
5153
}
5254
render(index, scales, channels, dimensions, context) {
53-
const {x: X, y: Y} = channels;
55+
const {x: X, y: Y, z: Z} = channels;
5456
const {curve} = this;
55-
return create("svg:g", context)
57+
const shape =
58+
curve === curveAuto && context.projection
59+
? sphereLine(context.path(), X, Y)
60+
: shapeLine()
61+
.curve(curve)
62+
.defined((i) => i >= 0)
63+
.x((i) => X[i])
64+
.y((i) => Y[i]);
65+
66+
const segments = groupIndex(index, [X, Y], this, channels);
67+
68+
const g = create("svg:g", context)
5669
.call(applyIndirectStyles, this, dimensions, context)
57-
.call(applyTransform, this, scales)
58-
.call((g) =>
59-
g
70+
.call(applyTransform, this, scales);
71+
72+
// When adding a halo to multiple series, nest by series so each
73+
// gets its own halo filter; otherwise render paths directly into g.
74+
(this.halo && Z
75+
? g
6076
.selectAll()
61-
.data(groupIndex(index, [X, Y], this, channels))
77+
.data(group(segments, (I) => Z[I.find((i) => i >= 0)]))
6278
.enter()
63-
.append("path")
64-
.call(applyDirectStyles, this)
65-
.call(applyGroupedChannelStyles, this, channels)
66-
.call(applyGroupedMarkers, this, channels, context)
67-
.attr(
68-
"d",
69-
curve === curveAuto && context.projection
70-
? sphereLine(context.path(), X, Y)
71-
: shapeLine()
72-
.curve(curve)
73-
.defined((i) => i >= 0)
74-
.x((i) => X[i])
75-
.y((i) => Y[i])
76-
)
77-
)
78-
.node();
79+
.append("g")
80+
: g.datum([, segments])
81+
)
82+
.call(applyHalo, this)
83+
.selectAll()
84+
.data(([, d]) => d)
85+
.enter()
86+
.append("path")
87+
.call(applyDirectStyles, this)
88+
.call(applyGroupedChannelStyles, this, channels)
89+
.call(applyGroupedMarkers, this, channels, context)
90+
.attr("d", shape);
91+
92+
return g.node();
7993
}
8094
}
8195

0 commit comments

Comments
 (0)