diff --git a/README.md b/README.md index c593cef76b..7851a0d5a5 100644 --- a/README.md +++ b/README.md @@ -1346,7 +1346,7 @@ Plot.barY(alphabet.filter(d => /[aeiou]/i.test(d.letter)), {x: "letter", y: "fre Together the **sort** and **reverse** transforms allow control over *z*-order, which can be important when addressing overplotting. If the sort option is a function but does not take exactly one argument, it is assumed to be a [comparator function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#description); otherwise, the sort option is interpreted as a channel value definition and thus may be either as a column name, accessor function, or array of values. -For greater control, you can also implement a custom transform function: +For greater control, you can also implement a [custom transform function](#custom-transforms): * **transform** - a function that returns transformed *data* and *index* @@ -1919,6 +1919,24 @@ Plot.stackX2({y: "year", x: "revenue", z: "format", fill: "group"}) Equivalent to [Plot.stackX](#plotstackxstack-options), except that the **x2** channel is returned as the **x** channel. This can be used, for example, to draw a line at the right edge of each stacked area. +### Custom transforms + +The **transform** option defines a custom transform function, allowing data, indexes, or channels to be derived prior to rendering. Custom transforms are rarely implemented directly; see the built-in transforms above. The transform function (if present) is passed two arguments, *data* and *facets*, representing the mark’s data and facet indexes; it must then return a {data, facets} object representing the resulting transformed data and facet indexes. The *facets* are represented as a nested array of arrays such as [[0, 1, 3, …], [2, 5, 10, …], …]; each element in *facets* specifies the zero-based indexes of elements in *data* that are in a given facet (*i.e.*, have a distinct value in the associated *fx* or *fy* dimension). + +While transform functions often produce new *data* or *facets*, they may return the passed-in *data* and *facets* as-is, and often have a side-effect of constructing derived channels. For example, the count of elements in a [groupX transform](#group) might be returned as a new *y* channel. In this case, the transform is typically expressed as an options transform: a function that takes a mark options object and returns a new, transformed options object, where the returned options object implements a *transform* function option. Transform functions should not mutate the input *data* or *facets*. Likewise options transforms should not mutate the input *options* object. + +Plot provides a few helpers for implementing transforms. + +#### Plot.transform(*options*, *transform*) + +Given an *options* object that may specify some basic transforms (*filter*, *sort*, or *reverse*) or a custom *transform* function, composes those transforms if any with the given *transform* function, returning a new *options* object. If a custom *transform* function is present on the given *options*, any basic transforms are ignored. Any additional input *options* are passed through in the returned *options* object. This method facilitates applying the basic transforms prior to applying the given custom *transform* and is used internally by Plot’s built-in transforms. + +#### Plot.channel([*source*]) + +This helper for constructing derived channels returns a [*channel*, *setChannel*] array. The *channel* object implements *channel*.transform, returning whatever value was most recently passed to *setChannel*. If *setChannel* is not called, then *channel*.transform returns undefined. If a *source* is specified, then *channel*.label exposes the given *source*’s label, if any: if *source* is a string as when representing a named field of data, then *channel*.label is *source*; otherwise *channel*.label propagates *source*.label. This allows derived channels to propagate a human-readable axis or legend label. + +Plot.channel is typically used by options transforms to define new channels; these channels are populated (derived) when the custom *transform* function is invoked. + ## Curves A curve defines how to turn a discrete representation of a line as a sequence of points [[*x₀*, *y₀*], [*x₁*, *y₁*], [*x₂*, *y₂*], …] into a continuous path; *i.e.*, how to interpolate between points. Curves are used by the [line](#line), [area](#area), and [link](#link) mark, and are implemented by [d3-shape](https://github.com/d3/d3-shape/blob/master/README.md#curves). diff --git a/src/index.js b/src/index.js index 7109b319f7..f815c6ba22 100644 --- a/src/index.js +++ b/src/index.js @@ -14,8 +14,8 @@ export {RuleX, RuleY, ruleX, ruleY} from "./marks/rule.js"; export {Text, text, textX, textY} from "./marks/text.js"; export {TickX, TickY, tickX, tickY} from "./marks/tick.js"; export {Vector, vector, vectorX, vectorY} from "./marks/vector.js"; -export {valueof} from "./options.js"; -export {filter, reverse, sort, shuffle} from "./transforms/basic.js"; +export {valueof, channel} from "./options.js"; +export {filter, reverse, sort, shuffle, basic as transform} from "./transforms/basic.js"; export {bin, binX, binY} from "./transforms/bin.js"; export {group, groupX, groupY, groupZ} from "./transforms/group.js"; export {normalize, normalizeX, normalizeY} from "./transforms/normalize.js"; diff --git a/src/options.js b/src/options.js index 78be2825c4..0984f98938 100644 --- a/src/options.js +++ b/src/options.js @@ -156,7 +156,7 @@ export function maybeInput(key, options) { // 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. -export function lazyChannel(source) { +export function channel(source) { let value; return [ { @@ -167,17 +167,17 @@ export function lazyChannel(source) { ]; } +// Like channel, but allows the source to be null. +export function maybeChannel(source) { + return source == null ? [source] : channel(source); +} + export function labelof(value, defaultValue) { return typeof value === "string" ? value : value && value.label !== undefined ? value.label : defaultValue; } -// Like lazyChannel, but allows the source to be null. -export function maybeLazyChannel(source) { - return source == null ? [source] : lazyChannel(source); -} - // Assuming that both x1 and x2 and lazy channels (per above), this derives a // new a channel that’s the average of the two, and which inherits the channel // label (if any). Both input channels are assumed to be quantitative. If either diff --git a/src/transforms/bin.js b/src/transforms/bin.js index 7432eda4d3..40631dde6a 100644 --- a/src/transforms/bin.js +++ b/src/transforms/bin.js @@ -1,5 +1,5 @@ import {bin as binner, extent, thresholdFreedmanDiaconis, thresholdScott, thresholdSturges, utcTickInterval} from "d3"; -import {valueof, range, identity, maybeLazyChannel, maybeTuple, maybeColorChannel, maybeValue, mid, labelof, isTemporal} from "../options.js"; +import {valueof, range, identity, maybeChannel, maybeTuple, maybeColorChannel, maybeValue, mid, labelof, isTemporal} from "../options.js"; import {coerceDate, coerceNumber} from "../scales.js"; import {basic} from "./basic.js"; import {hasOutput, maybeEvaluator, maybeGroup, maybeOutput, maybeOutputs, maybeReduce, maybeSort, maybeSubgroup, reduceCount, reduceFirst, reduceIdentity} from "./group.js"; @@ -67,14 +67,14 @@ function binn( if (gy != null && hasOutput(outputs, "y", "y1", "y2")) gy = null; // Produce x1, x2, y1, and y2 output channels as appropriate (when binning). - const [BX1, setBX1] = maybeLazyChannel(bx); - const [BX2, setBX2] = maybeLazyChannel(bx); - const [BY1, setBY1] = maybeLazyChannel(by); - const [BY2, setBY2] = maybeLazyChannel(by); + const [BX1, setBX1] = maybeChannel(bx); + const [BX2, setBX2] = maybeChannel(bx); + const [BY1, setBY1] = maybeChannel(by); + const [BY2, setBY2] = maybeChannel(by); // Produce x or y output channels as appropriate (when grouping). const [k, gk] = gx != null ? [gx, "x"] : gy != null ? [gy, "y"] : []; - const [GK, setGK] = maybeLazyChannel(k); + const [GK, setGK] = maybeChannel(k); // Greedily materialize the z, fill, and stroke channels (if channels and not // constants) so that we can reference them for subdividing groups without @@ -94,11 +94,11 @@ function binn( interval, // eslint-disable-line no-unused-vars ...options } = inputs; - const [GZ, setGZ] = maybeLazyChannel(z); + const [GZ, setGZ] = maybeChannel(z); const [vfill] = maybeColorChannel(fill); const [vstroke] = maybeColorChannel(stroke); - const [GF = fill, setGF] = maybeLazyChannel(vfill); - const [GS = stroke, setGS] = maybeLazyChannel(vstroke); + const [GF = fill, setGF] = maybeChannel(vfill); + const [GS = stroke, setGS] = maybeChannel(vstroke); return { ..."z" in inputs && {z: GZ || z}, diff --git a/src/transforms/group.js b/src/transforms/group.js index 45f461a58e..63a142f700 100644 --- a/src/transforms/group.js +++ b/src/transforms/group.js @@ -1,6 +1,6 @@ import {group as grouper, sort, sum, deviation, min, max, mean, median, mode, variance, InternSet, minIndex, maxIndex, rollup} from "d3"; import {ascendingDefined, firstof} from "../defined.js"; -import {valueof, maybeColorChannel, maybeInput, maybeTuple, maybeLazyChannel, lazyChannel, first, identity, take, labelof, range, second, percentile} from "../options.js"; +import {valueof, maybeColorChannel, maybeInput, maybeTuple, maybeChannel, channel, first, identity, take, labelof, range, second, percentile} from "../options.js"; import {basic} from "./basic.js"; // Group on {z, fill, stroke}. @@ -51,8 +51,8 @@ function groupn( filter = filter == null ? undefined : maybeEvaluator("filter", filter, inputs); // Produce x and y output channels as appropriate. - const [GX, setGX] = maybeLazyChannel(x); - const [GY, setGY] = maybeLazyChannel(y); + const [GX, setGX] = maybeChannel(x); + const [GY, setGY] = maybeChannel(y); // Greedily materialize the z, fill, and stroke channels (if channels and not // constants) so that we can reference them for subdividing groups without @@ -65,11 +65,11 @@ function groupn( y1, y2, // consumed if y is an output ...options } = inputs; - const [GZ, setGZ] = maybeLazyChannel(z); + const [GZ, setGZ] = maybeChannel(z); const [vfill] = maybeColorChannel(fill); const [vstroke] = maybeColorChannel(stroke); - const [GF = fill, setGF] = maybeLazyChannel(vfill); - const [GS = stroke, setGS] = maybeLazyChannel(vstroke); + const [GF = fill, setGF] = maybeChannel(vfill); + const [GS = stroke, setGS] = maybeChannel(vstroke); return { ..."z" in inputs && {z: GZ || z}, @@ -148,7 +148,7 @@ export function maybeOutputs(outputs, inputs) { export function maybeOutput(name, reduce, inputs) { const evaluator = maybeEvaluator(name, reduce, inputs); - const [output, setOutput] = lazyChannel(evaluator.label); + const [output, setOutput] = channel(evaluator.label); let O; return { name, diff --git a/src/transforms/map.js b/src/transforms/map.js index 5ecc89ed2b..ed7450445a 100644 --- a/src/transforms/map.js +++ b/src/transforms/map.js @@ -1,5 +1,5 @@ import {count, group, rank} from "d3"; -import {maybeZ, take, valueof, maybeInput, lazyChannel} from "../options.js"; +import {maybeZ, take, valueof, maybeInput, channel} from "../options.js"; import {basic} from "./basic.js"; export function mapX(m, options = {}) { @@ -19,7 +19,7 @@ export function map(outputs = {}, options = {}) { const channels = Object.entries(outputs).map(([key, map]) => { const input = maybeInput(key, options); if (input == null) throw new Error(`missing channel: ${key}`); - const [output, setOutput] = lazyChannel(input); + const [output, setOutput] = channel(input); return {key, input, output, setOutput, map: maybeMap(map)}; }); return { diff --git a/src/transforms/stack.js b/src/transforms/stack.js index cc2baab7a0..6f5ad1c080 100644 --- a/src/transforms/stack.js +++ b/src/transforms/stack.js @@ -1,6 +1,6 @@ import {InternMap, cumsum, group, groupSort, greatest, max, min, rollup, sum} from "d3"; import {ascendingDefined} from "../defined.js"; -import {field, lazyChannel, maybeLazyChannel, maybeZ, mid, range, valueof, maybeZero} from "../options.js"; +import {field, channel, maybeChannel, maybeZ, mid, range, valueof, maybeZero} from "../options.js"; import {basic} from "./basic.js"; export function stackX(stackOptions = {}, options = {}) { @@ -67,9 +67,9 @@ function mergeOptions(options) { function stack(x, y = () => 1, ky, {offset, order, reverse}, options) { const z = maybeZ(options); - const [X, setX] = maybeLazyChannel(x); - const [Y1, setY1] = lazyChannel(y); - const [Y2, setY2] = lazyChannel(y); + const [X, setX] = maybeChannel(x); + const [Y1, setY1] = channel(y); + const [Y2, setY2] = channel(y); offset = maybeOffset(offset); order = maybeOrder(order, offset, ky); return [ diff --git a/test/output/diamondsCaratSampling.svg b/test/output/diamondsCaratSampling.svg new file mode 100644 index 0000000000..a3356a7a4d --- /dev/null +++ b/test/output/diamondsCaratSampling.svg @@ -0,0 +1,2079 @@ + + + + + 2,000 + + + 4,000 + + + 6,000 + + + 8,000 + + + 10,000 + + + 12,000 + + + 14,000 + + + 16,000 + + + 18,000 + ↑ price + + + + 0.5 + + + 1.0 + + + 1.5 + + + 2.0 + + + 2.5 + + + 3.0 + + + 3.5 + + + 4.0 + + + 4.5 + + + 5.0 + carat → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/diamonds-carat-sampling.js b/test/plots/diamonds-carat-sampling.js new file mode 100644 index 0000000000..ddb19f8983 --- /dev/null +++ b/test/plots/diamonds-carat-sampling.js @@ -0,0 +1,29 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +// https://observablehq.com/@mbostock/evenly-spaced-sampling +function samples(array, m) { + if (!((m = Math.floor(m)) > 0)) return []; // return nothing + const n = array.length; + if (!(n > m)) return [...array]; // return everything + if (m === 1) return [array[n >> 1]]; // return the midpoint + return Array.from({length: m}, (_, i) => array[Math.round(i / (m - 1) * (n - 1))]); +} + +function sample(n, options) { + return Plot.transform(options, (data, facets) => ({data, facets: Array.from(facets, I => samples(I, n))})); +} + +export default async function() { + const data = await d3.csv("data/diamonds.csv", d3.autoType); + return Plot.plot({ + marks: [ + Plot.dot(data, sample(2000, { + x: "carat", + y: "price", + r: 1, + fill: "currentColor" + })) + ] + }); +} diff --git a/test/plots/index.js b/test/plots/index.js index 794e78b433..8c147c5c04 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -42,6 +42,7 @@ export {default as d3Survey2015Why} from "./d3-survey-2015-why.js"; export {default as decathlon} from "./decathlon.js"; export {default as diamondsCaratPrice} from "./diamonds-carat-price.js"; export {default as diamondsCaratPriceDots} from "./diamonds-carat-price-dots.js"; +export {default as diamondsCaratSampling} from "./diamonds-carat-sampling.js"; export {default as documentationLinks} from "./documentation-links.js"; export {default as downloads} from "./downloads.js"; export {default as driving} from "./driving.js";