From 0863b76a75aac2f1ba0030a481c7d4aca14e71e2 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 10 Dec 2020 15:14:30 -0800 Subject: [PATCH] facet --- src/axes.js | 11 ++++-- src/index.js | 1 - src/mark.js | 2 +- src/marks/axis.js | 8 +++-- src/marks/facet.js | 40 +++++++++------------- src/plot.js | 47 +++++++++++++++++++++++--- src/transforms/bin.js | 10 ++++-- test/ballot-status-race.html | 24 ++++++------- test/metro-unemployment-ridgeline.html | 28 +++++++-------- test/penguin-mass-sex.html | 18 +++++----- 10 files changed, 113 insertions(+), 76 deletions(-) diff --git a/src/axes.js b/src/axes.js index 1ffdd5bc19..c84f3b28ea 100644 --- a/src/axes.js +++ b/src/axes.js @@ -1,11 +1,18 @@ import {AxisX, AxisY} from "./marks/axis.js"; -export function Axes({x: xScale, y: yScale}, {x = {}, y = {}, grid} = {}) { +export function Axes( + {x: xScale, y: yScale, fx: fxScale, fy: fyScale}, + {x = {}, y = {}, fx = {}, fy = {}, grid} = {} +) { const {axis: xAxis = true} = x; const {axis: yAxis = true} = y; + const {axis: fxAxis = true} = fx; + const {axis: fyAxis = true} = fy; return { x: xScale && xAxis ? new AxisX({grid, ...x}) : null, - y: yScale && yAxis ? new AxisY({grid, ...y}) : null + y: yScale && yAxis ? new AxisY({grid, ...y}) : null, + fx: fxScale && fxAxis ? new AxisY({name: "fx", ...fx}) : null, + fy: fyScale && fyAxis ? new AxisY({name: "fy", ...fy}) : null }; } diff --git a/src/index.js b/src/index.js index 56c9f87167..ca93c67d85 100644 --- a/src/index.js +++ b/src/index.js @@ -6,7 +6,6 @@ export {BarX, BarY, barX, barY} from "./marks/bar.js"; export {bin, binX, binY} from "./marks/bin.js"; export {Cell, cell} from "./marks/cell.js"; export {Dot, dot, dotX, dotY} from "./marks/dot.js"; -export {FacetY, facetY} from "./marks/facet.js"; export {group, groupX, groupY} from "./marks/group.js"; export {Line, line, lineX, lineY} from "./marks/line.js"; export {Link, link} from "./marks/link.js"; diff --git a/src/mark.js b/src/mark.js index a1c14a82b8..61c496d663 100644 --- a/src/mark.js +++ b/src/mark.js @@ -21,7 +21,7 @@ export class Mark { }); } initialize(data) { - if (data !== undefined) data = this.transform(data); + if (data !== undefined) data = this.transform(data, this.data); return { index: data === undefined ? undefined : Uint32Array.from(data, indexOf), channels: this.channels.map(channel => { diff --git a/src/marks/axis.js b/src/marks/axis.js index dd8d46ef58..9d620228ff 100644 --- a/src/marks/axis.js +++ b/src/marks/axis.js @@ -4,6 +4,7 @@ import {create} from "d3-selection"; export class AxisX { constructor({ + name = "x", axis = true, ticks, tickSize = 6, @@ -13,6 +14,7 @@ export class AxisX { labelAnchor, labelOffset } = {}) { + this.name = name; this.axis = axis = axis === true ? "bottom" : (axis + "").toLowerCase(); if (!["top", "bottom"].includes(axis)) throw new Error(`invalid x-axis: ${axis}`); this.ticks = ticks; @@ -25,7 +27,7 @@ export class AxisX { } render( index, - {x}, + {[this.name]: x}, channels, {width, height, marginTop, marginRight, marginBottom, marginLeft} ) { @@ -71,6 +73,7 @@ export class AxisX { export class AxisY { constructor({ + name = "y", axis = true, ticks, tickSize = 6, @@ -80,6 +83,7 @@ export class AxisY { labelAnchor, labelOffset } = {}) { + this.name = name; this.axis = axis = axis === true ? "left" : (axis + "").toLowerCase(); if (!["left", "right"].includes(axis)) throw new Error(`invalid y-axis: ${axis}`); this.ticks = ticks; @@ -92,7 +96,7 @@ export class AxisY { } render( index, - {y}, + {[this.name]: y}, channels, {width, height, marginTop, marginRight, marginBottom, marginLeft} ) { diff --git a/src/marks/facet.js b/src/marks/facet.js index e3c9a1b4fa..06b204673c 100644 --- a/src/marks/facet.js +++ b/src/marks/facet.js @@ -3,12 +3,13 @@ import {create} from "d3-selection"; import {Mark} from "../mark.js"; import {autoScaleRange} from "../scales.js"; -export class FacetY extends Mark { +// TODO facet-x +export class Facet extends Mark { constructor(data, {y, transform} = {}, marks = []) { super( data, [ - {name: "y", value: y, scale: "y", type: "band"} + {name: "fy", value: y, scale: "fy", type: "band"} ], transform ); @@ -16,13 +17,12 @@ export class FacetY extends Mark { this.facets = undefined; // set by initialize } initialize(data) { - const {index, channels: [y]} = super.initialize(data); - const [, {value: Y}] = y; + const {index, channels: [fy]} = super.initialize(data); + const [, {value: FY}] = fy; const subchannels = []; const facets = this.facets = new Map(); - // - for (const [facetKey, facetIndex] of group(index, i => Y[i])) { + for (const [facetKey, facetIndex] of group(index, i => FY[i])) { const facetData = Array.from(facetIndex, i => data[i]); const markIndex = new Map(); const markChannels = new Map(); @@ -33,7 +33,7 @@ export class FacetY extends Mark { const {index, channels} = mark.initialize(markData); for (const [name, channel] of channels) { if (name !== undefined) named[name] = channel.value; - subchannels.push([undefined, facetYChannel(channel)]); + subchannels.push([undefined, channel]); } markIndex.set(mark, index); markChannels.set(mark, named); @@ -41,12 +41,12 @@ export class FacetY extends Mark { facets.set(facetKey, {markIndex, markChannels}); } - return {index, channels: [y, ...subchannels]}; + return {index, channels: [fy, ...subchannels]}; } - render(index, {y, fy, ...scales}, channels, options) { + render(index, scales, channels, options) { const {marks, facets} = this; - const {marginRight, marginLeft, width} = options; - const subscales = {y: fy, ...scales}; + const {fy} = scales; + const {y, marginRight, marginLeft, width} = options; const subdimensions = { marginTop: 0, @@ -54,22 +54,22 @@ export class FacetY extends Mark { marginBottom: 0, marginLeft, width, - height: y.bandwidth() + height: fy.bandwidth() }; - autoScaleRange({y: options.fy}, subdimensions); + autoScaleRange({y}, subdimensions); return create("svg:g") .call(g => g.selectAll() - .data(y.domain()) + .data(fy.domain()) .join("g") - .attr("transform", (key) => `translate(0,${y(key)})`) + .attr("transform", (key) => `translate(0,${fy(key)})`) .each(function(key) { const {markIndex, markChannels} = facets.get(key); for (const mark of marks) { const node = mark.render( markIndex.get(mark), - subscales, + scales, markChannels.get(mark), subdimensions ); @@ -79,11 +79,3 @@ export class FacetY extends Mark { .node(); } } - -export function facetY(data, options, marks) { - return new FacetY(data, options, marks); -} - -function facetYChannel({scale, ...channel}) { - return {...channel, scale: scale === "y" ? "fy" : scale}; -} diff --git a/src/plot.js b/src/plot.js index d3454ba97c..5f26f31a5d 100644 --- a/src/plot.js +++ b/src/plot.js @@ -1,8 +1,18 @@ import {create} from "d3-selection"; import {Axes, autoAxisTicks, autoAxisLabels} from "./axes.js"; +import {Facet} from "./marks/facet.js"; import {Scales, autoScaleRange} from "./scales.js"; export function plot(options = {}) { + const {facet} = options; + + // When faceting, wrap all marks in a faceting mark. + if (facet !== undefined) { + const {marks} = options; + const {data} = facet; + options = {...options, marks: [new Facet(data, facet, marks)]}; + } + const { marks = [], font = "10px sans-serif", @@ -40,14 +50,41 @@ export function plot(options = {}) { const axes = Axes(scaleDescriptors, options); const dimensions = Dimensions(scaleDescriptors, axes, options); - autoScaleRange(scaleDescriptors, dimensions); - autoAxisTicks(axes, dimensions); - autoAxisLabels(scaleChannels, scaleDescriptors, axes, dimensions); + // When faceting, layout fx and fy instead of x and y. + // TODO cleaner + if (facet !== undefined) { + const x = scales.fx ? "fx" : "x"; + const y = scales.fy ? "fy" : "y"; + const facetScaleChannels = new Map([["x", scaleChannels.get(x)], ["y", scaleChannels.get(y)]]); + const facetScaleDescriptors = {x: scaleDescriptors[x], y: scaleDescriptors[y]}; + const facetAxes = {x: axes[x], y: axes[y]}; + autoScaleRange(facetScaleDescriptors, dimensions); + autoAxisTicks(facetAxes, dimensions); + autoAxisLabels(facetScaleChannels, facetScaleDescriptors, facetAxes, dimensions); + } else { + autoScaleRange(scaleDescriptors, dimensions); + autoAxisTicks(axes, dimensions); + autoAxisLabels(scaleChannels, scaleDescriptors, axes, dimensions); + } // Normalize the options. options = {...scaleDescriptors, ...dimensions}; - if (axes.y) options.y = {...options.y, ...axes.y}, marks.unshift(axes.y); - if (axes.x) options.x = {...options.x, ...axes.x}, marks.unshift(axes.x); + if (axes.x) options.x = {...options.x, ...axes.x}; + if (axes.y) options.y = {...options.y, ...axes.y}; + if (axes.fx) options.fx = {...options.fx, ...axes.fx}; + if (axes.fy) options.fy = {...options.fy, ...axes.fy}; + + // When faceting, render axes for fx and fy instead of x and y. + // TODO cleaner + if (facet !== undefined) { + const x = scales.fx ? "fx" : "x"; + const y = scales.fy ? "fy" : "y"; + if (axes[x]) marks.unshift(axes[x]); + if (axes[y]) marks.unshift(axes[y]); + } else { + if (axes.x) marks.unshift(axes.x); + if (axes.y) marks.unshift(axes.y); + } const {width, height} = dimensions; diff --git a/src/transforms/bin.js b/src/transforms/bin.js index 0c562d5327..61e241477a 100644 --- a/src/transforms/bin.js +++ b/src/transforms/bin.js @@ -7,13 +7,17 @@ export function bin1(options = {}) { const bin = binner().value(value); if (domain !== undefined) bin.domain(domain); if (thresholds !== undefined) bin.thresholds(thresholds); - return data => { - const b = bin(data); + return (facetData, data) => { + let b; // We don’t want to choose thresholds dynamically for each facet; instead, - // we extract the set of thresholds from an initial computation. + // we extract the set of thresholds from an initial pass over all data. if (domain === undefined || thresholds === undefined) { + b = bin(data); if (domain === undefined) bin.domain(domain = [b[0].x0, b[b.length - 1].x1]); if (thresholds === undefined) bin.thresholds(thresholds = b.slice(1).map(b => b.x0)); + if (facetData !== data) b = bin(facetData); + } else { + b = bin(facetData); } if (cumulative) { let sum = 0; diff --git a/test/ballot-status-race.html b/test/ballot-status-race.html index 46e69dae77..e505e83ff9 100644 --- a/test/ballot-status-race.html +++ b/test/ballot-status-race.html @@ -56,36 +56,34 @@ }); document.body.appendChild(Plot.plot({ + marginLeft: 210, x: { grid: true, label: "Frequency (%) →" }, y: { + domain: ["ACCEPTED", "REJECTED", "PENDING"] + }, + fy: { domain: rollup .filter(d => d.status === "ACCEPTED") .sort((a, b) => d3.descending(a.percent, b.percent)) .map(d => d.race), label: null }, - fy: { - domain: ["ACCEPTED", "REJECTED", "PENDING"] - }, color: { type: "ordinal", domain: ["ACCEPTED", "REJECTED", "PENDING"], range: ["currentColor", "brown", "gray"] }, + facet: { + data: rollup, + y: "race" + }, marks: [ - Plot.facetY( - rollup, - {y: "race"}, - [ - Plot.barX(rollup, {x: "percent", y: "status", fill: "status"}), - Plot.ruleX([0]) - ] - ) - ], - marginLeft: 210 + Plot.barX(rollup, {x: "percent", y: "status", fill: "status"}), + Plot.ruleX([0]) + ] })); }); diff --git a/test/metro-unemployment-ridgeline.html b/test/metro-unemployment-ridgeline.html index ac468fa05f..544d13dccf 100644 --- a/test/metro-unemployment-ridgeline.html +++ b/test/metro-unemployment-ridgeline.html @@ -7,32 +7,30 @@ d3.csv("data/bls-metro-unemployment.csv", d3.autoType).then(data => { document.body.appendChild(Plot.plot({ + width: 960, + height: 1080, + marginLeft: 300, x: { label: null }, y: { + range: [20, -40] + }, + fy: { domain: d3.rollups(data, group => d3.max(group, d => d.unemployment), d => d.division) .sort(([, a], [, b]) => d3.descending(a, b)) .map(([key]) => key), label: null }, - fy: { - range: [20, -40] + facet: { + data, + y: "division" }, marks: [ - Plot.facetY( - data, - {y: "division"}, - [ - Plot.ruleY([0]), - Plot.areaY(data, {x: "date", y: "unemployment", fill: "#eee"}), - Plot.line(data, {x: "date", y: "unemployment"}) - ] - ) - ], - width: 960, - height: 1080, - marginLeft: 300 + Plot.areaY(data, {x: "date", y: "unemployment", fill: "#eee"}), + Plot.line(data, {x: "date", y: "unemployment"}), + Plot.ruleY([0]) + ] })); }); diff --git a/test/penguin-mass-sex.html b/test/penguin-mass-sex.html index 5e919d8011..6077ba85af 100644 --- a/test/penguin-mass-sex.html +++ b/test/penguin-mass-sex.html @@ -7,21 +7,19 @@ d3.csv("data/penguins.csv", d3.autoType).then(data => { document.body.appendChild(Plot.plot({ + marginLeft: 70, x: { round: true, label: "Body mass (g) →" }, + facet: { + data, + y: "sex" + }, marks: [ - Plot.facetY( - data, - {y: "sex"}, - [ - Plot.ruleY([0]), - Plot.binX(data, {x: "body_mass_g"}) - ] - ) - ], - marginLeft: 70 + Plot.binX(data, {x: "body_mass_g"}), + Plot.ruleY([0]) + ] })); });