diff --git a/README.md b/README.md index 25c26d6ccb..907b8e8221 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ It’s not just line charts, of course. Here’s another useful chart type, the Plot.plot({ height: 240, marks: [ - Plot.binX(data, {x: "Volume"}), + Plot.binRectY(data, {x: "Volume"}), Plot.ruleY([0]) // add a rule at y = 0 ] }) diff --git a/src/index.js b/src/index.js index 8ee01ec2ae..8a029bd593 100644 --- a/src/index.js +++ b/src/index.js @@ -2,11 +2,11 @@ export {plot} from "./plot.js"; export {Mark} from "./mark.js"; export {Area, area, areaX, areaY} from "./marks/area.js"; export {BarX, BarY, barX, barY} from "./marks/bar.js"; -export {bin, binX, binY} from "./marks/bin.js"; +export {binDot, binRect, binRectX, binRectY} from "./marks/bin.js"; export {Cell, cell, cellX, cellY} from "./marks/cell.js"; export {Dot, dot, dotX, dotY} from "./marks/dot.js"; export {Frame, frame} from "./marks/frame.js"; -export {group, groupX, groupY} from "./marks/group.js"; +export {groupCell, groupDot, groupBarX, groupBarY} from "./marks/group.js"; export {Line, line, lineX, lineY} from "./marks/line.js"; export {Link, link} from "./marks/link.js"; export {Rect, rect, rectX, rectY} from "./marks/rect.js"; @@ -14,7 +14,7 @@ export {RuleX, RuleY, ruleX, ruleY} from "./marks/rule.js"; export {stackAreaX, stackAreaY, stackBarX, stackBarY} from "./marks/stack.js"; export {Text, text, textX, textY} from "./marks/text.js"; export {TickX, TickY, tickX, tickY} from "./marks/tick.js"; -export {bin1, bin2} from "./transforms/bin.js"; -export {group1, group2} from "./transforms/group.js"; +export {bin, binX, binY, binR} from "./transforms/bin.js"; +export {group, groupX, groupY} from "./transforms/group.js"; export {movingAverage} from "./transforms/movingAverage.js"; export {stackX, stackY} from "./transforms/stack.js"; diff --git a/src/mark.js b/src/mark.js index a99110bccb..b01f184029 100644 --- a/src/mark.js +++ b/src/mark.js @@ -130,13 +130,6 @@ export function maybeNumber(value, defaultValue) { : [value, undefined]; } -// The value may be defined as a string or function, rather than an object with -// a value property. TODO Allow value to be specified as array, too? This would -// require promoting the array to an accessor for compatibility with d3.bin. -export function maybeValue(x) { - return typeof x === "string" || typeof x === "function" ? {value: x} : x; -} - // If the channel value is specified as a string, indicating a named field, this // wraps the specified function f with another function with the corresponding // label property, such that the associated axis inherits the label by default. diff --git a/src/marks/bin.js b/src/marks/bin.js index 351eb5b245..7caefb3b3a 100644 --- a/src/marks/bin.js +++ b/src/marks/bin.js @@ -1,102 +1,19 @@ -import {arrayify, identity, maybeLabel} from "../mark.js"; -import {bin1, bin2} from "../transforms/bin.js"; +import {bin, binX, binY, binR} from "../transforms/bin.js"; import {rect, rectX, rectY} from "./rect.js"; +import {dot} from "./dot.js"; -export function bin(data, {x, y, domain, thresholds, normalize, transform, ...options} = {}) { - data = arrayify(data); - if (transform !== undefined) data = arrayify(transform(data)); - return rect( - data, - { - insetTop: 1, - insetLeft: 1, - ...options, - transform: bin2({x, y, domain, thresholds}), - fill: normalize ? normalizer(normalize, data.length) : length, - x1: maybeLabel(x0, x), - x2: x1, - y1: maybeLabel(y0, y), - y2: y1 - } - ); +export function binRect(data, options) { + return rect(data, bin({insetTop: 1, insetLeft: 1, ...options, out: "fill"})); } -export function binX(data, { - x = identity, - domain, - thresholds, - normalize, - cumulative, - transform, - ...options -} = {}) { - data = arrayify(data); - if (transform !== undefined) data = arrayify(transform(data)); - return rectY( - data, - { - insetLeft: 1, - ...options, - transform: bin1({value: x, domain, thresholds, cumulative}), - y: normalize ? normalizer(normalize, data.length) : length, - x1: maybeLabel(x0, x), - x2: x1 - } - ); +export function binDot(data, options) { + return dot(data, binR(options)); } -export function binY(data, { - y = identity, - domain, - thresholds, - normalize, - cumulative, - transform, - ...options -} = {}) { - data = arrayify(data); - if (transform !== undefined) data = arrayify(transform(data)); - return rectX( - data, - { - insetTop: 1, - ...options, - transform: bin1({value: y, domain, thresholds, cumulative}), - x: normalize ? normalizer(normalize, data.length) : length, - y1: maybeLabel(x0, y), - y2: x1 - } - ); +export function binRectY(data, options) { + return rectY(data, binX({insetLeft: 1, ...options})); } -function x0(d) { - return d.x0; -} - -function x1(d) { - return d.x1; -} - -function y0(d) { - return d.y0; -} - -function y1(d) { - return d.y1; -} - -function length(d) { - return d.length; -} - -length.label = "Frequency"; - -// An alternative channel definition to length (above) that computes the -// proportion of each bin in [0, k]. If k is true, it is treated as 100 for -// percentages; otherwise, it is typically 1. -function normalizer(k, n) { - k = k === true ? 100 : +k; - const value = bin => bin.length * k / n; - value.label = `Frequency${k === 100 ? " (%)" : ""}`; - return value; +export function binRectX(data, options) { + return rectX(data, binY({insetTop: 1, ...options})); } diff --git a/src/marks/group.js b/src/marks/group.js index 13b16a0c72..7102b0d129 100644 --- a/src/marks/group.js +++ b/src/marks/group.js @@ -1,93 +1,20 @@ -import {arrayify, identity, first, second, maybeLabel, maybeZero} from "../mark.js"; -import {group1, group2} from "../transforms/group.js"; +import {groupX, groupY, group} from "../transforms/group.js"; import {barX, barY} from "./bar.js"; import {cell} from "./cell.js"; +import {dot} from "./dot.js"; -export function group(data, { - x = first, - y = second, - transform, - ...options -} = {}) { - if (transform !== undefined) data = transform(arrayify(data)); - return cell( - data, - { - ...options, - transform: group2(x, y), - x: maybeLabel(first, x), - y: maybeLabel(second, y), - fill: length3 - } - ); +export function groupCell(data, options) { + return cell(data, group({...options, out: "fill"})); } -export function groupX(data, { - x = identity, - y, - y1, - y2, - transform, - ...options -} = {}) { - data = arrayify(data); - if (transform !== undefined) data = arrayify(transform(data)); - ([y1, y2] = maybeZero(y, y1, y2, maybeLength(data, options))); - return barY( - data, - { - ...options, - transform: group1(x), - x: maybeLabel(first, x), - y1, - y2 - } - ); +export function groupDot(data, options) { + return dot(data, group({...options, out: "r"})); } -export function groupY(data, { - y = identity, - x, - x1, - x2, - transform, - ...options -} = {}) { - data = arrayify(data); - if (transform !== undefined) data = arrayify(transform(data)); - ([x1, x2] = maybeZero(x, x1, x2, maybeLength(data, options))); - return barX( - data, - { - ...options, - transform: group1(y), - x1, - x2, - y: maybeLabel(first, y) - } - ); +export function groupBarX(data, options) { + return barX(data, groupY(options)); } -function length2([, group]) { - return group.length; -} - -function length3([,, group]) { - return group.length; -} - -length2.label = length3.label = "Frequency"; - -function maybeLength(data, {normalize}) { - return normalize ? normalizer(normalize, data.length) : length2; -} - -// An alternative channel definition to length2 (above) that computes the -// proportion of each bin in [0, k]. If k is true, it is treated as 100 for -// percentages; otherwise, it is typically 1. -function normalizer(k, n) { - k = k === true ? 100 : +k; - const value = ([, group]) => group.length * k / n; - value.label = `Frequency${k === 100 ? " (%)" : ""}`; - return value; +export function groupBarY(data, options) { + return barY(data, groupX(options)); } diff --git a/src/marks/stack.js b/src/marks/stack.js index e079fd6185..1a3a26496d 100644 --- a/src/marks/stack.js +++ b/src/marks/stack.js @@ -3,17 +3,17 @@ import {areaX, areaY} from "./area.js"; import {barX, barY} from "./bar.js"; export function stackAreaX(data, options) { - return areaX(...stackX(data, options)); + return areaX(data, stackX(options)); } export function stackAreaY(data, options) { - return areaY(...stackY(data, options)); + return areaY(data, stackY(options)); } export function stackBarX(data, options) { - return barX(...stackX(data, options)); + return barX(data, stackX(options)); } export function stackBarY(data, options) { - return barY(...stackY(data, options)); + return barY(data, stackY(options)); } diff --git a/src/transforms/bin.js b/src/transforms/bin.js index b9bd1a5b9e..9020f66976 100644 --- a/src/transforms/bin.js +++ b/src/transforms/bin.js @@ -1,13 +1,56 @@ import {bin as binner, cross} from "d3-array"; -import {valueof, first, second, maybeValue, range, offsetRange} from "../mark.js"; +import {valueof, first, second, range, offsetRange, identity, maybeLabel} from "../mark.js"; -export function bin1(options = {}) { - let {value, domain, thresholds, cumulative} = maybeValue(options); +export function binX({x, domain, thresholds, normalize, cumulative, ...options} = {}) { + const [transform, y, x1, x2] = bin1(x, {domain, thresholds, normalize, cumulative}); + return {...options, transform, y, x1, x2}; +} + +export function binY({y = identity, domain, thresholds, normalize, cumulative, ...options} = {}) { + const [transform, x, y1, y2] = bin1(y, {domain, thresholds, normalize, cumulative}); + return {...options, transform, x, y1, y2}; +} + +function bin1(value = identity, {domain, thresholds, normalize, cumulative} = {}) { + const length = binLength(normalize); const bin = binof({value, domain, thresholds}); - return (data, facets) => rebin(bin(data), facets, subset1, cumulative); + return [ + maybeNormalize((data, facets) => rebin(bin(data), facets, subset1, cumulative), length), + length, + maybeLabel(x0, value), + x1 + ]; +} + +export function binR({x, y, domain, thresholds, normalize, ...options} = {}) { + const r = binLength(normalize); + return { + ...options, + transform: maybeNormalize(bin2({x, y, domain, thresholds}), r), + r, + x: maybeLabel(xMid, x), + y: maybeLabel(yMid, y) + }; +} + +export function bin({x, y, out, domain, thresholds, normalize, ...options} = {}) { + const l = binLength(normalize); + return { + ...options, + transform: maybeNormalize(bin2({x, y, domain, thresholds}), l), + [out]: l, + x1: maybeLabel(x0, x), + x2: x1, + y1: maybeLabel(y0, y), + y2: y1 + }; } -export function bin2({x = {}, y = {}, domain, thresholds} = {}) { +// Here x and y may each either be a standalone value (e.g., a string +// representing a field name, a function, an array), or the value and some +// additional per-dimension binning options as an objects of the form {value, +// domain?, thresholds?}. +function bin2({x, y, domain, thresholds} = {}) { const binX = binof({domain, thresholds, value: first, ...maybeValue(x)}); const binY = binof({domain, thresholds, value: second, ...maybeValue(y)}); return (data, facets) => rebin( @@ -96,3 +139,61 @@ function accumulate(bins) { function nonempty({length}) { return length > 0; } + +function x0(d) { + return d.x0; +} + +function x1(d) { + return d.x1; +} + +function y0(d) { + return d.y0; +} + +function y1(d) { + return d.y1; +} + +function xMid(d) { + return (d.x0 + d.x1) / 2; +} + +function yMid(d) { + return (d.y0 + d.y1) / 2; +} + +function length1(d) { + return d.length; +} + +length1.label = "Frequency"; + +// Returns a channel definition that’s either the number of elements in the +// given bin (length2 above), or the same as a proportion of the total number of +// elements in the data scaled by k. If k is true, it is treated as 100 for +// percentages; otherwise, it is typically 1. +function binLength(k) { + if (!k) return length1; + k = k === true ? 100 : +k; + let length; // set lazily by the transform + const value = bin => bin.length * k / length; + value.normalize = data => void (length = data.length); + value.label = `Frequency${k === 100 ? " (%)" : ""}`; + return value; +} + +// If the bin length requires normalization (per binLength above), this wraps +// the specified transform to allow it. +function maybeNormalize(transform, length) { + return length.normalize ? (data, facets) => { + length.normalize(data); + return transform(data, facets); + } : transform; +} + +// This distinguishes between per-dimension options and a standalone value. +function maybeValue(value) { + return typeof value === "object" && value && "value" in value ? value : {value}; +} diff --git a/src/transforms/group.js b/src/transforms/group.js index bc7c7251fd..f5a599b04e 100644 --- a/src/transforms/group.js +++ b/src/transforms/group.js @@ -1,25 +1,44 @@ import {groups} from "d3-array"; import {defined} from "../defined.js"; -import {valueof, maybeValue, range, offsetRange} from "../mark.js"; - -export function group1(x) { - const {value} = maybeValue({value: x}); - return (data, facets) => { - const values = valueof(data, value); - let g = groups(range(data), i => values[i]).filter(defined1); - return regroup(g, facets); - }; +import {valueof, range, offsetRange, maybeLabel, first, second, identity} from "../mark.js"; + +export function groupX({x, normalize, ...options} = {}) { + const [transform, X, y] = group1(x, normalize); + return {...options, transform, x: X, y}; +} + +export function groupY({y, normalize, ...options} = {}) { + const [transform, Y, x] = group1(y, normalize); + return {...options, transform, y: Y, x}; } -export function group2(vx, vy) { - const {value: x} = maybeValue({value: vx}); - const {value: y} = maybeValue({value: vy}); - return (data, facets) => { - const valuesX = valueof(data, x); - const valuesY = valueof(data, y); - let g = groups(range(data), i => valuesX[i], i => valuesY[i]).filter(defined1); - g = g.flatMap(([x, xgroup]) => xgroup.filter(defined1).map(([y, ygroup]) => [x, y, ygroup])); - return regroup(g, facets); +function group1(x = identity, k) { + const y = (k = k === true ? 100 : +k) ? normalizedLength2(k) : length2; + return [ + (data, facets) => { + if (k) y.normalize(data); + const X = valueof(data, x); + let g = groups(range(data), i => X[i]).filter(defined1); + return regroup(g, facets); + }, + maybeLabel(first, x), + y + ]; +} + +export function group({x = first, y = second, out, ...options} = {}) { + return { + ...options, + transform(data, facets) { + const X = valueof(data, x); + const Y = valueof(data, y); + let g = groups(range(data), i => X[i], i => Y[i]).filter(defined1); + g = g.flatMap(([x, xgroup]) => xgroup.filter(defined1).map(([y, ygroup]) => [x, y, ygroup])); + return regroup(g, facets); + }, + x: maybeLabel(first, x), + y: maybeLabel(second, y), + [out]: length3 }; } @@ -51,6 +70,28 @@ function defined1([key]) { } // When faceting, some groups may be empty; these are filtered out. -export function nonempty1([, {length}]) { +function nonempty1([, {length}]) { return length > 0; } + +function length2([, group]) { + return group.length; +} + +function length3([,, group]) { + return group.length; +} + +length2.label = length3.label = "Frequency"; + +// Returns a channel definition that’s the number of elements in the given group +// (length2 above) as a proportion of the total number of elements in the data +// scaled by k. If k is true, it is treated as 100 for percentages; otherwise, +// it is typically 1. +function normalizedLength2(k) { + let length; // set lazily by the transform + const value = ([, group]) => group.length * k / length; + value.normalize = data => void (length = data.length); + value.label = `Frequency${k === 100 ? " (%)" : ""}`; + return value; +} diff --git a/src/transforms/stack.js b/src/transforms/stack.js index 24e5b901cc..c0d0e1e57b 100644 --- a/src/transforms/stack.js +++ b/src/transforms/stack.js @@ -1,43 +1,55 @@ import {InternMap} from "d3-array"; -import {valueof} from "../mark"; +import {valueof} from "../mark.js"; -// TODO configurable series order -export function stackX(data, {x, y, ...options}) { - const X = valueof(data, x); - const Y = valueof(data, y); - const n = X.length; - const X0 = new InternMap(); - const X1 = new Float64Array(n); - const X2 = new Float64Array(n); - for (let i = 0; i < n; ++i) { - const k = Y[i]; - const x1 = X1[i] = X0.has(k) ? X0.get(k) : 0; - const x2 = X2[i] = x1 + +X[i]; - X0.set(k, isNaN(x2) ? x1 : x2); - } - return [data, {...options, x1: maybeLabel(X1, x), x2: X2, y: maybeLabel(Y, y)}]; +export function stackX({x, y, ...options}) { + const [transform, Y, x1, x2] = stack(y, x); + return {...options, transform, y: Y, x1, x2}; +} + +export function stackY({x, y, ...options}) { + const [transform, X, y1, y2] = stack(x, y); + return {...options, transform, x: X, y1, y2}; } // TODO configurable series order -export function stackY(data, {x, y, ...options}) { - const X = valueof(data, x); - const Y = valueof(data, y); - const n = X.length; - const Y0 = new InternMap(); - const Y1 = new Float64Array(n); - const Y2 = new Float64Array(n); - for (let i = 0; i < n; ++i) { - const k = X[i]; - const y1 = Y1[i] = Y0.has(k) ? Y0.get(k) : 0; - const y2 = Y2[i] = y1 + +Y[i]; - Y0.set(k, isNaN(y2) ? y1 : y2); - } - return [data, {...options, x: maybeLabel(X, x), y1: maybeLabel(Y1, y), y2: Y2}]; +function stack(x, y) { + const [X, setX] = lazyChannel(x); + const [Y1, setY1] = lazyChannel(y); + const [Y2, setY2] = lazyChannel(y); + return [ + data => { + const X = setX(valueof(data, x)); + const Y = valueof(data, y); + const n = X.length; + const Y0 = new InternMap(); + const Y1 = setY1(new Float64Array(n)); + const Y2 = setY2(new Float64Array(n)); + for (let i = 0; i < n; ++i) { + const k = X[i]; + const y1 = Y1[i] = Y0.has(k) ? Y0.get(k) : 0; + const y2 = Y2[i] = y1 + +Y[i]; + Y0.set(k, isNaN(y2) ? y1 : y2); + } + return data; + }, + X, + Y1, + Y2 + ]; } -// If x is a labeled value, propagate the label to the returned value array. -function maybeLabel(X, x) { - const label = typeof x === "string" ? x : x ? x.label : undefined; - if (label !== undefined) X.label = label; - return X; +// Defines a channel whose values are lazily populated by calling the returned +// setter. If the given source is labeled, the label is propagated to the +// returned channel definition. +function lazyChannel(source) { + let value; + return [ + { + transform() { return value; }, + label: typeof source === "string" ? source + : source ? source.label + : undefined + }, + v => value = v + ]; } diff --git a/test/plots/aapl-volume.js b/test/plots/aapl-volume.js index 4d5791a27f..ad83c4eb7d 100644 --- a/test/plots/aapl-volume.js +++ b/test/plots/aapl-volume.js @@ -12,7 +12,7 @@ export default async function() { grid: true }, marks: [ - Plot.binX(data, {x: d => Math.log10(d.Volume), normalize: true}), + Plot.binRectY(data, {x: d => Math.log10(d.Volume), normalize: true}), Plot.ruleY([0]) ] }); diff --git a/test/plots/diamonds-carat-price-dots.js b/test/plots/diamonds-carat-price-dots.js index 6fe25d745c..9024b8e93f 100644 --- a/test/plots/diamonds-carat-price-dots.js +++ b/test/plots/diamonds-carat-price-dots.js @@ -16,12 +16,7 @@ export default async function() { domain: [0, 100] }, marks: [ - Plot.dot(data, { - transform: Plot.bin2({x: "carat", y: "price", thresholds: 100}), - x: d => (d.x0 + d.x1) / 2, - y: d => (d.y0 + d.y1) / 2, - r: "length" - }) + Plot.binDot(data, {x: "carat", y: "price", thresholds: 100}) ] }); } diff --git a/test/plots/diamonds-carat-price.js b/test/plots/diamonds-carat-price.js index b49bd91360..f99eb9a207 100644 --- a/test/plots/diamonds-carat-price.js +++ b/test/plots/diamonds-carat-price.js @@ -10,7 +10,7 @@ export default async function() { type: "symlog" }, marks: [ - Plot.bin(data, {x: "carat", y: "price", thresholds: 100}) + Plot.binRect(data, {x: "carat", y: "price", thresholds: 100}) ] }); } diff --git a/test/plots/moby-dick-faceted.js b/test/plots/moby-dick-faceted.js index 8b7b087f8a..de78803fd1 100644 --- a/test/plots/moby-dick-faceted.js +++ b/test/plots/moby-dick-faceted.js @@ -17,7 +17,7 @@ export default async function() { y: cases }, marks: [ - Plot.groupX(letters, {x: uppers}), + Plot.groupBarY(letters, {x: uppers}), Plot.ruleY([0]) ] }); diff --git a/test/plots/moby-dick-letter-frequency.js b/test/plots/moby-dick-letter-frequency.js index 46c605cf70..0526631624 100644 --- a/test/plots/moby-dick-letter-frequency.js +++ b/test/plots/moby-dick-letter-frequency.js @@ -8,7 +8,7 @@ export default async function() { grid: true }, marks: [ - Plot.groupX([...mobydick] + Plot.groupBarY([...mobydick] .filter(c => /[a-z]/i.test(c)) .map(c => c.toUpperCase())), Plot.ruleY([0]) diff --git a/test/plots/moby-dick-letter-position.js b/test/plots/moby-dick-letter-position.js index c549f08ce0..9165316565 100644 --- a/test/plots/moby-dick-letter-position.js +++ b/test/plots/moby-dick-letter-position.js @@ -30,7 +30,7 @@ export default async function() { scheme: "blues" }, marks: [ - Plot.group(positions, {insetTop: 1, insetLeft: 1}) + Plot.groupCell(positions, {insetTop: 1, insetLeft: 1}) ] }); } diff --git a/test/plots/penguin-mass-sex-species.js b/test/plots/penguin-mass-sex-species.js index 098de90c95..fcfa63f7b8 100644 --- a/test/plots/penguin-mass-sex-species.js +++ b/test/plots/penguin-mass-sex-species.js @@ -15,7 +15,7 @@ export default async function() { marginRight: 70 }, marks: [ - Plot.binX(data, {x: "body_mass_g"}), + Plot.binRectY(data, {x: "body_mass_g"}), Plot.ruleY([0]) ] }); diff --git a/test/plots/penguin-mass-sex.js b/test/plots/penguin-mass-sex.js index 163f27d31c..294d10efc8 100644 --- a/test/plots/penguin-mass-sex.js +++ b/test/plots/penguin-mass-sex.js @@ -14,7 +14,7 @@ export default async function() { marginRight: 70 }, marks: [ - Plot.binX(data, {x: "body_mass_g"}), + Plot.binRectY(data, {x: "body_mass_g"}), Plot.ruleY([0]) ] }); diff --git a/test/plots/penguin-mass.js b/test/plots/penguin-mass.js index 550582c98e..ccca9601a1 100644 --- a/test/plots/penguin-mass.js +++ b/test/plots/penguin-mass.js @@ -12,7 +12,7 @@ export default async function() { grid: true }, marks: [ - Plot.binX(data, {x: "body_mass_g"}), + Plot.binRectY(data, {x: "body_mass_g"}), Plot.ruleY([0]) ] }); diff --git a/test/plots/uniform-random-difference.js b/test/plots/uniform-random-difference.js index c832ccbdc9..acf49cb372 100644 --- a/test/plots/uniform-random-difference.js +++ b/test/plots/uniform-random-difference.js @@ -13,7 +13,7 @@ export default async function() { grid: true }, marks: [ - Plot.binX({length: 10000}, {x: () => random() - random(), normalize: true}), + Plot.binRectY({length: 10000}, {x: () => random() - random(), normalize: true}), Plot.ruleY([0]) ] }); diff --git a/test/plots/word-length-moby-dick.js b/test/plots/word-length-moby-dick.js index 036579eca7..8826eb45dc 100644 --- a/test/plots/word-length-moby-dick.js +++ b/test/plots/word-length-moby-dick.js @@ -20,7 +20,7 @@ export default async function() { grid: true }, marks: [ - Plot.groupX(words, {x: d => d.length, normalize: true}) + Plot.groupBarY(words, {x: d => d.length, normalize: true}) ] }); }