Skip to content

Commit 978c54e

Browse files
authored
group aesthetics (#761)
* group aesthetics * default filter * single paths for undefined data * relax none detection * default round caps and joins * warn on high-cardinality implicit z
1 parent 08118dd commit 978c54e

36 files changed

+272
-188
lines changed

src/legends/swatches.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import {create, path} from "d3";
22
import {inferFontVariant} from "../axes.js";
33
import {maybeTickFormat} from "../axis.js";
4-
import {maybeColorChannel, maybeNumberChannel} from "../options.js";
5-
import {applyInlineStyles, impliedString, maybeClassName, none} from "../style.js";
4+
import {isNoneish, maybeColorChannel, maybeNumberChannel} from "../options.js";
5+
import {applyInlineStyles, impliedString, maybeClassName} from "../style.js";
66

77
function maybeScale(scale, key) {
88
if (key == null) return key;
@@ -29,7 +29,7 @@ export function legendSwatches(color, options) {
2929
export function legendSymbols(symbol, {
3030
fill = symbol.hint?.fill !== undefined ? symbol.hint.fill : "none",
3131
fillOpacity = 1,
32-
stroke = symbol.hint?.stroke !== undefined ? symbol.hint.stroke : none(fill) ? "currentColor" : "none",
32+
stroke = symbol.hint?.stroke !== undefined ? symbol.hint.stroke : isNoneish(fill) ? "currentColor" : "none",
3333
strokeOpacity = 1,
3434
strokeWidth = 1.5,
3535
r = 4.5,

src/marks/area.js

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,52 @@
1-
import {area as shapeArea, create, group} from "d3";
1+
import {area as shapeArea, create} from "d3";
22
import {Curve} from "../curve.js";
3-
import {defined} from "../defined.js";
43
import {Mark} from "../plot.js";
54
import {indexOf, maybeZ} from "../options.js";
6-
import {applyDirectStyles, applyIndirectStyles, applyTransform, applyGroupedChannelStyles} from "../style.js";
5+
import {applyDirectStyles, applyIndirectStyles, applyTransform, applyGroupedChannelStyles, groupIndex} from "../style.js";
76
import {maybeIdentityX, maybeIdentityY} from "../transforms/identity.js";
87
import {maybeStackX, maybeStackY} from "../transforms/stack.js";
98

109
const defaults = {
10+
filter: null,
1111
ariaLabel: "area",
1212
strokeWidth: 1,
13+
strokeLinecap: "round",
14+
strokeLinejoin: "round",
1315
strokeMiterlimit: 1
1416
};
1517

1618
export class Area extends Mark {
1719
constructor(data, options = {}) {
18-
const {x1, y1, x2, y2, curve, tension} = options;
20+
const {x1, y1, x2, y2, z, curve, tension} = options;
1921
super(
2022
data,
2123
[
22-
{name: "x1", value: x1, filter: null, scale: "x"},
23-
{name: "y1", value: y1, filter: null, scale: "y"},
24-
{name: "x2", value: x2, filter: null, scale: "x", optional: true},
25-
{name: "y2", value: y2, filter: null, scale: "y", optional: true},
24+
{name: "x1", value: x1, scale: "x"},
25+
{name: "y1", value: y1, scale: "y"},
26+
{name: "x2", value: x2, scale: "x", optional: true},
27+
{name: "y2", value: y2, scale: "y", optional: true},
2628
{name: "z", value: maybeZ(options), optional: true}
2729
],
2830
options,
2931
defaults
3032
);
33+
this.z = z;
3134
this.curve = Curve(curve, tension);
3235
}
3336
render(I, {x, y}, channels, dimensions) {
34-
const {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1, z: Z} = channels;
37+
const {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1} = channels;
3538
const {dx, dy} = this;
3639
return create("svg:g")
3740
.call(applyIndirectStyles, this, dimensions)
3841
.call(applyTransform, x, y, dx, dy)
3942
.call(g => g.selectAll()
40-
.data(Z ? group(I, i => Z[i]).values() : [I])
43+
.data(groupIndex(I, [X1, Y1, X2, Y2], this, channels))
4144
.join("path")
4245
.call(applyDirectStyles, this)
4346
.call(applyGroupedChannelStyles, this, channels)
4447
.attr("d", shapeArea()
4548
.curve(this.curve)
46-
.defined(i => defined(X1[i]) && defined(Y1[i]) && defined(X2[i]) && defined(Y2[i]))
49+
.defined(i => i >= 0)
4750
.x0(i => X1[i])
4851
.y0(i => Y1[i])
4952
.x1(i => X2[i])

src/marks/line.js

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,53 @@
1-
import {create, group, line as shapeLine} from "d3";
1+
import {create, line as shapeLine} from "d3";
22
import {Curve} from "../curve.js";
3-
import {defined} from "../defined.js";
43
import {Mark} from "../plot.js";
54
import {indexOf, identity, maybeTuple, maybeZ} from "../options.js";
6-
import {applyDirectStyles, applyIndirectStyles, applyTransform, applyGroupedChannelStyles, offset} from "../style.js";
5+
import {applyDirectStyles, applyIndirectStyles, applyTransform, applyGroupedChannelStyles, offset, groupIndex} from "../style.js";
76
import {applyGroupedMarkers, markers} from "./marker.js";
87

98
const defaults = {
9+
filter: null,
1010
ariaLabel: "line",
1111
fill: "none",
1212
stroke: "currentColor",
1313
strokeWidth: 1.5,
14+
strokeLinecap: "round",
15+
strokeLinejoin: "round",
1416
strokeMiterlimit: 1
1517
};
1618

1719
export class Line extends Mark {
1820
constructor(data, options = {}) {
19-
const {x, y, curve, tension} = options;
21+
const {x, y, z, curve, tension} = options;
2022
super(
2123
data,
2224
[
23-
{name: "x", value: x, filter: null, scale: "x"},
24-
{name: "y", value: y, filter: null, scale: "y"},
25+
{name: "x", value: x, scale: "x"},
26+
{name: "y", value: y, scale: "y"},
2527
{name: "z", value: maybeZ(options), optional: true}
2628
],
2729
options,
2830
defaults
2931
);
32+
this.z = z;
3033
this.curve = Curve(curve, tension);
3134
markers(this, options);
3235
}
3336
render(I, {x, y}, channels, dimensions) {
34-
const {x: X, y: Y, z: Z} = channels;
37+
const {x: X, y: Y} = channels;
3538
const {dx, dy} = this;
3639
return create("svg:g")
3740
.call(applyIndirectStyles, this, dimensions)
3841
.call(applyTransform, x, y, offset + dx, offset + dy)
3942
.call(g => g.selectAll()
40-
.data(Z ? group(I, i => Z[i]).values() : [I])
43+
.data(groupIndex(I, [X, Y], this, channels))
4144
.join("path")
4245
.call(applyDirectStyles, this)
4346
.call(applyGroupedChannelStyles, this, channels)
4447
.call(applyGroupedMarkers, this, channels)
4548
.attr("d", shapeLine()
4649
.curve(this.curve)
47-
.defined(i => defined(X[i]) && defined(Y[i]))
50+
.defined(i => i >= 0)
4851
.x(i => X[i])
4952
.y(i => Y[i])))
5053
.node();

src/options.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,11 @@ export function take(values, index) {
123123
return Array.from(index, i => values[i]);
124124
}
125125

126+
// Based on InternMap (d3.group).
127+
export function keyof(value) {
128+
return value !== null && typeof value === "object" ? value.valueOf() : value;
129+
}
130+
126131
export function maybeInput(key, options) {
127132
if (options[key] !== undefined) return options[key];
128133
switch (key) {
@@ -271,6 +276,18 @@ export function isColor(value) {
271276
|| color(value) !== null;
272277
}
273278

279+
export function isNoneish(value) {
280+
return value == null || isNone(value);
281+
}
282+
283+
export function isNone(value) {
284+
return /^\s*none\s*$/i.test(value);
285+
}
286+
287+
export function isRound(value) {
288+
return /^\s*round\s*$/i.test(value);
289+
}
290+
274291
const symbols = new Map([
275292
["asterisk", symbolAsterisk],
276293
["circle", symbolCircle],

src/plot.js

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,8 @@ export function plot(options = {}) {
9999
for (const mark of marks) {
100100
const channels = markChannels.get(mark) ?? [];
101101
const values = applyScales(channels, scales);
102-
const index = filter(markIndex.get(mark), channels, values);
102+
let index = markIndex.get(mark);
103+
if (mark.filter != null) index = mark.filter(index, channels, values);
103104
const node = mark.render(index, scales, values, dimensions, axes);
104105
if (node != null) svg.appendChild(node);
105106
}
@@ -136,7 +137,7 @@ export function plot(options = {}) {
136137
return figure;
137138
}
138139

139-
function filter(index, channels, values) {
140+
function defaultFilter(index, channels, values) {
140141
for (const [name, {filter = defined}] of channels) {
141142
if (name !== undefined && filter !== null) {
142143
const value = values[name];
@@ -154,6 +155,7 @@ export class Mark {
154155
this.sort = isOptions(sort) ? sort : null;
155156
this.facet = facet == null || facet === false ? null : keyword(facet === true ? "include" : facet, "facet", ["auto", "include", "exclude"]);
156157
const {transform} = basic(options);
158+
this.filter = defaults?.filter === undefined ? defaultFilter : defaults.filter;
157159
this.transform = transform;
158160
if (defaults !== undefined) channels = styles(this, options, channels, defaults);
159161
this.channels = channels.filter(channel => {
@@ -328,9 +330,11 @@ class Facet extends Mark {
328330
.each(function(key) {
329331
const marksFacetIndex = marksIndexByFacet.get(key);
330332
for (let i = 0; i < marks.length; ++i) {
333+
const mark = marks[i];
331334
const values = marksValues[i];
332-
const index = filter(marksFacetIndex[i], marksChannels[i], values);
333-
const node = marks[i].render(index, scales, values, subdimensions);
335+
let index = marksFacetIndex[i];
336+
if (mark.filter != null) index = mark.filter(index, marksChannels[i], values);
337+
const node = mark.render(index, scales, values, subdimensions);
334338
if (node != null) this.appendChild(node);
335339
}
336340
}))

src/scales/ordinal.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import {InternSet, quantize, reverse as reverseof, sort, symbolsFill, symbolsStroke} from "d3";
22
import {scaleBand, scaleOrdinal, scalePoint, scaleImplicit} from "d3";
33
import {ascendingDefined} from "../defined.js";
4-
import {maybeSymbol} from "../options.js";
5-
import {none} from "../style.js";
4+
import {maybeSymbol, isNoneish} from "../options.js";
65
import {registry, color, symbol} from "./index.js";
76
import {maybeBooleanRange, ordinalScheme, quantitativeScheme} from "./schemes.js";
87

@@ -127,5 +126,5 @@ function inferSymbolHint(channels) {
127126
}
128127

129128
function inferSymbolRange(hint) {
130-
return none(hint.fill) ? symbolsStroke : symbolsFill;
129+
return isNoneish(hint.fill) ? symbolsStroke : symbolsFill;
131130
}

src/style.js

Lines changed: 72 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import {isoFormat, namespaces} from "d3";
2-
import {nonempty} from "./defined.js";
1+
import {group, isoFormat, namespaces} from "d3";
2+
import {defined, nonempty} from "./defined.js";
33
import {formatNumber} from "./format.js";
4-
import {string, number, maybeColorChannel, maybeNumberChannel, isTemporal, isNumeric} from "./options.js";
4+
import {string, number, maybeColorChannel, maybeNumberChannel, isTemporal, isNumeric, isNoneish, isNone, isRound, keyof} from "./options.js";
5+
import {warn} from "./warnings.js";
56

67
export const offset = typeof window !== "undefined" && window.devicePixelRatio > 1 ? 0 : 0.5;
78

@@ -64,10 +65,10 @@ export function styles(
6465
// applies if the stroke is (constant) none; if you set a stroke, then the
6566
// default fill becomes none. Similarly for marks that stroke by stroke, the
6667
// default stroke only applies if the fill is (constant) none.
67-
if (none(defaultFill)) {
68-
if (!none(defaultStroke) && !none(fill)) defaultStroke = "none";
68+
if (isNoneish(defaultFill)) {
69+
if (!isNoneish(defaultStroke) && !isNoneish(fill)) defaultStroke = "none";
6970
} else {
70-
if (none(defaultStroke) && !none(stroke)) defaultFill = "none";
71+
if (isNoneish(defaultStroke) && !isNoneish(stroke)) defaultFill = "none";
7172
}
7273

7374
const [vfill, cfill] = maybeColorChannel(fill, defaultFill);
@@ -79,16 +80,19 @@ export function styles(
7980
// For styles that have no effect if there is no stroke, only apply the
8081
// defaults if the stroke is not the constant none. (If stroke is a channel,
8182
// then cstroke will be undefined, but there’s still a stroke; hence we don’t
82-
// use the none helper here.)
83-
if (cstroke !== "none") {
83+
// use isNoneish here.)
84+
if (!isNone(cstroke)) {
8485
if (strokeWidth === undefined) strokeWidth = defaultStrokeWidth;
8586
if (strokeLinecap === undefined) strokeLinecap = defaultStrokeLinecap;
8687
if (strokeLinejoin === undefined) strokeLinejoin = defaultStrokeLinejoin;
87-
if (strokeMiterlimit === undefined) strokeMiterlimit = defaultStrokeMiterlimit;
88+
89+
// The default stroke miterlimit need not be applied if the current stroke
90+
// is the constant round; this only has effect on miter joins.
91+
if (strokeMiterlimit === undefined && !isRound(strokeLinejoin)) strokeMiterlimit = defaultStrokeMiterlimit;
8892

8993
// The paint order only takes effect if there is both a fill and a stroke
9094
// (at least if we ignore markers, which no built-in marks currently use).
91-
if (cfill !== "none" && paintOrder === undefined) paintOrder = defaultPaintOrder;
95+
if (!isNone(cfill) && paintOrder === undefined) paintOrder = defaultPaintOrder;
9296
}
9397

9498
const [vstrokeWidth, cstrokeWidth] = maybeNumberChannel(strokeWidth);
@@ -176,6 +180,64 @@ export function applyGroupedChannelStyles(selection, {target}, {ariaLabel: AL, t
176180
applyTitleGroup(selection, T);
177181
}
178182

183+
function groupAesthetics({ariaLabel: AL, title: T, fill: F, fillOpacity: FO, stroke: S, strokeOpacity: SO, strokeWidth: SW, opacity: O, href: H}) {
184+
return [AL, T, F, FO, S, SO, SW, O, H].filter(c => c !== undefined);
185+
}
186+
187+
function groupZ(I, Z, z) {
188+
const G = group(I, i => Z[i]);
189+
if (z === undefined && G.size > I.length >> 2) {
190+
warn(`Warning: the implicit z channel has high cardinality. This may occur when the fill or stroke channel is associated with quantitative data rather than ordinal or categorical data. You can suppress this warning by setting the z option explicitly; if this data represents a single series, set z to null.`);
191+
}
192+
return G.values();
193+
}
194+
195+
export function* groupIndex(I, position, {z}, channels) {
196+
const {z: Z} = channels; // group channel
197+
const A = groupAesthetics(channels); // aesthetic channels
198+
const C = [...position, ...A]; // all channels
199+
200+
// Group the current index by Z (if any).
201+
for (const G of Z ? groupZ(I, Z, z) : [I]) {
202+
let Ag; // the A-values (aesthetics) of the current group, if any
203+
let Gg; // the current group index (a subset of G, and I), if any
204+
out: for (const i of G) {
205+
206+
// If any channel has an undefined value for this index, skip it.
207+
for (const c of C) {
208+
if (!defined(c[i])) {
209+
if (Gg) Gg.push(-1);
210+
continue out;
211+
}
212+
}
213+
214+
// Otherwise, if this is a new group, record the aesthetics for this
215+
// group. Yield the current group and start a new one.
216+
if (Ag === undefined) {
217+
if (Gg) yield Gg;
218+
Ag = A.map(c => keyof(c[i])), Gg = [i];
219+
continue;
220+
}
221+
222+
// Otherwise, add the current index to the current group. Then, if any of
223+
// the aesthetics don’t match the current group, yield the current group
224+
// and start a new group of the current index.
225+
Gg.push(i);
226+
for (let j = 0; j < A.length; ++j) {
227+
const k = keyof(A[j][i]);
228+
if (k !== Ag[j]) {
229+
yield Gg;
230+
Ag = A.map(c => keyof(c[i])), Gg = [i];
231+
continue out;
232+
}
233+
}
234+
}
235+
236+
// Yield the current group, if any.
237+
if (Gg) yield Gg;
238+
}
239+
}
240+
179241
// clip: true clips to the frame
180242
// TODO: accept other types of clips (paths, urls, x, y, other marks?…)
181243
// https://github.com/observablehq/plot/issues/181
@@ -254,10 +316,6 @@ export function impliedNumber(value, impliedValue) {
254316
if ((value = number(value)) !== impliedValue) return value;
255317
}
256318

257-
export function none(color) {
258-
return color == null || /^\s*none\s*$/i.test(color);
259-
}
260-
261319
const validClassName = /^-?([_a-z]|[\240-\377]|\\[0-9a-f]{1,6}(\r\n|[ \t\r\n\f])?|\\[^\r\n\f0-9a-f])([_a-z0-9-]|[\240-\377]|\\[0-9a-f]{1,6}(\r\n|[ \t\r\n\f])?|\\[^\r\n\f0-9a-f])*$/;
262320

263321
export function maybeClassName(name) {

test/marks/line-test.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ it("line() has the expected defaults", () => {
1515
assert.strictEqual(line.stroke, "currentColor");
1616
assert.strictEqual(line.strokeWidth, 1.5);
1717
assert.strictEqual(line.strokeOpacity, undefined);
18-
assert.strictEqual(line.strokeLinejoin, undefined);
19-
assert.strictEqual(line.strokeLinecap, undefined);
20-
assert.strictEqual(line.strokeMiterlimit, 1);
18+
assert.strictEqual(line.strokeLinejoin, "round");
19+
assert.strictEqual(line.strokeLinecap, "round");
20+
assert.strictEqual(line.strokeMiterlimit, undefined);
2121
assert.strictEqual(line.strokeDasharray, undefined);
2222
assert.strictEqual(line.strokeDashoffset, undefined);
2323
assert.strictEqual(line.mixBlendMode, undefined);

test/output/aaplBollinger.svg

Lines changed: 2 additions & 2 deletions
Loading

test/output/aaplClose.svg

Lines changed: 1 addition & 1 deletion
Loading

0 commit comments

Comments
 (0)