From 0650bef0cc074d78bbb629709a18b0d0af6879ca Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 16 Feb 2022 14:48:49 -0800 Subject: [PATCH] group aesthetics --- src/axis.js | 10 ++++-- src/decoration.js | 13 ++++++++ src/marks/area.js | 21 +++++++------ src/marks/line.js | 17 +++++----- src/options.js | 5 +++ src/plot.js | 22 +++++-------- src/style.js | 58 +++++++++++++++++++++++++++++++++-- test/output/availability.svg | 4 ++- test/output/carsParcoords.svg | 18 +++++++---- 9 files changed, 123 insertions(+), 45 deletions(-) create mode 100644 src/decoration.js diff --git a/src/axis.js b/src/axis.js index 7fff6cedeb4..9166b9025fe 100644 --- a/src/axis.js +++ b/src/axis.js @@ -3,8 +3,9 @@ import {boolean, take, number, string, keyword, maybeKeyword, constant, isTempor import {formatIsoDate} from "./format.js"; import {radians} from "./math.js"; import {applyAttr, impliedString} from "./style.js"; +import {Decoration} from "./decoration.js"; -export class AxisX { +export class AxisX extends Decoration { constructor({ name = "x", axis, @@ -22,6 +23,7 @@ export class AxisX { ariaLabel, ariaDescription } = {}) { + super(); this.name = name; this.axis = keyword(axis, "axis", ["top", "bottom"]); this.ticks = ticks; @@ -97,7 +99,7 @@ export class AxisX { } } -export class AxisY { +export class AxisY extends Decoration { constructor({ name = "y", axis, @@ -115,6 +117,7 @@ export class AxisY { ariaLabel, ariaDescription } = {}) { + super(); this.name = name; this.axis = keyword(axis, "axis", ["left", "right"]); this.ticks = ticks; @@ -131,6 +134,9 @@ export class AxisY { this.ariaLabel = string(ariaLabel); this.ariaDescription = string(ariaDescription); } + filter(I) { + return I; + } render( index, {[this.name]: y, fx}, diff --git a/src/decoration.js b/src/decoration.js new file mode 100644 index 00000000000..0a5dd9e72e9 --- /dev/null +++ b/src/decoration.js @@ -0,0 +1,13 @@ +import {defined} from "./defined.js"; + +export class Decoration { + filter(index, channels, values) { + for (const [name, {filter = defined}] of channels) { + if (name !== undefined && filter !== null) { + const value = values[name]; + index = index.filter(i => filter(value[i])); + } + } + return index; + } +} diff --git a/src/marks/area.js b/src/marks/area.js index 625b142464d..495003bdd23 100644 --- a/src/marks/area.js +++ b/src/marks/area.js @@ -1,9 +1,8 @@ -import {area as shapeArea, create, group} from "d3"; +import {area as shapeArea, create} from "d3"; import {Curve} from "../curve.js"; -import {defined} from "../defined.js"; import {Mark} from "../plot.js"; import {indexOf, maybeZ} from "../options.js"; -import {applyDirectStyles, applyIndirectStyles, applyTransform, applyGroupedChannelStyles} from "../style.js"; +import {applyDirectStyles, applyIndirectStyles, applyTransform, applyGroupedChannelStyles, groupIndex} from "../style.js"; import {maybeIdentityX, maybeIdentityY} from "../transforms/identity.js"; import {maybeStackX, maybeStackY} from "../transforms/stack.js"; @@ -19,10 +18,10 @@ export class Area extends Mark { super( data, [ - {name: "x1", value: x1, filter: null, scale: "x"}, - {name: "y1", value: y1, filter: null, scale: "y"}, - {name: "x2", value: x2, filter: null, scale: "x", optional: true}, - {name: "y2", value: y2, filter: null, scale: "y", optional: true}, + {name: "x1", value: x1, scale: "x"}, + {name: "y1", value: y1, scale: "y"}, + {name: "x2", value: x2, scale: "x", optional: true}, + {name: "y2", value: y2, scale: "y", optional: true}, {name: "z", value: maybeZ(options), optional: true} ], options, @@ -30,20 +29,22 @@ export class Area extends Mark { ); this.curve = Curve(curve, tension); } + filter(I) { + return I; + } render(I, {x, y}, channels, dimensions) { - const {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1, z: Z} = channels; + const {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1} = channels; const {dx, dy} = this; return create("svg:g") .call(applyIndirectStyles, this, dimensions) .call(applyTransform, x, y, dx, dy) .call(g => g.selectAll() - .data(Z ? group(I, i => Z[i]).values() : [I]) + .data(groupIndex(I, [X1, Y1, X2, Y2], channels)) .join("path") .call(applyDirectStyles, this) .call(applyGroupedChannelStyles, this, channels) .attr("d", shapeArea() .curve(this.curve) - .defined(i => defined(X1[i]) && defined(Y1[i]) && defined(X2[i]) && defined(Y2[i])) .x0(i => X1[i]) .y0(i => Y1[i]) .x1(i => X2[i]) diff --git a/src/marks/line.js b/src/marks/line.js index 209d40147f3..c34cd0f2e81 100644 --- a/src/marks/line.js +++ b/src/marks/line.js @@ -1,9 +1,8 @@ -import {create, group, line as shapeLine} from "d3"; +import {create, line as shapeLine} from "d3"; import {Curve} from "../curve.js"; -import {defined} from "../defined.js"; import {Mark} from "../plot.js"; import {indexOf, identity, maybeTuple, maybeZ} from "../options.js"; -import {applyDirectStyles, applyIndirectStyles, applyTransform, applyGroupedChannelStyles, offset} from "../style.js"; +import {applyDirectStyles, applyIndirectStyles, applyTransform, applyGroupedChannelStyles, offset, groupIndex} from "../style.js"; import {applyGroupedMarkers, markers} from "./marker.js"; const defaults = { @@ -20,8 +19,8 @@ export class Line extends Mark { super( data, [ - {name: "x", value: x, filter: null, scale: "x"}, - {name: "y", value: y, filter: null, scale: "y"}, + {name: "x", value: x, scale: "x"}, + {name: "y", value: y, scale: "y"}, {name: "z", value: maybeZ(options), optional: true} ], options, @@ -30,21 +29,23 @@ export class Line extends Mark { this.curve = Curve(curve, tension); markers(this, options); } + filter(I) { + return I; + } render(I, {x, y}, channels, dimensions) { - const {x: X, y: Y, z: Z} = channels; + const {x: X, y: Y} = channels; const {dx, dy} = this; return create("svg:g") .call(applyIndirectStyles, this, dimensions) .call(applyTransform, x, y, offset + dx, offset + dy) .call(g => g.selectAll() - .data(Z ? group(I, i => Z[i]).values() : [I]) + .data(groupIndex(I, [X, Y], channels)) .join("path") .call(applyDirectStyles, this) .call(applyGroupedChannelStyles, this, channels) .call(applyGroupedMarkers, this, channels) .attr("d", shapeLine() .curve(this.curve) - .defined(i => defined(X[i]) && defined(Y[i])) .x(i => X[i]) .y(i => Y[i]))) .node(); diff --git a/src/options.js b/src/options.js index 5eeb0aefe2c..38526e67706 100644 --- a/src/options.js +++ b/src/options.js @@ -123,6 +123,11 @@ export function take(values, index) { return Array.from(index, i => values[i]); } +// Based on InternMap (d3.group). +export function keyof(value) { + return value !== null && typeof value === "object" ? value.valueOf() : value; +} + export function maybeInput(key, options) { if (options[key] !== undefined) return options[key]; switch (key) { diff --git a/src/plot.js b/src/plot.js index 5299d73fb26..be52405b8ab 100644 --- a/src/plot.js +++ b/src/plot.js @@ -1,7 +1,7 @@ import {create, cross, difference, groups, InternMap, select} from "d3"; import {Axes, autoAxisTicks, autoScaleLabels} from "./axes.js"; import {Channel, channelSort} from "./channel.js"; -import {defined} from "./defined.js"; +import {Decoration} from "./decoration.js"; import {Dimensions} from "./dimensions.js"; import {Legends, exposeLegends} from "./legends.js"; import {arrayify, isOptions, keyword, range, first, second, where} from "./options.js"; @@ -99,7 +99,7 @@ export function plot(options = {}) { for (const mark of marks) { const channels = markChannels.get(mark) ?? []; const values = applyScales(channels, scales); - const index = filter(markIndex.get(mark), channels, values); + const index = mark.filter(markIndex.get(mark), channels, values); const node = mark.render(index, scales, values, dimensions, axes); if (node != null) svg.appendChild(node); } @@ -136,18 +136,9 @@ export function plot(options = {}) { return figure; } -function filter(index, channels, values) { - for (const [name, {filter = defined}] of channels) { - if (name !== undefined && filter !== null) { - const value = values[name]; - index = index.filter(i => filter(value[i])); - } - } - return index; -} - -export class Mark { +export class Mark extends Decoration { constructor(data, channels = [], options = {}, defaults) { + super(); const {facet = "auto", sort, dx, dy, clip} = options; const names = new Set(); this.data = data; @@ -328,9 +319,10 @@ class Facet extends Mark { .each(function(key) { const marksFacetIndex = marksIndexByFacet.get(key); for (let i = 0; i < marks.length; ++i) { + const mark = marks[i]; const values = marksValues[i]; - const index = filter(marksFacetIndex[i], marksChannels[i], values); - const node = marks[i].render(index, scales, values, subdimensions); + const index = mark.filter(marksFacetIndex[i], marksChannels[i], values); + const node = mark.render(index, scales, values, subdimensions); if (node != null) this.appendChild(node); } })) diff --git a/src/style.js b/src/style.js index c85e17934d1..be1b6617dbc 100644 --- a/src/style.js +++ b/src/style.js @@ -1,7 +1,7 @@ -import {isoFormat, namespaces} from "d3"; -import {nonempty} from "./defined.js"; +import {group, isoFormat, namespaces} from "d3"; +import {defined, nonempty} from "./defined.js"; import {formatNumber} from "./format.js"; -import {string, number, maybeColorChannel, maybeNumberChannel, isTemporal, isNumeric} from "./options.js"; +import {string, number, maybeColorChannel, maybeNumberChannel, isTemporal, isNumeric, keyof} from "./options.js"; export const offset = typeof window !== "undefined" && window.devicePixelRatio > 1 ? 0 : 0.5; @@ -176,6 +176,58 @@ export function applyGroupedChannelStyles(selection, {target}, {ariaLabel: AL, t applyTitleGroup(selection, T); } +function groupAesthetics({ariaLabel: AL, title: T, fill: F, fillOpacity: FO, stroke: S, strokeOpacity: SO, strokeWidth: SW, opacity: O, href: H}) { + return [AL, T, F, FO, S, SO, SW, O, H].filter(c => c !== undefined); +} + +export function* groupIndex(I, position, channels) { + const {z: Z} = channels; // group channel + const A = groupAesthetics(channels); // aesthetic channels + const C = [...position, ...A]; // all channels + + // Group the current index by Z (if any). + for (const G of Z ? group(I, i => Z[i]).values() : [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 + out: for (const i of G) { + + // If any channel has an undefined value for this index, yield the current + // group and start a new empty group, skipping this index. + for (const c of C) { + if (!defined(c[i])) { + if (Gg) yield Gg; + Ag = Gg = undefined; + continue out; + } + } + + // 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]; + continue; + } + + // Otherwise, add the current index to the current group. Then, if any of + // the aesthetics don’t match the current group, yield the current group + // and start a new group of the current index. + Gg.push(i); + 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]; + continue out; + } + } + } + + // Yield the current group, if any. + if (Gg) yield Gg; + } +} + // clip: true clips to the frame // TODO: accept other types of clips (paths, urls, x, y, other marks?…) // https://github.com/observablehq/plot/issues/181 diff --git a/test/output/availability.svg b/test/output/availability.svg index b0b2069689b..8bb6c264c9a 100644 --- a/test/output/availability.svg +++ b/test/output/availability.svg @@ -57,7 +57,9 @@ - + + + diff --git a/test/output/carsParcoords.svg b/test/output/carsParcoords.svg index 4966ee8586e..86191ef8dad 100644 --- a/test/output/carsParcoords.svg +++ b/test/output/carsParcoords.svg @@ -51,7 +51,8 @@ - + + @@ -249,11 +250,13 @@ - + + - + + @@ -262,7 +265,8 @@ - + + @@ -382,9 +386,11 @@ - + + - + +