Skip to content

line halo #870

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3273,6 +3273,15 @@ For each pixel in the raster grid, initiates a random walk, stopping when either

<!-- jsdocEnd interpolatorRandomWalk -->


## 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:
Expand Down
33 changes: 33 additions & 0 deletions src/marks/halo.js
Original file line number Diff line number Diff line change
@@ -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(`
<feMorphology in="SourceAlpha" result="DILATED" operator="dilate" radius="${radius}"></feMorphology>
<feFlood flood-color="${color}" result="BG"></feFlood>
<feComposite in="BG" in2="DILATED" operator="in" result="OUTLINE"></feComposite>
<feMerge>
<feMergeNode in="OUTLINE" />
<feMergeNode in="SourceGraphic" />
</feMerge>`);
Comment on lines +9 to +15
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You only need the result+in for dilated; otherwise in defaults to the previous filter primitive.

Suggested change
<feMorphology in="SourceAlpha" result="DILATED" operator="dilate" radius="${radius}"></feMorphology>
<feFlood flood-color="${color}" result="BG"></feFlood>
<feComposite in="BG" in2="DILATED" operator="in" result="OUTLINE"></feComposite>
<feMerge>
<feMergeNode in="OUTLINE" />
<feMergeNode in="SourceGraphic" />
</feMerge>`);
<feMorphology in="SourceAlpha" result="dilated" operator="dilate" radius="${radius}"></feMorphology>
<feFlood flood-color="${color}"></feFlood>
<feComposite in2="dilated" operator="in"></feComposite>
<feMerge>
<feMergeNode></feMergeNode>
<feMergeNode in="SourceGraphic"></feMergeNode>
</feMerge>`);

}

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};
}
31 changes: 25 additions & 6 deletions src/marks/line.js
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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",
Expand All @@ -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,
{
Expand All @@ -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) {
Expand All @@ -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
Expand All @@ -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
Copy link
Member

@mbostock mbostock Apr 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is hard to follow. I think it would be clearer to initialize the DOM the desired way from the beginning, rather than creating it once (above using groupIndex) and then moving things around afterwards to apply the halo. (Especially since this already requires changing groupIndex to populate a segment property on the data, whose purpose isn’t clear until you go look at the code here.)

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();
}
}

Expand Down
13 changes: 8 additions & 5 deletions src/style.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
}

Expand All @@ -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;
}
}

Expand Down
5 changes: 5 additions & 0 deletions test/data/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading