Skip to content

Commit

Permalink
facet
Browse files Browse the repository at this point in the history
  • Loading branch information
mbostock committed Dec 11, 2020
1 parent 10d2266 commit 0863b76
Show file tree
Hide file tree
Showing 10 changed files with 113 additions and 76 deletions.
11 changes: 9 additions & 2 deletions src/axes.js
Original file line number Diff line number Diff line change
@@ -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
};
}

Expand Down
1 change: 0 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion src/mark.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
8 changes: 6 additions & 2 deletions src/marks/axis.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {create} from "d3-selection";

export class AxisX {
constructor({
name = "x",
axis = true,
ticks,
tickSize = 6,
Expand All @@ -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;
Expand All @@ -25,7 +27,7 @@ export class AxisX {
}
render(
index,
{x},
{[this.name]: x},
channels,
{width, height, marginTop, marginRight, marginBottom, marginLeft}
) {
Expand Down Expand Up @@ -71,6 +73,7 @@ export class AxisX {

export class AxisY {
constructor({
name = "y",
axis = true,
ticks,
tickSize = 6,
Expand All @@ -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;
Expand All @@ -92,7 +96,7 @@ export class AxisY {
}
render(
index,
{y},
{[this.name]: y},
channels,
{width, height, marginTop, marginRight, marginBottom, marginLeft}
) {
Expand Down
40 changes: 16 additions & 24 deletions src/marks/facet.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,26 @@ 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
);
this.marks = marks;
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();
Expand All @@ -33,43 +33,43 @@ 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);
}
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,
marginRight,
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
);
Expand All @@ -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};
}
47 changes: 42 additions & 5 deletions src/plot.js
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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;

Expand Down
10 changes: 7 additions & 3 deletions src/transforms/bin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
24 changes: 11 additions & 13 deletions test/ballot-status-race.html
Original file line number Diff line number Diff line change
Expand Up @@ -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])
]
}));
});

Expand Down
28 changes: 13 additions & 15 deletions test/metro-unemployment-ridgeline.html
Original file line number Diff line number Diff line change
Expand Up @@ -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])
]
}));
});

Expand Down
18 changes: 8 additions & 10 deletions test/penguin-mass-sex.html
Original file line number Diff line number Diff line change
Expand Up @@ -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])
]
}));
});

Expand Down

0 comments on commit 0863b76

Please sign in to comment.