@@ -424,6 +424,8 @@ Plot.plot({
### Sort options
+#### Ordinal domain sorting
+
If an ordinal scale’s domain is not set, it defaults to natural ascending order; to order the domain by associated values in another dimension, either compute the domain manually (consider [d3.groupSort](https://github.com/d3/d3-array/blob/main/README.md#groupSort)) or use an associated mark’s **sort** option. For example, to sort bars by ascending frequency rather than alphabetically by letter:
```js
@@ -462,6 +464,20 @@ If the input channel is *data*, then the reducer is passed groups of the mark’
Note: when the value of the sort option is a string or a function, it is interpreted as a [basic sort transform](#transforms). To use both sort options and a sort transform, use [Plot.sort](#plotsortorder-options).
+#### Index sorting
+
+In addition to the [sort transform](#transforms) which allow sorting by data, you can use the **sort** option to sort marks by some other channel value. For example, to sort dots by descending radius:
+
+```js
+Plot.dot(earthquakes, {x: "longitude", y: "latitude", r: "intensity", sort: {channel: "r", reverse: true}})
+```
+
+In fact, sorting by descending radius is the default behavior of the dot mark when an *r* channel is specified. You can disable this by setting the sort explicitly to null:
+
+```js
+Plot.dot(earthquakes, {x: "longitude", y: "latitude", r: "intensity", sort: null})
+```
+
### Facet options
The *facet* option enables [faceting](https://observablehq.com/@observablehq/plot-facets). When faceting, two additional band scales may be configured:
@@ -717,8 +733,8 @@ The rectangular marks ([bar](#bar), [cell](#cell), and [rect](#rect)) support in
* **insetRight** - inset the right edge
* **insetBottom** - inset the bottom edge
* **insetLeft** - inset the left edge
-* **rx** - the [*x*-radius](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/rx) for rounded corners
-* **ry** - the [*y*-radius](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/ry) for rounded corners
+* **rx** - the [*x* radius](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/rx) for rounded corners
+* **ry** - the [*y* radius](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/ry) for rounded corners
Insets are specified in pixels. Corner radii are specified in either pixels or percentages (strings). Both default to zero. Insets are typically used to ensure a one-pixel gap between adjacent bars; note that the [bin transform](#bin) provides default insets, and that the [band scale padding](#position-options) defaults to 0.1, which also provides separation.
@@ -1037,6 +1053,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
[
](https://observablehq.com/@observablehq/plot-image)
@@ -1433,7 +1457,7 @@ The **filter** transform is similar to filtering the data with [*array*.filter](
Plot.barY(alphabet.filter(d => /[aeiou]/i.test(d.letter)), {x: "letter", y: "frequency"})
```
-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.
+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](#custom-transforms):
@@ -1443,7 +1467,7 @@ The basic transforms are composable: the *filter* transform is applied first, th
Plot’s option transforms, listed below, do more than populate the **transform** function: they derive new mark options and channels. These transforms take a mark’s *options* object (and possibly transform-specific options as the first argument) and return a new, transformed, *options*. Option transforms are composable: you can pass an *options* objects through more than one transform before passing it to a mark. You can also reuse the same transformed *options* on multiple marks.
-The *filter*, *sort* and *reverse* transforms are also available as functions, allowing the order of operations to be specified explicitly. For example, sorting before binning results in sorted data inside bins, whereas sorting after binning results affects the *z*-order of rendered marks.
+The *filter*, *sort* and *reverse* transforms are also available as functions, allowing the order of operations to be specified explicitly. For example, sorting before binning results in sorted data inside bins, whereas sorting after binning results affects the *z* order of rendered marks.
#### Plot.sort(*order*, *options*)
@@ -1451,7 +1475,7 @@ The *filter*, *sort* and *reverse* transforms are also available as functions, a
Plot.sort("body_mass_g", options) // show data in ascending body mass order
```
-Sorts the data by the specified *order*, which can be an accessor function, a comparator function, or a channel value definition such as a field name.
+Sorts the data by the specified *order*, which can be an accessor function, a comparator function, or a channel value definition such as a field name. See also [index sorting](#index-sorting), which allows marks to be sorted by a named channel, such as *r* for radius.
#### Plot.shuffle(*options*)
@@ -1524,12 +1548,12 @@ 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*)
-* *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*)
-* *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*)
+* *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 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
* an object with a *reduce* method, and optionally a *scope*
@@ -1944,7 +1968,7 @@ The following **order** methods are supported:
- a named field or function of data - order data by priority
- an array of *z* values
-The **reverse** option reverses the effective order. For the *value* order, Plot.stackY uses the *y*-value while Plot.stackX uses the *x*-value. For the *appearance* order, Plot.stackY uses the *x*-position of the maximum *y*-value while Plot.stackX uses the *y*-position of the maximum *x*-value. If an array of *z* values are specified, they should enumerate the *z* values for all series in the desired order; this array is typically hard-coded or computed with [d3.groupSort](https://github.com/d3/d3-array/blob/main/README.md#groupSort). Note that the input order (null) and *value* order can produce crossing paths: they do not guarantee a consistent series order across stacks.
+The **reverse** option reverses the effective order. For the *value* order, Plot.stackY uses the *y* value while Plot.stackX uses the *x* value. For the *appearance* order, Plot.stackY uses the *x* position of the maximum *y* value while Plot.stackX uses the *y* position of the maximum *x* value. If an array of *z* values are specified, they should enumerate the *z* values for all series in the desired order; this array is typically hard-coded or computed with [d3.groupSort](https://github.com/d3/d3-array/blob/main/README.md#groupSort). Note that the input order (null) and *value* order can produce crossing paths: they do not guarantee a consistent series order across stacks.
The stack transform supports diverging stacks: negative values are stacked below zero while positive values are stacked above zero. For Plot.stackY, the **y1** channel contains the value of lesser magnitude (closer to zero) while the **y2** channel contains the value of greater magnitude (farther from zero); the difference between the two corresponds to the input **y** channel value. For Plot.stackX, the same is true, except for **x1**, **x2**, and **x** respectively.
@@ -2145,6 +2169,72 @@ This helper for constructing derived columns returns a [*column*, *setColumn*] a
Plot.column is typically used by options transforms to define new channels; the associated columns are populated (derived) when the **transform** option function is invoked.
+## Initializers
+
+Initializers can be used to transform and derive new channels prior to rendering. Unlike transforms which operate in abstract data space, initializers can operate in screen space such as pixel coordinates and colors. For example, initializers can modify a marks’ positions to avoid occlusion. Initializers are invoked *after* the initial scales are constructed and can modify the channels or derive new channels; these in turn may (or may not, as desired) be passed to scales.
+
+### Dodge
+
+[Source](./src/transforms/dodge.js) · [Examples](https://observablehq.com/@observablehq/plot-dodge) · The dodge transform can be applied to any mark that consumes *x* or *y* channels, such as the [dot](#dot), [image](#image), [text](#text), and [vector](#vector) marks. 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 dodge anchor; defaults to *left* for dodgeX, or *bottom* for dodgeY
+
+The **anchor** option may one of *middle*, *right*, and *left* for dodgeX, and one of *middle*, *top*, and *bottom* 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.
+
+#### Plot.dodgeY([*layoutOptions*, ]*options*)
+
+```js
+Plot.dodgeY({x: "date"})
+```
+
+Given marks arranged along the *x* axis, the dodgeY transform piles them vertically by defining a *y* position channel that avoids overlapping. The *x* position channel is unchanged.
+
+#### Plot.dodgeX([*layoutOptions*, ]*options*)
+
+```js
+Plot.dodgeX({y: "value"})
+```
+
+Equivalent to Plot.dodgeY, but piling horizontally, creating a new *x* position channel that avoids overlapping. The *y* position channel is unchanged.
+
+### Hexbin
+
+[Source](./src/transforms/hexbin.js) · [Examples](https://observablehq.com/@observablehq/plot-hexbin) · The hexbin transform can be applied to any mark that consumes *x* and *y*, such as the [dot](#dot), [image](#image), [text](#text), and [vector](#vector) marks. It aggregates values into hexagonal bins of the given **binWidth** (in pixels) and computes new position channels *x* and *y* as the centers of each bin. It can also create new channels by applying a specified reducer to each bin, such as the *count* of elements in the bin.
+
+#### Plot.hexbin(*outputs*, *options*)
+
+Aggregates the given input channels into hexagonal bins, creating output channels with the reduced data. The *options* must specify the **x** and **y** channels. The **binWidth** option (default 20) defines the distance between centers of neighboring hexagons in pixels. 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 the [bin transform](#bin); 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 channel, bins are returned in order of descending radius.
+
+See also the [hexgrid](#hexgrid) mark.
+
+### Custom initializers
+
+You can specify a custom initializer by specifying a function as the mark *initializer* option. This function is called after the scales have been computed, and receives as inputs the (possibly transformed) array of *data*, the *facets* index of elements of this array that belong to each facet, the input *channels* (as an object of named channels), the *scales*, and the *dimensions*. The mark itself is the *this* context. The initializer function must return an object with *data*, *facets*, and new *channels*. Any new channels are merged with existing channels, replacing channels of the same name.
+
## 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 afd54781ee..f9ce070949 100644
--- a/package.json
+++ b/package.json
@@ -35,6 +35,7 @@
},
"sideEffects": false,
"devDependencies": {
+ "@rollup/plugin-commonjs": "22",
"@rollup/plugin-json": "4",
"@rollup/plugin-node-resolve": "13",
"canvas": "2",
@@ -42,7 +43,7 @@
"htl": "0.3",
"js-beautify": "1",
"jsdom": "19",
- "mocha": "9",
+ "mocha": "10",
"module-alias": "2",
"rollup": "2",
"rollup-plugin-terser": "7",
@@ -50,6 +51,7 @@
},
"dependencies": {
"d3": "^7.3.0",
+ "interval-tree-1d": "1",
"isoformat": "0.2"
},
"engines": {
diff --git a/rollup.config.js b/rollup.config.js
index f2cded1105..ae8f8778b5 100644
--- a/rollup.config.js
+++ b/rollup.config.js
@@ -1,5 +1,6 @@
import fs from "fs";
import {terser} from "rollup-plugin-terser";
+import commonjs from "@rollup/plugin-commonjs";
import json from "@rollup/plugin-json";
import node from "@rollup/plugin-node-resolve";
import * as meta from "./package.json";
@@ -25,6 +26,7 @@ const config = {
banner: `// ${meta.name} v${meta.version} Copyright ${copyrights.join(", ")}`
},
plugins: [
+ commonjs(),
json(),
node()
]
diff --git a/src/channel.js b/src/channel.js
index d44638ddd8..9f013238cd 100644
--- a/src/channel.js
+++ b/src/channel.js
@@ -1,7 +1,9 @@
import {ascending, descending, rollup, sort} from "d3";
+import {ascendingDefined, descendingDefined} from "./defined.js";
import {first, labelof, map, maybeValue, range, valueof} from "./options.js";
import {registry} from "./scales/index.js";
import {maybeReduce} from "./transforms/group.js";
+import {composeInitializer} from "./transforms/initializer.js";
// TODO Type coercion?
export function Channel(data, {scale, type, value, filter, hint}) {
@@ -15,19 +17,41 @@ export function Channel(data, {scale, type, value, filter, hint}) {
};
}
-export function channelSort(channels, facetChannels, data, options) {
+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 channelDomain(channels, facetChannels, data, options) {
const {reverse: defaultReverse, reduce: defaultReduce = true, limit: defaultLimit} = options;
for (const x in options) {
- if (!registry.has(x)) continue; // ignore unknown scale keys
+ if (!registry.has(x)) continue; // ignore unknown scale keys (including generic 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 +63,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 +73,26 @@ export function channelSort(channels, facetChannels, data, options) {
}
}
+function sortInitializer(name, compare = ascendingDefined) {
+ return (data, facets, {[name]: V}) => {
+ if (!V) throw new Error(`missing channel: ${name}`);
+ V = V.value;
+ const compareValue = (i, j) => compare(V[i], V[j]);
+ return {facets: facets.map(I => I.slice().sort(compareValue))};
+ };
+}
+
+export function channelSort(initializer, {channel, reverse}) {
+ return composeInitializer(initializer, sortInitializer(channel, reverse ? descendingDefined : ascendingDefined));
+}
+
+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 +100,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/defined.js b/src/defined.js
index 7536baa21d..fe821d6f60 100644
--- a/src/defined.js
+++ b/src/defined.js
@@ -27,11 +27,3 @@ export function positive(x) {
export function negative(x) {
return x < 0 && isFinite(x) ? x : NaN;
}
-
-export function firstof(...values) {
- for (const v of values) {
- if (v !== undefined) {
- return v;
- }
- }
-}
diff --git a/src/index.js b/src/index.js
index 671cc22af8..fb0d9d2214 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,7 +19,10 @@ export {Vector, vector, vectorX, vectorY} from "./marks/vector.js";
export {valueof, column} from "./options.js";
export {filter, reverse, sort, shuffle, basic as transform} from "./transforms/basic.js";
export {bin, binX, binY} from "./transforms/bin.js";
+export {dodgeX, dodgeY} from "./transforms/dodge.js";
export {group, groupX, groupY, groupZ} from "./transforms/group.js";
+export {hexbin} from "./transforms/hexbin.js";
+export {initializer} from "./transforms/initializer.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..5d87b11370 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",
@@ -26,7 +27,7 @@ export class Dot extends Mark {
{name: "rotate", value: vrotate, optional: true},
{name: "symbol", value: vsymbol, scale: "symbol", optional: true}
],
- options,
+ vr === undefined || options.sort !== undefined ? options : {...options, sort: {channel: "r", reverse: true}},
defaults
);
this.r = cr;
@@ -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..4fbb9f2244
--- /dev/null
+++ b/src/marks/hexgrid.js
@@ -0,0 +1,46 @@
+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";
+import {ox, oy} from "../transforms/hexbin.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({binWidth = 20, clip = true, ...options} = {}) {
+ super(undefined, undefined, {clip, ...options}, defaults);
+ this.binWidth = number(binWidth);
+ }
+ render(index, scales, channels, dimensions) {
+ const {dx, dy, binWidth} = this;
+ const {marginTop, marginRight, marginBottom, marginLeft, width, height} = dimensions;
+ const x0 = marginLeft - ox, x1 = width - marginRight - ox, y0 = marginTop - oy, y1 = height - marginBottom - oy;
+ const rx = binWidth / 2, 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 + ox, offset + dy + oy)
+ .attr("d", m.join("")))
+ .node();
+ }
+}
diff --git a/src/options.js b/src/options.js
index adc602f504..5e879dd62e 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);
@@ -23,6 +21,7 @@ export const indexOf = (d, i) => i;
export const identity = {transform: d => d};
export const zero = () => 0;
export const one = () => 1;
+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;
@@ -319,48 +318,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 bd2d391400..89f0b55f09 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, channelDomain, 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; each mark must be unique");
- 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});
- }
-
- // 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 {data, facets, channels} = mark.initialize(markFacets, facetChannels);
+ applyScaleTransforms(channels, options);
+ stateByMark.set(mark, {data, facets, channels});
}
- 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,37 @@ export function plot(options = {}) {
autoScaleLabels(channelsByScale, scaleDescriptors, axes, dimensions, options);
autoAxisTicks(scaleDescriptors, axes);
+ const {fx, fy} = scales;
+ const fyMargins = fy && {marginTop: 0, marginBottom: 0, height: fy.bandwidth()};
+ const fxMargins = fx && {marginRight: 0, marginLeft: 0, width: fx.bandwidth()};
+ const subdimensions = {...dimensions, ...fxMargins, ...fyMargins};
+
+ // Reinitialize; for deriving channels dependent on other channels.
+ const newByScale = new Set();
+ for (const [mark, state] of stateByMark) {
+ if (mark.initializer != null) {
+ const {facets, channels} = mark.initializer(state.data, state.facets, state.channels, scales, subdimensions);
+ 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;
@@ -126,7 +153,6 @@ export function plot(options = {}) {
.node();
// When faceting, render axes for fx and fy instead of x and y.
- const {fx, fy} = scales;
const axisY = axes[facets !== undefined && fy ? "fy" : "y"];
const axisX = axes[facets !== undefined && fx ? "fx" : "x"];
if (axisY) svg.appendChild(axisY.render(null, scales, dimensions));
@@ -136,9 +162,6 @@ export function plot(options = {}) {
if (facets !== undefined) {
const fyDomain = fy && fy.domain();
const fxDomain = fx && fx.domain();
- const fyMargins = fy && {marginTop: 0, marginBottom: 0, height: fy.bandwidth()};
- const fxMargins = fx && {marginRight: 0, marginLeft: 0, width: fx.bandwidth()};
- const subdimensions = {...dimensions, ...fxMargins, ...fyMargins};
const indexByFacet = facetMap(facetChannels);
facets.forEach(([key], i) => indexByFacet.set(key, i));
const selection = select(svg);
@@ -175,16 +198,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);
}
}
@@ -224,10 +247,11 @@ export function plot(options = {}) {
export class Mark {
constructor(data, channels = [], options = {}, defaults) {
- const {facet = "auto", sort, dx, dy, clip} = options;
+ const {facet = "auto", sort, dx, dy, clip, initializer} = options;
const names = new Set();
this.data = data;
this.sort = isOptions(sort) ? sort : null;
+ this.initializer = this.sort?.channel == null ? initializer : channelSort(initializer, this.sort);
this.facet = facet == null || facet === false ? null : keyword(facet === true ? "include" : facet, "facet", ["auto", "include", "exclude"]);
const {transform} = basic(options);
this.transform = transform;
@@ -249,25 +273,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 (this.sort != null) channelSort(channels, facetChannels, data, this.sort);
- return {index, channels};
+ 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) channelDomain(channels, facetChannels, data, this.sort);
+ return {data, 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 +315,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 +380,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 +403,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 5316f8e285..cbd0925f1b 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, isTemporalString, isNumericString, isScaleOptions, isTypedArray, map, maybeSymbol, order} from "./options.js";
+import {isColor, isEvery, isOrdinal, isFirst, isTemporal, isTemporalString, isNumericString, isScaleOptions, isTypedArray, map, order} from "./options.js";
import {registry, color, position, radius, opacity, symbol, length} from "./scales/index.js";
import {ScaleLinear, ScaleSqrt, ScalePow, ScaleLog, ScaleSymlog, ScaleQuantile, ScaleQuantize, 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 {isSymbol, maybeSymbol} from "./symbols.js";
import {warn} from "./warnings.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; not a function`);
- 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; not a function");
+ 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;
@@ -319,23 +317,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
@@ -373,7 +354,7 @@ function coerceDates(values) {
}
// If the values are specified as a typed array, no coercion is required.
-function coerceNumbers(values) {
+export function coerceNumbers(values) {
return isTypedArray(values) ? values : map(values, coerceNumber, Float64Array);
}
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 9c69c18dcb..5babc5c275 100644
--- a/src/scales/quantitative.js
+++ b/src/scales/quantitative.js
@@ -214,9 +214,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..e35aaec694
--- /dev/null
+++ b/src/symbols.js
@@ -0,0 +1,61 @@
+import {symbolAsterisk, symbolDiamond2, symbolPlus, symbolSquare2, symbolTriangle2, symbolX as symbolTimes} from "d3";
+import {symbolCircle, symbolCross, symbolDiamond, symbolSquare, symbolStar, symbolTriangle, symbolWye} from "d3";
+
+export const sqrt3 = Math.sqrt(3);
+export const sqrt4_3 = 2 / sqrt3;
+
+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/basic.js b/src/transforms/basic.js
index 2f99f3b2e3..008e86a266 100644
--- a/src/transforms/basic.js
+++ b/src/transforms/basic.js
@@ -9,6 +9,7 @@ export function basic({
sort: s1,
reverse: r1,
transform: t1,
+ initializer: i1,
...options
} = {}, t2) {
if (t1 === undefined) { // explicit transform overrides filter, sort, and reverse
@@ -16,9 +17,10 @@ export function basic({
if (s1 != null && !isOptions(s1)) t1 = composeTransform(t1, sortTransform(s1));
if (r1) t1 = composeTransform(t1, reverseTransform);
}
+ if (t2 != null && i1 != null) throw new Error("transforms cannot be applied after initializers");
return {
...options,
- ...isOptions(s1) && {sort: s1},
+ ...(s1 === null || isOptions(s1)) && {sort: s1},
transform: composeTransform(t1, t2)
};
}
@@ -26,9 +28,9 @@ export function basic({
function composeTransform(t1, t2) {
if (t1 == null) return t2 === null ? undefined : t2;
if (t2 == null) return t1 === null ? undefined : t1;
- return (data, facets) => {
- ({data, facets} = t1(data, facets));
- return t2(arrayify(data), facets);
+ return function(data, facets) {
+ ({data, facets} = t1.call(this, data, facets));
+ return t2.call(this, arrayify(data), facets);
};
}
diff --git a/src/transforms/bin.js b/src/transforms/bin.js
index 6691dff201..bd7f5971db 100644
--- a/src/transforms/bin.js
+++ b/src/transforms/bin.js
@@ -97,8 +97,8 @@ function binn(
const [GZ, setGZ] = maybeColumn(z);
const [vfill] = maybeColorChannel(fill);
const [vstroke] = maybeColorChannel(stroke);
- const [GF = fill, setGF] = maybeColumn(vfill);
- const [GS = stroke, setGS] = maybeColumn(vstroke);
+ const [GF, setGF] = maybeColumn(vfill);
+ const [GS, setGS] = maybeColumn(vstroke);
return {
..."z" in inputs && {z: GZ || z},
@@ -109,7 +109,7 @@ function binn(
const Z = valueof(data, z);
const F = valueof(data, vfill);
const S = valueof(data, vstroke);
- const G = maybeSubgroup(outputs, Z, F, S);
+ const G = maybeSubgroup(outputs, {z: Z, fill: F, stroke: S});
const groupFacets = [];
const groupData = [];
const GK = K && setGK([]);
diff --git a/src/transforms/dodge.js b/src/transforms/dodge.js
new file mode 100644
index 0000000000..e711ed42fe
--- /dev/null
+++ b/src/transforms/dodge.js
@@ -0,0 +1,100 @@
+import {max} from "d3";
+import IntervalTree from "interval-tree-1d";
+import {finite, positive} from "../defined.js";
+import {identity, number, valueof} from "../options.js";
+import {coerceNumbers} from "../scales.js";
+import {initializer} from "./initializer.js";
+
+const anchorXLeft = ({marginLeft}) => [1, marginLeft];
+const anchorXRight = ({width, marginRight}) => [-1, width - marginRight];
+const anchorXMiddle = ({width, marginLeft, marginRight}) => [0, (marginLeft + width - marginRight) / 2];
+const anchorYTop = ({marginTop}) => [1, marginTop];
+const anchorYBottom = ({height, marginBottom}) => [-1, height - marginBottom];
+const anchorYMiddle = ({height, marginTop, marginBottom}) => [0, (marginTop + height - marginBottom) / 2];
+
+function maybeAnchor(anchor) {
+ return typeof anchor === "string" ? {anchor} : anchor;
+}
+
+export function dodgeX(dodgeOptions = {}, options = {}) {
+ if (arguments.length === 1) [options, dodgeOptions] = [dodgeOptions, options];
+ let {anchor = "left", padding = 1} = maybeAnchor(dodgeOptions);
+ switch (`${anchor}`.toLowerCase()) {
+ case "left": anchor = anchorXLeft; break;
+ case "right": anchor = anchorXRight; break;
+ case "middle": anchor = anchorXMiddle; break;
+ default: throw new Error(`unknown dodge anchor: ${anchor}`);
+ }
+ return dodge("x", "y", anchor, number(padding), options);
+}
+
+export function dodgeY(dodgeOptions = {}, options = {}) {
+ if (arguments.length === 1) [options, dodgeOptions] = [dodgeOptions, options];
+ let {anchor = "bottom", padding = 1} = maybeAnchor(dodgeOptions);
+ switch (`${anchor}`.toLowerCase()) {
+ case "top": anchor = anchorYTop; break;
+ case "bottom": anchor = anchorYBottom; break;
+ case "middle": anchor = anchorYMiddle; break;
+ default: throw new Error(`unknown dodge anchor: ${anchor}`);
+ }
+ return dodge("y", "x", anchor, number(padding), options);
+}
+
+function dodge(y, x, anchor, padding, options) {
+ return initializer(options, function(data, facets, {[x]: X, r: R}, scales, dimensions) {
+ if (!X) throw new Error(`missing channel: ${x}`);
+ X = coerceNumbers(valueof(X.value, X.scale !== undefined ? scales[X.scale] : identity));
+ const r = R ? undefined : this.r !== undefined ? this.r : options.r !== undefined ? number(options.r) : 3;
+ if (R) R = coerceNumbers(valueof(R.value, R.scale !== undefined ? scales[R.scale] : identity));
+ let [ky, ty] = anchor(dimensions);
+ const compare = ky ? compareAscending : compareSymmetric;
+ if (ky) ty += ky * ((R ? max(facets.flat(), i => R[i]) : r) + padding); else ky = 1;
+ const Y = new Float64Array(X.length);
+ const radius = R ? i => R[i] : () => r;
+ for (let I of facets) {
+ const tree = IntervalTree();
+ I = I.filter(R ? i => finite(X[i]) && positive(R[i]) : i => finite(X[i]));
+ for (const i of I) {
+ const intervals = [];
+ const l = X[i] - radius(i);
+ const h = X[i] + radius(i);
+
+ // For any previously placed circles that may overlap this circle, compute
+ // the y-positions that place this circle tangent to these other circles.
+ // https://observablehq.com/@mbostock/circle-offset-along-line
+ tree.queryInterval(l - padding, h + padding, ([,, j]) => {
+ const yj = Y[j];
+ const dx = X[i] - X[j];
+ const dr = padding + (R ? R[i] + R[j] : 2 * r);
+ const dy = Math.sqrt(dr * dr - dx * dx);
+ intervals.push([yj - dy, yj + dy]);
+ });
+
+ // Find the best y-value where this circle can fit.
+ for (let y of intervals.flat().sort(compare)) {
+ if (intervals.every(([lo, hi]) => y <= lo || y >= hi)) {
+ Y[i] = y;
+ break;
+ }
+ }
+
+ // Insert the placed circle into the interval tree.
+ tree.insert([l, h, i]);
+ }
+ for (const i of I) Y[i] = Y[i] * ky + ty;
+ }
+ return {data, facets, channels: {
+ [x]: {value: X},
+ [y]: {value: Y},
+ ...R && {r: {value: R}}
+ }};
+ });
+}
+
+function compareSymmetric(a, b) {
+ return Math.abs(a) - Math.abs(b);
+}
+
+function compareAscending(a, b) {
+ return (a < 0) - (b < 0) || (a - b);
+}
diff --git a/src/transforms/group.js b/src/transforms/group.js
index 0e188cf1ec..f0758b32e3 100644
--- a/src/transforms/group.js
+++ b/src/transforms/group.js
@@ -1,5 +1,5 @@
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 {ascendingDefined} from "../defined.js";
import {valueof, maybeColorChannel, maybeInput, maybeTuple, maybeColumn, column, first, identity, take, labelof, range, second, percentile} from "../options.js";
import {basic} from "./basic.js";
@@ -68,8 +68,8 @@ function groupn(
const [GZ, setGZ] = maybeColumn(z);
const [vfill] = maybeColorChannel(fill);
const [vstroke] = maybeColorChannel(stroke);
- const [GF = fill, setGF] = maybeColumn(vfill);
- const [GS = stroke, setGS] = maybeColumn(vstroke);
+ const [GF, setGF] = maybeColumn(vfill);
+ const [GS, setGS] = maybeColumn(vstroke);
return {
..."z" in inputs && {z: GZ || z},
@@ -81,7 +81,7 @@ function groupn(
const Z = valueof(data, z);
const F = valueof(data, vfill);
const S = valueof(data, vstroke);
- const G = maybeSubgroup(outputs, Z, F, S);
+ const G = maybeSubgroup(outputs, {z: Z, fill: F, stroke: S});
const groupFacets = [];
const groupData = [];
const GX = X && setGX([]);
@@ -226,12 +226,13 @@ export function maybeReduce(reduce, value) {
throw new Error(`invalid reduce: ${reduce}`);
}
-export function maybeSubgroup(outputs, Z, F, S) {
- return firstof(
- outputs.some(o => o.name === "z") ? undefined : Z,
- outputs.some(o => o.name === "fill") ? undefined : F,
- outputs.some(o => o.name === "stroke") ? undefined : S
- );
+export function maybeSubgroup(outputs, inputs) {
+ for (const name in inputs) {
+ const value = inputs[name];
+ if (value !== undefined && !outputs.some(o => o.name === name)) {
+ return value;
+ }
+ }
}
export function maybeSort(facets, sort, reverse) {
diff --git a/src/transforms/hexbin.js b/src/transforms/hexbin.js
new file mode 100644
index 0000000000..0550044aaf
--- /dev/null
+++ b/src/transforms/hexbin.js
@@ -0,0 +1,124 @@
+import {coerceNumbers} from "../scales.js";
+import {sqrt3} from "../symbols.js";
+import {identity, isNoneish, number, valueof} from "../options.js";
+import {hasOutput, maybeGroup, maybeOutputs, maybeSubgroup} from "./group.js";
+import {initializer} from "./initializer.js";
+
+// We don’t want the hexagons to align with the edges of the plot frame, as that
+// would cause extreme x-values (the upper bound of the default x-scale domain)
+// to be rounded up into a floating bin to the right of the plot. Therefore,
+// rather than centering the origin hexagon around ⟨0,0⟩ in screen coordinates,
+// we offset slightly to ⟨0.5,0⟩. The hexgrid mark uses the same origin.
+export const ox = 0.5, oy = 0;
+
+// TODO filter e.g. to show empty hexbins?
+// TODO disallow x, x1, x2, y, y1, y2 reducers?
+export function hexbin(outputs = {fill: "count"}, inputs = {}) {
+ let {binWidth, ...options} = inputs;
+ binWidth = binWidth === undefined ? 20 : number(binWidth);
+ outputs = maybeOutputs(outputs, options);
+
+ // A fill output means a fill channel, and hence the stroke should default to
+ // none (assuming a mark that defaults to fill and no stroke, such as dot).
+ // Note that it’s safe to mutate options here because we just created it with
+ // the rest operator above.
+ const {z, fill, stroke} = options;
+ if (stroke === undefined && isNoneish(fill) && hasOutput(outputs, "fill")) options.stroke = "none";
+
+ // Populate default values for the r and symbol options, as appropriate.
+ if (options.symbol === undefined) options.symbol = "hexagon";
+ if (options.r === undefined && !hasOutput(outputs, "r")) options.r = binWidth / 2;
+
+ return initializer(options, (data, facets, {x: X, y: Y, z: Z, fill: F, stroke: S, symbol: Q}, scales) => {
+ if (X === undefined) throw new Error("missing channel: x");
+ if (Y === undefined) throw new Error("missing channel: y");
+
+ // Coerce the X and Y channels to numbers (so that null is properly treated
+ // as an undefined value rather than being coerced to zero).
+ X = coerceNumbers(valueof(X.value, X.scale !== undefined ? scales[X.scale] : identity));
+ Y = coerceNumbers(valueof(Y.value, Y.scale !== undefined ? scales[Y.scale] : identity));
+
+ // Extract the values for channels that are eligible for grouping; not all
+ // marks define a z channel, so compute one if it not already computed. If z
+ // was explicitly set to null, ensure that we don’t subdivide bins.
+ Z = Z ? Z.value : valueof(data, z);
+ F = F?.value;
+ S = S?.value;
+ Q = Q?.value;
+
+ // Group on the first of z, fill, stroke, and symbol. Implicitly reduce
+ // these channels using the first corresponding value for each bin.
+ const G = maybeSubgroup(outputs, {z: Z, fill: F, stroke: S, symbol: Q});
+ const GZ = Z && [];
+ const GF = F && [];
+ const GS = S && [];
+ const GQ = Q && [];
+
+ // Construct the hexbins and populate the output channels.
+ const binFacets = [];
+ const BX = [];
+ const BY = [];
+ let i = -1;
+ for (const o of outputs) o.initialize(data);
+ for (const facet of facets) {
+ const binFacet = [];
+ for (const o of outputs) o.scope("facet", facet);
+ for (const [f, I] of maybeGroup(facet, G)) {
+ for (const bin of hbin(I, X, Y, binWidth)) {
+ binFacet.push(++i);
+ BX.push(bin.x);
+ BY.push(bin.y);
+ if (Z) GZ.push(G === Z ? f : Z[bin[0]]);
+ if (F) GF.push(G === F ? f : F[bin[0]]);
+ if (S) GS.push(G === S ? f : S[bin[0]]);
+ if (Q) GQ.push(G === Q ? f : Q[bin[0]]);
+ for (const o of outputs) o.reduce(bin);
+ }
+ }
+ binFacets.push(binFacet);
+ }
+
+ // Construct the output channels, and populate the radius scale hint.
+ const channels = {
+ x: {value: BX},
+ y: {value: BY},
+ ...Z && {z: {value: GZ}},
+ ...F && {fill: {value: GF, scale: true}},
+ ...S && {stroke: {value: GS, scale: true}},
+ ...Q && {symbol: {value: GQ, scale: true}},
+ ...Object.fromEntries(outputs.map(({name, output}) => [name, {scale: true, radius: name === "r" ? binWidth / 2 : undefined, value: output.transform()}]))
+ };
+
+ return {data, facets: binFacets, channels};
+ });
+}
+
+function hbin(I, X, Y, dx) {
+ const dy = dx * (1.5 / sqrt3);
+ const bins = new Map();
+ for (const i of I) {
+ let px = X[i],
+ py = Y[i];
+ if (isNaN(px) || isNaN(py)) continue;
+ let pj = Math.round(py = (py - oy) / dy),
+ pi = Math.round(px = (px - ox) / dx - (pj & 1) / 2),
+ py1 = py - pj;
+ if (Math.abs(py1) * 3 > 1) {
+ let px1 = px - pi,
+ pi2 = pi + (px < pi ? -1 : 1) / 2,
+ pj2 = pj + (py < pj ? -1 : 1),
+ px2 = px - pi2,
+ py2 = py - pj2;
+ if (px1 * px1 + py1 * py1 > px2 * px2 + py2 * py2) pi = pi2 + (pj & 1 ? 1 : -1) / 2, pj = pj2;
+ }
+ const key = `${pi},${pj}`;
+ let bin = bins.get(key);
+ if (bin === undefined) {
+ bins.set(key, bin = []);
+ bin.x = (pi + (pj & 1) / 2) * dx + ox;
+ bin.y = pj * dy + oy;
+ }
+ bin.push(i);
+ }
+ return bins.values();
+}
diff --git a/src/transforms/initializer.js b/src/transforms/initializer.js
new file mode 100644
index 0000000000..f5646d2808
--- /dev/null
+++ b/src/transforms/initializer.js
@@ -0,0 +1,19 @@
+// If both i1 and i2 are defined, returns a composite initializer that first
+// applies i1 and then applies i2.
+export function initializer({initializer: i1, ...options} = {}, i2) {
+ return {
+ ...options,
+ initializer: composeInitializer(i1, i2)
+ };
+}
+
+export function composeInitializer(i1, i2) {
+ if (i1 == null) return i2 === null ? undefined : i2;
+ if (i2 == null) return i1 === null ? undefined : i1;
+ return function(data, facets, channels, scales, dimensions) {
+ let c1, c2;
+ ({data, facets, channels: c1} = i1.call(this, data, facets, channels, scales, dimensions));
+ ({data, facets, channels: c2} = i2.call(this, data, facets, {...channels, ...c1}, scales, dimensions));
+ return {data, facets, channels: {...c1, ...c2}};
+ };
+}
diff --git a/test/output/aaplChangeVolume.svg b/test/output/aaplChangeVolume.svg
index 7055f1a5eb..c578b5d48c 100644
--- a/test/output/aaplChangeVolume.svg
+++ b/test/output/aaplChangeVolume.svg
@@ -109,1265 +109,1265 @@