Skip to content

Commit

Permalink
group aesthetics
Browse files Browse the repository at this point in the history
  • Loading branch information
mbostock committed Feb 16, 2022
1 parent 08118dd commit 0650bef
Show file tree
Hide file tree
Showing 9 changed files with 123 additions and 45 deletions.
10 changes: 8 additions & 2 deletions src/axis.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -22,6 +23,7 @@ export class AxisX {
ariaLabel,
ariaDescription
} = {}) {
super();
this.name = name;
this.axis = keyword(axis, "axis", ["top", "bottom"]);
this.ticks = ticks;
Expand Down Expand Up @@ -97,7 +99,7 @@ export class AxisX {
}
}

export class AxisY {
export class AxisY extends Decoration {
constructor({
name = "y",
axis,
Expand All @@ -115,6 +117,7 @@ export class AxisY {
ariaLabel,
ariaDescription
} = {}) {
super();
this.name = name;
this.axis = keyword(axis, "axis", ["left", "right"]);
this.ticks = ticks;
Expand All @@ -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},
Expand Down
13 changes: 13 additions & 0 deletions src/decoration.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
21 changes: 11 additions & 10 deletions src/marks/area.js
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -19,31 +18,33 @@ 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,
defaults
);
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])
Expand Down
17 changes: 9 additions & 8 deletions src/marks/line.js
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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,
Expand All @@ -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();
Expand Down
5 changes: 5 additions & 0 deletions src/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
22 changes: 7 additions & 15 deletions src/plot.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}))
Expand Down
58 changes: 55 additions & 3 deletions src/style.js
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion test/output/availability.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 12 additions & 6 deletions test/output/carsParcoords.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 0650bef

Please sign in to comment.