diff --git a/README.md b/README.md index f4bdbad1a6..c3707517cd 100644 --- a/README.md +++ b/README.md @@ -950,6 +950,14 @@ Plot.dotY(cars.map(d => d["economy (mpg)"])) Equivalent to [Plot.dot](#plotdotdata-options) except that if the **y** option is not specified, it defaults to the identity function and assumes that *data* = [*y₀*, *y₁*, *y₂*, …]. +### Hexgrid + +The hexgrid mark can be used to support marks using the [hexbin](#hexbin) layout. + +#### Plot.hexgrid([*options*]) + +The *binWidth* option specifies the distance between the centers of neighboring hexagons, in pixels (defaults to 20). The *clip* option defaults to true, clipping the mark to the frame’s dimensions. + ### Image [a scatterplot of Presidential portraits](https://observablehq.com/@observablehq/plot-image) @@ -1437,10 +1445,10 @@ The following aggregation methods are supported: * *pXX* - the percentile value, where XX is a number in [00,99] * *deviation* - the standard deviation * *variance* - the variance per [Welford’s algorithm](https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Welford's_online_algorithm) -* *x* - the middle the bin’s *x*-extent (when binning on *x*) +* *x* - the middle of the bin’s *x*-extent (when binning on *x*) * *x1* - the lower bound of the bin’s *x*-extent (when binning on *x*) * *x2* - the upper bound of the bin’s *x*-extent (when binning on *x*) -* *y* - the middle the bin’s *y*-extent (when binning on *y*) +* *y* - the middle of the bin’s *y*-extent (when binning on *y*) * *y1* - the lower bound of the bin’s *y*-extent (when binning on *y*) * *y2* - the upper bound of the bin’s *y*-extent (when binning on *y*) * a function to be passed the array of values for each bin and the extent of the bin @@ -1939,6 +1947,69 @@ This helper for constructing derived channels returns a [*channel*, *setChannel* Plot.channel is typically used by options transforms to define new channels; these channels are populated (derived) when the custom *transform* function is invoked. +## Scale-aware transforms + +Some transforms need to operate in representation space (such as pixels and colors, *i.e.* after scales have been applied) rather than data space. Such a transform might, for example, modify the marks’ positions in screen space to avoid occlusion. These scale-aware transforms are applied *after* the initial setting of the scales, and can modify the channels or derive new channels—which can in turn be passed to scales. + +### Dodge + +The dodge transform can be applied to any mark that consumes *x* or *y*, such as the Dot, Image, Text and Vector marks. +#### Plot.dodgeY([*layoutOptions*, ]*options*) + +```js +Plot.dodgeY({x: "date"}) +``` + +If the marks are arranged along the *x* axis, the dodgeY transform piles them vertically, keeping their *x* position unchanged, and creating a *y* position that avoids overlapping. + +#### Plot.dodgeX([*layoutOptions*, ]*options*) + +```js +Plot.dodgeX({y: "value"}) +``` + +Equivalent to Plot.dodgeY, but the piling is horizontal, keeping the marks’ *y* position unchanged, and creating an *x* position that avoids overlapping. +The dodge transforms accept the following options: +* **padding** — a number of pixels added to the radius of the mark to estimate its size +* **anchor** - the frame anchor: one of *middle*, *right*, and *left* (default) for dodgeX, and one of *middle*, *top*, and *bottom* (default) for dodgeY. With the *middle* anchor the piles will grow from the center in both directions; with the other anchors, the piles will grow from the specified anchor towards the opposite direction. + +### Hexbin + +The hexbin transform can be applied to any mark that consumes *x* and *y*, such as the Dot, Image, Text and Vector marks. It aggregates the values into hexagonal bins of the given *radius* (in pixel space), and computes new values *x* and *y* as the centers of each bin. It can also return new channels by applying a reducer to each bin, such as the number of elements in the bin. + +#### Plot.hexbin(*outputs*, *options*) + +[Source](./src/transforms/hexbin.js) · [Examples](https://observablehq.com/@observablehq/plot-hexbin) · Aggregates the given inputs into hexagonal bins, and creates output channels with the reduced data. The options must specify the *x* and *y* channels, and can optionally indicate the *binWidth* in pixels (defaults to 20), defined as the distance between the centers of two neighboring hexagons. If any of **z**, **fill**, or **stroke** is a channel, the first of these channels will be used to subdivide bins. The *outputs* options are similar to Plot.bin’s outputs; each output channel receives as input, for each hexagon, the subset of the data which has been matched to its center. The outputs object specifies the aggregation method for each output channel. + +The following aggregation methods are supported: + +* *first* - the first value, in input order +* *last* - the last value, in input order +* *count* - the number of elements (frequency) +* *distinct* - the number of distinct values +* *sum* - the sum of values +* *proportion* - the sum proportional to the overall total (weighted frequency) +* *proportion-facet* - the sum proportional to the facet total +* *min* - the minimum value +* *min-index* - the zero-based index of the minimum value +* *max* - the maximum value +* *max-index* - the zero-based index of the maximum value +* *mean* - the mean value (average) +* *median* - the median value +* *deviation* - the standard deviation +* *variance* - the variance per [Welford’s algorithm](https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Welford's_online_algorithm) +* *mode* - the value with the most occurrences +* a function to be passed the array of values for each bin and the extent of the bin +* an object with a *reduce* method + +When the hexbin transform has an *r* output, the bins are returned in decreasing size order. + +See also the [hexgrid](#hexgrid) mark. + +### Custom scale-aware transforms + +When its *options* have an *initialize* property, the initialize function is called after the scales have been computed. It receives as inputs the (possibly transformed) data array, the index of elements of this array that belong to each facet, the input channels (as a key: array object), the scales, and the dimensions, with the mark as this. It must return the data, index, and the channels that need to be scaled in a second pass. + ## 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/package.json b/package.json index b4ba6a0d3a..8892247b1a 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ }, "dependencies": { "d3": "^7.3.0", + "d3-hexbin": "^0.2.2", "isoformat": "0.2" }, "engines": { diff --git a/src/channel.js b/src/channel.js index 61c93061f3..b0e31486ba 100644 --- a/src/channel.js +++ b/src/channel.js @@ -15,6 +15,28 @@ export function Channel(data, {scale, type, value, filter, hint}) { }; } +export function channelObject(channelDescriptors, data) { + const channels = {}; + for (const channel of channelDescriptors) { + channels[channel.name] = Channel(data, channel); + } + return channels; +} + +// TODO Use Float64Array for scales with numeric ranges, e.g. position? +export function valueObject(channels, scales) { + const values = {}; + for (const channelName in channels) { + const {scale: scaleName, value} = channels[channelName]; + const scale = scales[scaleName]; + values[channelName] = scale === undefined ? value : Array.from(value, scale); + } + return values; +} + +// Note: mutates channel.domain! This is set to a function so that it is lazily +// computed; i.e., if the scale’s domain is set explicitly, that takes priority +// over the sort option, and we don’t need to do additional work. export function channelSort(channels, facetChannels, data, options) { const {reverse: defaultReverse, reduce: defaultReduce = true, limit: defaultLimit} = options; for (const x in options) { @@ -22,12 +44,12 @@ export function channelSort(channels, facetChannels, data, options) { let {value: y, reverse = defaultReverse, reduce = defaultReduce, limit = defaultLimit} = maybeValue(options[x]); if (reverse === undefined) reverse = y === "width" || y === "height"; // default to descending for lengths if (reduce == null || reduce === false) continue; // disabled reducer - const X = channels.find(([, {scale}]) => scale === x) || facetChannels && facetChannels.find(([, {scale}]) => scale === x); + const X = findScaleChannel(channels, x) || facetChannels && findScaleChannel(facetChannels, x); if (!X) throw new Error(`missing channel for scale: ${x}`); - const XV = X[1].value; + const XV = X.value; const [lo = 0, hi = Infinity] = limit && typeof limit[Symbol.iterator] === "function" ? limit : limit < 0 ? [limit] : [0, limit]; if (y == null) { - X[1].domain = () => { + X.domain = () => { let domain = XV; if (reverse) domain = domain.slice().reverse(); if (lo !== 0 || hi !== Infinity) domain = domain.slice(lo, hi); @@ -39,7 +61,7 @@ export function channelSort(channels, facetChannels, data, options) { : y === "width" ? difference(channels, "x1", "x2") : values(channels, y, y === "y" ? "y2" : y === "x" ? "x2" : undefined); const reducer = maybeReduce(reduce === true ? "max" : reduce, YV); - X[1].domain = () => { + X.domain = () => { let domain = rollup(range(XV), I => reducer.reduce(I, YV), i => XV[i]); domain = sort(domain, reverse ? descendingGroup : ascendingGroup); if (lo !== 0 || hi !== Infinity) domain = domain.slice(lo, hi); @@ -49,6 +71,13 @@ export function channelSort(channels, facetChannels, data, options) { } } +function findScaleChannel(channels, scale) { + for (const name in channels) { + const channel = channels[name]; + if (channel.scale === scale) return channel; + } +} + function difference(channels, k1, k2) { const X1 = values(channels, k1); const X2 = values(channels, k2); @@ -56,9 +85,9 @@ function difference(channels, k1, k2) { } function values(channels, name, alias) { - let channel = channels.find(([n]) => n === name); - if (!channel && alias !== undefined) channel = channels.find(([n]) => n === alias); - if (channel) return channel[1].value; + let channel = channels[name]; + if (!channel && alias !== undefined) channel = channels[alias]; + if (channel) return channel.value; throw new Error(`missing channel: ${name}`); } diff --git a/src/index.js b/src/index.js index f815c6ba22..90e4bd4ff8 100644 --- a/src/index.js +++ b/src/index.js @@ -4,8 +4,9 @@ export {Arrow, arrow} from "./marks/arrow.js"; export {BarX, BarY, barX, barY} from "./marks/bar.js"; export {boxX, boxY} from "./marks/box.js"; export {Cell, cell, cellX, cellY} from "./marks/cell.js"; -export {Dot, dot, dotX, dotY} from "./marks/dot.js"; +export {Dot, dot, dotX, dotY, circle, hexagon} from "./marks/dot.js"; export {Frame, frame} from "./marks/frame.js"; +export {Hexgrid, hexgrid} from "./marks/hexgrid.js"; export {Image, image} from "./marks/image.js"; export {Line, line, lineX, lineY} from "./marks/line.js"; export {Link, link} from "./marks/link.js"; @@ -18,6 +19,7 @@ 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 {hexbin} from "./transforms/hexbin.js"; export {normalize, normalizeX, normalizeY} from "./transforms/normalize.js"; export {map, mapX, mapY} from "./transforms/map.js"; export {window, windowX, windowY} from "./transforms/window.js"; diff --git a/src/marks/dot.js b/src/marks/dot.js index b1b06e7321..aee2ec734f 100644 --- a/src/marks/dot.js +++ b/src/marks/dot.js @@ -1,8 +1,9 @@ import {create, path, symbolCircle} from "d3"; import {positive} from "../defined.js"; -import {identity, maybeFrameAnchor, maybeNumberChannel, maybeSymbolChannel, maybeTuple} from "../options.js"; +import {identity, maybeFrameAnchor, maybeNumberChannel, maybeTuple} from "../options.js"; import {Mark} from "../plot.js"; import {applyChannelStyles, applyDirectStyles, applyFrameAnchor, applyIndirectStyles, applyTransform, offset} from "../style.js"; +import {maybeSymbolChannel} from "../symbols.js"; const defaults = { ariaLabel: "dot", @@ -100,3 +101,11 @@ export function dotX(data, {x = identity, ...options} = {}) { export function dotY(data, {y = identity, ...options} = {}) { return new Dot(data, {...options, y}); } + +export function circle(data, options) { + return dot(data, {...options, symbol: "circle"}); +} + +export function hexagon(data, options) { + return dot(data, {...options, symbol: "hexagon"}); +} diff --git a/src/marks/hexgrid.js b/src/marks/hexgrid.js new file mode 100644 index 0000000000..d65cd5db5c --- /dev/null +++ b/src/marks/hexgrid.js @@ -0,0 +1,45 @@ +import {create} from "d3"; +import {Mark} from "../plot.js"; +import {number} from "../options.js"; +import {applyDirectStyles, applyIndirectStyles, applyTransform, offset} from "../style.js"; +import {sqrt4_3} from "../symbols.js"; + +const defaults = { + ariaLabel: "hexgrid", + fill: "none", + stroke: "currentColor", + strokeOpacity: 0.1 +}; + +export function hexgrid(options) { + return new Hexgrid(options); +} + +export class Hexgrid extends Mark { + constructor({radius = 10, clip = true, ...options} = {}) { + super(undefined, undefined, {clip, ...options}, defaults); + this.radius = number(radius); + } + render(index, scales, channels, dimensions) { + const {dx, dy, radius: rx} = this; + const {marginTop, marginRight, marginBottom, marginLeft, width, height} = dimensions; + const x0 = marginLeft, x1 = width - marginRight, y0 = marginTop, y1 = height - marginBottom; + const ry = rx * sqrt4_3, hy = ry / 2, wx = rx * 2, wy = ry * 1.5; + const path = `m0,${-ry}l${rx},${hy}v${ry}l${-rx},${hy}`; + const i0 = Math.floor(x0 / wx), i1 = Math.ceil(x1 / wx); + const j0 = Math.floor((y0 + hy) / wy), j1 = Math.ceil((y1 - hy) / wy) + 1; + const m = []; + for (let j = j0; j < j1; ++j) { + for (let i = i0; i < i1; ++i) { + m.push(`M${i * wx + (j & 1) * rx},${j * wy}${path}`); + } + } + return create("svg:g") + .call(applyIndirectStyles, this, dimensions) + .call(g => g.append("path") + .call(applyDirectStyles, this) + .call(applyTransform, null, null, offset + dx, offset + dy) + .attr("d", m.join(""))) + .node(); + } +} diff --git a/src/options.js b/src/options.js index 0984f98938..af451fc186 100644 --- a/src/options.js +++ b/src/options.js @@ -1,7 +1,5 @@ import {parse as isoParse} from "isoformat"; import {color, descending, quantile} from "d3"; -import {symbolAsterisk, symbolDiamond2, symbolPlus, symbolSquare2, symbolTriangle2, symbolX as symbolTimes} from "d3"; -import {symbolCircle, symbolCross, symbolDiamond, symbolSquare, symbolStar, symbolTriangle, symbolWye} from "d3"; // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray const TypedArray = Object.getPrototypeOf(Uint8Array); @@ -22,6 +20,7 @@ export const field = name => d => d[name]; export const indexOf = (d, i) => i; export const identity = {transform: d => d}; export const zero = () => 0; +export const yes = () => true; export const string = x => x == null ? x : `${x}`; export const number = x => x == null ? x : +x; export const boolean = x => x == null ? x : !!x; @@ -305,48 +304,6 @@ export function isRound(value) { return /^\s*round\s*$/i.test(value); } -const symbols = new Map([ - ["asterisk", symbolAsterisk], - ["circle", symbolCircle], - ["cross", symbolCross], - ["diamond", symbolDiamond], - ["diamond2", symbolDiamond2], - ["plus", symbolPlus], - ["square", symbolSquare], - ["square2", symbolSquare2], - ["star", symbolStar], - ["times", symbolTimes], - ["triangle", symbolTriangle], - ["triangle2", symbolTriangle2], - ["wye", symbolWye] -]); - -function isSymbolObject(value) { - return value && typeof value.draw === "function"; -} - -export function isSymbol(value) { - if (isSymbolObject(value)) return true; - if (typeof value !== "string") return false; - return symbols.has(value.toLowerCase()); -} - -export function maybeSymbol(symbol) { - if (symbol == null || isSymbolObject(symbol)) return symbol; - const value = symbols.get(`${symbol}`.toLowerCase()); - if (value) return value; - throw new Error(`invalid symbol: ${symbol}`); -} - -export function maybeSymbolChannel(symbol) { - if (symbol == null || isSymbolObject(symbol)) return [undefined, symbol]; - if (typeof symbol === "string") { - const value = symbols.get(`${symbol}`.toLowerCase()); - if (value) return [undefined, value]; - } - return [symbol, undefined]; -} - export function maybeFrameAnchor(value = "middle") { return keyword(value, "frameAnchor", ["middle", "top-left", "top", "top-right", "right", "bottom-right", "bottom", "bottom-left", "left"]); } diff --git a/src/plot.js b/src/plot.js index 5e3dfa468c..770fba3d48 100644 --- a/src/plot.js +++ b/src/plot.js @@ -1,11 +1,12 @@ import {create, cross, difference, groups, InternMap, select} from "d3"; import {Axes, autoAxisTicks, autoScaleLabels} from "./axes.js"; -import {Channel, channelSort} from "./channel.js"; +import {Channel, channelObject, channelSort, valueObject} from "./channel.js"; import {defined} from "./defined.js"; import {Dimensions} from "./dimensions.js"; import {Legends, exposeLegends} from "./legends.js"; -import {arrayify, isOptions, keyword, range, second, where} from "./options.js"; -import {Scales, ScaleFunctions, autoScaleRange, applyScales, exposeScales} from "./scales.js"; +import {arrayify, isOptions, isScaleOptions, keyword, range, second, where, yes} from "./options.js"; +import {Scales, ScaleFunctions, autoScaleRange, exposeScales} from "./scales.js"; +import {registry as scaleRegistry} from "./scales/index.js"; import {applyInlineStyles, maybeClassName, maybeClip, styles} from "./style.js"; import {basic} from "./transforms/basic.js"; import {consumeWarnings} from "./warnings.js"; @@ -29,25 +30,35 @@ export function plot(options = {}) { // A Map from scale name to an array of associated channels. const channelsByScale = new Map(); + // If a scale is explicitly declared in options, initialize its associated + // channels to the empty array; this will guarantee that a corresponding scale + // will be created later (even if there are no other channels). But ignore + // facet scale declarations if faceting is not enabled. + for (const key of scaleRegistry.keys()) { + if (isScaleOptions(options[key]) && key !== "fx" && key !== "fy") { + channelsByScale.set(key, []); + } + } + // Faceting! let facets; // array of facet definitions (e.g. [["foo", [0, 1, 3, …]], …]) let facetIndex; // index over the facet data, e.g. [0, 1, 2, 3, …] - let facetChannels; // e.g. [["fx", {value}], ["fy", {value}]] + let facetChannels; // e.g. {fx: {value}, fy: {value}} let facetsIndex; // nested array of facet indexes [[0, 1, 3, …], [2, 5, …], …] let facetsExclude; // lazily-constructed opposite of facetsIndex if (facet !== undefined) { const {x, y} = facet; if (x != null || y != null) { const facetData = arrayify(facet.data); - facetChannels = []; + facetChannels = {}; if (x != null) { const fx = Channel(facetData, {value: x, scale: "fx"}); - facetChannels.push(["fx", fx]); + facetChannels.fx = fx; channelsByScale.set("fx", [fx]); } if (y != null) { const fy = Channel(facetData, {value: y, scale: "fy"}); - facetChannels.push(["fy", fy]); + facetChannels.fy = fy; channelsByScale.set("fy", [fy]); } facetIndex = range(facetData); @@ -56,33 +67,21 @@ export function plot(options = {}) { } } - // Initialize the marks’ channels, indexing them by mark and scale as needed. + // Initialize the marks’ state. for (const mark of marks) { if (stateByMark.has(mark)) throw new Error("duplicate mark"); - const markFacets = facets === undefined ? undefined + const markFacets = facetsIndex === undefined ? undefined : mark.facet === "auto" ? mark.data === facet.data ? facetsIndex : undefined : mark.facet === "include" ? facetsIndex : mark.facet === "exclude" ? facetsExclude || (facetsExclude = facetsIndex.map(f => Uint32Array.from(difference(facetIndex, f)))) : undefined; - const {index, channels} = mark.initialize(markFacets, facetChannels); - for (const [, channel] of channels) { - const {scale} = channel; - if (scale !== undefined) { - const channels = channelsByScale.get(scale); - if (channels !== undefined) channels.push(channel); - else channelsByScale.set(scale, [channel]); - } - } - stateByMark.set(mark, {index, channels, faceted: markFacets !== undefined}); + const {facets, channels} = mark.initialize(markFacets, facetChannels); + applyScaleTransforms(channels, options); + stateByMark.set(mark, {facets, channels}); } - // Apply scale transforms, mutating channel.value. - for (const [scale, channels] of channelsByScale) { - const {percent, transform = percent ? x => x * 100 : undefined} = options[scale] || {}; - if (transform != null) for (const c of channels) c.value = Array.from(c.value, transform); - } - - const scaleDescriptors = Scales(channelsByScale, options); + // Initalize the scales and axes. + const scaleDescriptors = Scales(addScaleChannels(channelsByScale, stateByMark), options); const scales = ScaleFunctions(scaleDescriptors); const axes = Axes(scaleDescriptors, options); const dimensions = Dimensions(scaleDescriptors, axes, options); @@ -91,9 +90,32 @@ export function plot(options = {}) { autoScaleLabels(channelsByScale, scaleDescriptors, axes, dimensions, options); autoAxisTicks(scaleDescriptors, axes); + // Reinitialize; for deriving channels dependent on other channels. + const newByScale = new Set(); + for (const [mark, state] of stateByMark) { + if (mark.reinitialize != null) { + const {facets, channels} = mark.reinitialize(state.facets, state.channels, scales); + if (facets !== undefined) state.facets = facets; + if (channels !== undefined) { + inferChannelScale(channels, mark); + applyScaleTransforms(channels, options); + Object.assign(state.channels, channels); + for (const {scale} of Object.values(channels)) if (scale != null) newByScale.add(scale); + } + } + } + + // Reconstruct scales if new scaled channels were created during reinitialization. + if (newByScale.size) { + const newScaleDescriptors = Scales(addScaleChannels(new Map(), stateByMark, key => newByScale.has(key)), options); + const newScales = ScaleFunctions(newScaleDescriptors); + Object.assign(scaleDescriptors, newScaleDescriptors); + Object.assign(scales, newScales); + } + // Compute value objects, applying scales as needed. for (const state of stateByMark.values()) { - state.values = applyScales(state.channels, scales); + state.values = valueObject(state.channels, scales); } const {width, height} = dimensions; @@ -175,16 +197,16 @@ export function plot(options = {}) { .attr("transform", facetTranslate(fx, fy)) .each(function(key) { const j = indexByFacet.get(key); - for (const [mark, {channels, values, index, faceted}] of stateByMark) { - const renderIndex = mark.filter(faceted ? index[j] : index, channels, values); - const node = mark.render(renderIndex, scales, values, subdimensions); + for (const [mark, {channels, values, facets}] of stateByMark) { + const facet = facets ? mark.filter(facets[j] ?? facets[0], channels, values) : null; + const node = mark.render(facet, scales, values, subdimensions); if (node != null) this.appendChild(node); } }); } else { - for (const [mark, {channels, values, index}] of stateByMark) { - const renderIndex = mark.filter(index, channels, values); - const node = mark.render(renderIndex, scales, values, dimensions); + for (const [mark, {channels, values, facets}] of stateByMark) { + const facet = facets ? mark.filter(facets[0], channels, values) : null; + const node = mark.render(facet, scales, values, dimensions); if (node != null) svg.appendChild(node); } } @@ -227,6 +249,7 @@ export class Mark { const {facet = "auto", sort, dx, dy, clip} = options; const names = new Set(); this.data = data; + this.reinitialize = options.initialize; this.sort = isOptions(sort) ? sort : null; this.facet = facet == null || facet === false ? null : keyword(facet === true ? "include" : facet, "facet", ["auto", "include", "exclude"]); const {transform} = basic(options); @@ -249,25 +272,18 @@ export class Mark { this.dy = +dy || 0; this.clip = maybeClip(clip); } - initialize(facetIndex, facetChannels) { + initialize(facets, facetChannels) { let data = arrayify(this.data); - let index = facetIndex === undefined && data != null ? range(data) : facetIndex; - if (data !== undefined && this.transform !== undefined) { - if (facetIndex === undefined) index = index.length ? [index] : []; - ({facets: index, data} = this.transform(data, index)); - data = arrayify(data); - if (facetIndex === undefined && index.length) ([index] = index); - } - const channels = this.channels.map(channel => { - const {name} = channel; - return [name == null ? undefined : `${name}`, Channel(data, channel)]; - }); + if (facets === undefined && data != null) facets = [range(data)]; + if (this.transform != null) ({facets, data} = this.transform(data, facets)), data = arrayify(data); + const channels = channelObject(this.channels, data); if (this.sort != null) channelSort(channels, facetChannels, data, this.sort); - return {index, channels}; + return {facets, channels}; } filter(index, channels, values) { - for (const [name, {filter = defined}] of channels) { - if (name !== undefined && filter !== null) { + for (const name in channels) { + const {filter = defined} = channels[name]; + if (filter !== null) { const value = values[name]; index = index.filter(i => filter(value[i])); } @@ -298,6 +314,53 @@ class Render extends Mark { render() {} } +// Note: mutates channel.value to apply the scale transform, if any. +function applyScaleTransforms(channels, options) { + for (const name in channels) { + const channel = channels[name]; + const {scale} = channel; + if (scale != null) { + const {percent, transform = percent ? x => x * 100 : undefined} = options[scale] || {}; + if (transform != null) channel.value = Array.from(channel.value, transform); + } + } + return channels; +} + +// An initializer may generate channels without knowing how the downstream mark +// will use them. Marks are typically responsible associated scales with +// channels, but here we assume common behavior across marks. +function inferChannelScale(channels) { + for (const name in channels) { + const channel = channels[name]; + let {scale} = channel; + if (scale === true) { + switch (name) { + case "fill": case "stroke": scale = "color"; break; + case "fillOpacity": case "strokeOpacity": case "opacity": scale = "opacity"; break; + case "r": case "length": case "symbol": scale = name; break; + default: scale = null; + } + channel.scale = scale; + } + } +} + +function addScaleChannels(channelsByScale, stateByMark, filter = yes) { + for (const {channels} of stateByMark.values()) { + for (const name in channels) { + const channel = channels[name]; + const {scale} = channel; + if (scale != null && filter(scale)) { + const channels = channelsByScale.get(scale); + if (channels !== undefined) channels.push(channel); + else channelsByScale.set(scale, [channel]); + } + } + } + return channelsByScale; +} + // Derives a copy of the specified axis with the label disabled. function nolabel(axis) { return axis === undefined || axis.label === undefined @@ -316,15 +379,17 @@ function facetKeys({fx, fy}) { // Returns an array of [[key1, index1], [key2, index2], …] representing the data // indexes associated with each facet. For two-dimensional faceting, each key // is a two-element array; see also facetMap. -function facetGroups(index, channels) { - return (channels.length > 1 ? facetGroup2 : facetGroup1)(index, ...channels); +function facetGroups(index, {fx, fy}) { + return fx && fy ? facetGroup2(index, fx, fy) + : fx ? facetGroup1(index, fx) + : facetGroup1(index, fy); } -function facetGroup1(index, [, {value: F}]) { +function facetGroup1(index, {value: F}) { return groups(index, i => F[i]); } -function facetGroup2(index, [, {value: FX}], [, {value: FY}]) { +function facetGroup2(index, {value: FX}, {value: FY}) { return groups(index, i => FX[i], i => FY[i]) .flatMap(([x, xgroup]) => xgroup .map(([y, ygroup]) => [[x, y], ygroup])); @@ -337,8 +402,8 @@ function facetTranslate(fx, fy) { : ky => `translate(0,${fy(ky)})`; } -function facetMap(channels) { - return new (channels.length > 1 ? FacetMap2 : FacetMap); +function facetMap({fx, fy}) { + return new (fx && fy ? FacetMap2 : FacetMap); } class FacetMap { diff --git a/src/scales.js b/src/scales.js index 7b89b86874..94dc0ecccd 100644 --- a/src/scales.js +++ b/src/scales.js @@ -1,13 +1,14 @@ import {parse as isoParse} from "isoformat"; -import {isColor, isEvery, isOrdinal, isFirst, isSymbol, isTemporal, maybeSymbol, order, isTemporalString, isNumericString, isScaleOptions} from "./options.js"; +import {isColor, isEvery, isOrdinal, isFirst, isTemporal, order, isTemporalString, isNumericString, isScaleOptions} from "./options.js"; import {registry, color, position, radius, opacity, symbol, length} from "./scales/index.js"; import {ScaleLinear, ScaleSqrt, ScalePow, ScaleLog, ScaleSymlog, ScaleQuantile, ScaleThreshold, ScaleIdentity} from "./scales/quantitative.js"; import {ScaleDiverging, ScaleDivergingSqrt, ScaleDivergingPow, ScaleDivergingLog, ScaleDivergingSymlog} from "./scales/diverging.js"; import {ScaleTime, ScaleUtc} from "./scales/temporal.js"; import {ScaleOrdinal, ScalePoint, ScaleBand, ordinalImplicit} from "./scales/ordinal.js"; import {warn} from "./warnings.js"; +import {isSymbol, maybeSymbol} from "./symbols.js"; -export function Scales(channels, { +export function Scales(channelsByScale, { inset: globalInset = 0, insetTop: globalInsetTop = globalInset, insetRight: globalInsetRight = globalInset, @@ -21,42 +22,39 @@ export function Scales(channels, { ...options } = {}) { const scales = {}; - for (const key of registry.keys()) { - const scaleChannels = channels.get(key); + for (const [key, channels] of channelsByScale) { const scaleOptions = options[key]; - if (scaleChannels || scaleOptions) { - const scale = Scale(key, scaleChannels, { - round: registry.get(key) === position ? round : undefined, // only for position - nice, - clamp, - align, - padding, - ...scaleOptions - }); - if (scale) { - // populate generic scale options (percent, transform, insets) - let { - percent, - transform, - inset, - insetTop = inset !== undefined ? inset : key === "y" ? globalInsetTop : 0, // not fy - insetRight = inset !== undefined ? inset : key === "x" ? globalInsetRight : 0, // not fx - insetBottom = inset !== undefined ? inset : key === "y" ? globalInsetBottom : 0, // not fy - insetLeft = inset !== undefined ? inset : key === "x" ? globalInsetLeft : 0 // not fx - } = scaleOptions || {}; - if (transform == null) transform = undefined; - else if (typeof transform !== "function") throw new Error("invalid scale transform"); - scale.percent = !!percent; - scale.transform = transform; - if (key === "x" || key === "fx") { - scale.insetLeft = +insetLeft; - scale.insetRight = +insetRight; - } else if (key === "y" || key === "fy") { - scale.insetTop = +insetTop; - scale.insetBottom = +insetBottom; - } - scales[key] = scale; + const scale = Scale(key, channels, { + round: registry.get(key) === position ? round : undefined, // only for position + nice, + clamp, + align, + padding, + ...scaleOptions + }); + if (scale) { + // populate generic scale options (percent, transform, insets) + let { + percent, + transform, + inset, + insetTop = inset !== undefined ? inset : key === "y" ? globalInsetTop : 0, // not fy + insetRight = inset !== undefined ? inset : key === "x" ? globalInsetRight : 0, // not fx + insetBottom = inset !== undefined ? inset : key === "y" ? globalInsetBottom : 0, // not fy + insetLeft = inset !== undefined ? inset : key === "x" ? globalInsetLeft : 0 // not fx + } = scaleOptions || {}; + if (transform == null) transform = undefined; + else if (typeof transform !== "function") throw new Error("invalid scale transform"); + scale.percent = !!percent; + scale.transform = transform; + if (key === "x" || key === "fx") { + scale.insetLeft = +insetLeft; + scale.insetRight = +insetRight; + } else if (key === "y" || key === "fy") { + scale.insetTop = +insetTop; + scale.insetBottom = +insetBottom; } + scales[key] = scale; } } return scales; @@ -318,23 +316,6 @@ export function scaleOrder({range, domain = range}) { return Math.sign(order(domain)) * Math.sign(order(range)); } -// TODO use Float64Array.from for position and radius scales? -export function applyScales(channels, scales) { - const values = Object.create(null); - for (let [name, {value, scale}] of channels) { - if (name !== undefined) { - if (scale !== undefined) { - scale = scales[scale]; - if (scale !== undefined) { - value = Array.from(value, scale); - } - } - values[name] = value; - } - } - return values; -} - // Certain marks have special behavior if a scale is collapsed, i.e. if the // domain is degenerate and represents only a single value such as [3, 3]; for // example, a rect will span the full extent of the chart along a collapsed diff --git a/src/scales/ordinal.js b/src/scales/ordinal.js index 90ee306dec..cb661c204c 100644 --- a/src/scales/ordinal.js +++ b/src/scales/ordinal.js @@ -1,7 +1,8 @@ import {InternSet, quantize, reverse as reverseof, sort, symbolsFill, symbolsStroke} from "d3"; import {scaleBand, scaleOrdinal, scalePoint, scaleImplicit} from "d3"; import {ascendingDefined} from "../defined.js"; -import {maybeSymbol, isNoneish} from "../options.js"; +import {isNoneish} from "../options.js"; +import {maybeSymbol} from "../symbols.js"; import {registry, color, symbol} from "./index.js"; import {maybeBooleanRange, ordinalScheme, quantitativeScheme} from "./schemes.js"; @@ -105,7 +106,7 @@ function maybeRound(scale, channels, options) { function inferDomain(channels) { const values = new InternSet(); for (const {value, domain} of channels) { - if (domain !== undefined) return domain(); + if (domain !== undefined) return domain(); // see channelSort if (value === undefined) continue; for (const v of value) values.add(v); } @@ -113,16 +114,22 @@ function inferDomain(channels) { } // If all channels provide a consistent hint, propagate it to the scale. -function inferSymbolHint(channels) { - const hint = {}; - for (const {hint: channelHint} of channels) { - for (const key of ["fill", "stroke"]) { - const value = channelHint[key]; - if (!(key in hint)) hint[key] = value; - else if (hint[key] !== value) hint[key] = undefined; - } +function inferHint(channels, key) { + let value; + for (const {hint} of channels) { + const candidate = hint?.[key]; + if (candidate === undefined) continue; // no hint here + if (value === undefined) value = candidate; // first hint + else if (value !== candidate) return; // inconsistent hint } - return hint; + return value; +} + +function inferSymbolHint(channels) { + return { + fill: inferHint(channels, "fill"), + stroke: inferHint(channels, "stroke") + }; } function inferSymbolRange(hint) { diff --git a/src/scales/quantitative.js b/src/scales/quantitative.js index 39f6584a1c..bccef7f078 100644 --- a/src/scales/quantitative.js +++ b/src/scales/quantitative.js @@ -171,9 +171,11 @@ function inferZeroDomain(channels) { } // We don’t want the upper bound of the radial domain to be zero, as this would -// be degenerate, so we ignore nonpositive values. We also don’t want the maximum -// default radius to exceed 30px. +// be degenerate, so we ignore nonpositive values. We also don’t want the +// maximum default radius to exceed 30px. function inferRadialRange(channels, domain) { + const hint = channels.find(({radius}) => radius !== undefined); + if (hint !== undefined) return [0, hint.radius]; // a natural maximum radius, e.g. hexbins const h25 = quantile(channels, 0.5, ({value}) => value === undefined ? NaN : quantile(value, 0.25, positive)); const range = domain.map(d => 3 * Math.sqrt(d / h25)); const k = 30 / max(range); diff --git a/src/symbols.js b/src/symbols.js new file mode 100644 index 0000000000..f501631b4c --- /dev/null +++ b/src/symbols.js @@ -0,0 +1,60 @@ +import {symbolAsterisk, symbolDiamond2, symbolPlus, symbolSquare2, symbolTriangle2, symbolX as symbolTimes} from "d3"; +import {symbolCircle, symbolCross, symbolDiamond, symbolSquare, symbolStar, symbolTriangle, symbolWye} from "d3"; + +export const sqrt4_3 = 2 / Math.sqrt(3); + +const symbolHexagon = { + draw(context, size) { + const rx = Math.sqrt(size / Math.PI), ry = rx * sqrt4_3, hy = ry / 2; + context.moveTo(0, ry); + context.lineTo(rx, hy); + context.lineTo(rx, -hy); + context.lineTo(0, -ry); + context.lineTo(-rx, -hy); + context.lineTo(-rx, hy); + context.closePath(); + } +}; + +const symbols = new Map([ + ["asterisk", symbolAsterisk], + ["circle", symbolCircle], + ["cross", symbolCross], + ["diamond", symbolDiamond], + ["diamond2", symbolDiamond2], + ["hexagon", symbolHexagon], + ["plus", symbolPlus], + ["square", symbolSquare], + ["square2", symbolSquare2], + ["star", symbolStar], + ["times", symbolTimes], + ["triangle", symbolTriangle], + ["triangle2", symbolTriangle2], + ["wye", symbolWye] +]); + +function isSymbolObject(value) { + return value && typeof value.draw === "function"; +} + +export function isSymbol(value) { + if (isSymbolObject(value)) return true; + if (typeof value !== "string") return false; + return symbols.has(value.toLowerCase()); +} + +export function maybeSymbol(symbol) { + if (symbol == null || isSymbolObject(symbol)) return symbol; + const value = symbols.get(`${symbol}`.toLowerCase()); + if (value) return value; + throw new Error(`invalid symbol: ${symbol}`); +} + +export function maybeSymbolChannel(symbol) { + if (symbol == null || isSymbolObject(symbol)) return [undefined, symbol]; + if (typeof symbol === "string") { + const value = symbols.get(`${symbol}`.toLowerCase()); + if (value) return [undefined, value]; + } + return [symbol, undefined]; +} diff --git a/src/transforms/hexbin.js b/src/transforms/hexbin.js new file mode 100644 index 0000000000..b8c235f367 --- /dev/null +++ b/src/transforms/hexbin.js @@ -0,0 +1,57 @@ +import {hexbin as Hexbin} from "d3-hexbin"; // TODO inline +import {sqrt4_3} from "../symbols.js"; +import {basic} from "./basic.js"; +import {hasOutput, maybeOutputs} from "./group.js"; + +export function hexbin(outputs = {fill: "count"}, options = {}) { + const {radius, ...rest} = outputs; + return hexbinn(rest, {radius, ...options}); +} + +// TODO group by (implicit) z +// TODO filter e.g. to show empty hexbins? +// TODO data output with sort and reverse? +// TODO disallow x, x1, x2, y, y1, y2 reducers? +function hexbinn(outputs, {radius = 10, ...options}) { + radius = +radius; + outputs = maybeOutputs(outputs, options); + return { + symbol: "hexagon", + ...!hasOutput(outputs, "r") && {r: radius}, + ...hasOutput(outputs, "fill") && {stroke: "none"}, + ...basic(options, (data, facets) => { + for (const o of outputs) o.initialize(data); + return {data, facets}; + }), + initialize(facets, {x: X, y: Y}, {x, y}) { + if (X === undefined) throw new Error("missing channel: x"); + if (Y === undefined) throw new Error("missing channel: y"); + ({value: X} = X); + ({value: Y} = Y); + const binsof = Hexbin().x(i => x(X[i])).y(i => y(Y[i])).radius(radius * sqrt4_3); + const binFacets = []; + const BX = []; + const BY = []; + let i = 0; + for (const facet of facets) { + const binFacet = []; + for (const o of outputs) o.scope("facet", facet); + for (const bin of binsof(facet)) { + binFacet.push(i++); + BX.push(bin.x); + BY.push(bin.y); + for (const o of outputs) o.reduce(bin); + } + binFacets.push(binFacet); + } + return { + facets: binFacets, + channels: { + x: {value: BX}, + y: {value: BY}, + ...Object.fromEntries(outputs.map(({name, output}) => [name, {scale: true, radius: name === "r" ? radius : undefined, value: output.transform()}])) + } + }; + } + }; +} diff --git a/test/output/hexbin.svg b/test/output/hexbin.svg new file mode 100644 index 0000000000..3dd2cc8e45 --- /dev/null +++ b/test/output/hexbin.svg @@ -0,0 +1,284 @@ + + + + + 34 + + + 36 + + + 38 + + + 40 + + + 42 + + + 44 + + + 46 + + + 48 + + + 50 + + + 52 + + + 54 + + + 56 + + + 58 + ↑ culmen_length_mm + + + + 14 + + + 15 + + + 16 + + + 17 + + + 18 + + + 19 + + + 20 + + + 21 + culmen_depth_mm → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/hexbin.js b/test/plots/hexbin.js new file mode 100644 index 0000000000..9b2e63872b --- /dev/null +++ b/test/plots/hexbin.js @@ -0,0 +1,13 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export default async function() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + return Plot.plot({ + marks: [ + Plot.frame(), + Plot.hexgrid(), + Plot.dot(penguins, Plot.hexbin({r: "count"}, {x: "culmen_depth_mm", y: "culmen_length_mm"})) + ] + }); +} diff --git a/test/plots/index.js b/test/plots/index.js index 8c147c5c04..ab58150106 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -62,6 +62,7 @@ export {default as gistempAnomalyTransform} from "./gistemp-anomaly-transform.js export {default as googleTrendsRidgeline} from "./google-trends-ridgeline.js"; export {default as gridChoropleth} from "./grid-choropleth.js"; export {default as hadcrutWarmingStripes} from "./hadcrut-warming-stripes.js"; +export {default as hexbin} from "./hexbin.js"; export {default as highCardinalityOrdinal} from "./high-cardinality-ordinal.js"; export {default as identityScale} from "./identity-scale.js"; export {default as industryUnemployment} from "./industry-unemployment.js"; diff --git a/test/transforms/normalize-test.js b/test/transforms/normalize-test.js index 92b5884954..98599c34f4 100644 --- a/test/transforms/normalize-test.js +++ b/test/transforms/normalize-test.js @@ -43,6 +43,6 @@ it("Plot.normalize deviation doesn’t crash on equal values", () => { function testNormalize(data, basis, r) { const mark = Plot.dot(data, Plot.normalizeY(basis, {y: data})); - const c = new Map(mark.initialize().channels); - assert.deepStrictEqual(c.get("y").value, r); + const {channels: {y: {value: Y}}} = mark.initialize(); + assert.deepStrictEqual(Y, r); } diff --git a/test/transforms/reduce-test.js b/test/transforms/reduce-test.js index c57c6af9df..b1480407c2 100644 --- a/test/transforms/reduce-test.js +++ b/test/transforms/reduce-test.js @@ -20,6 +20,6 @@ it("function reducers reduce as expected", () => { function testReducer(data, x, r) { const mark = Plot.dot(data, Plot.groupZ({x}, {x: d => d})); - const c = new Map(mark.initialize().channels); - assert.deepStrictEqual(c.get("x").value, [r]); + const {channels: {x: {value: X}}} = mark.initialize(); + assert.deepStrictEqual(X, [r]); } diff --git a/yarn.lock b/yarn.lock index 4f6392b915..17d49151bf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -536,6 +536,11 @@ d3-geo@3: dependencies: d3-array "2.5.0 - 3" +d3-hexbin@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/d3-hexbin/-/d3-hexbin-0.2.2.tgz#9c5837dacfd471ab05337a9e91ef10bfc4f98831" + integrity sha1-nFg32s/UcasFM3qeke8Qv8T5iDE= + d3-hierarchy@3: version "3.1.1" resolved "https://registry.yarnpkg.com/d3-hierarchy/-/d3-hierarchy-3.1.1.tgz#9cbb0ffd2375137a351e6cfeed344a06d4ff4597"