From 18d5ec1cd5e26caac0d8e1b90fd23aebf6240590 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 22 Mar 2022 11:23:15 +0100 Subject: [PATCH 01/11] document layouts (as "scale-aware transforms") --- README.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f4bdbad1a6..284b3579b5 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 *radius* option specifies the radius of the hexagonal mesh, in pixels (defaults to 10). 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 *radius* in pixels of the hexagonal lattice (defaults to 10). 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 transsforms + +When its *options* have an *initialize* property, the initialize function is called after the data has been faceted and scaled; it receives as inputs the index of the elements to layout, the scales descriptors, the values (the scaled channels as a key: array object), the dimensions, and the mark as this. It must return the index, values, 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). From 0d770be9a41531ae3888428b38923ee709cc2d3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 22 Mar 2022 12:19:06 +0100 Subject: [PATCH 02/11] document binWidth --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 284b3579b5..1fbcc293f7 100644 --- a/README.md +++ b/README.md @@ -956,7 +956,7 @@ The hexgrid mark can be used to support marks using the [hexbin](#hexbin) layout #### Plot.hexgrid([*options*]) -The *radius* option specifies the radius of the hexagonal mesh, in pixels (defaults to 10). The *clip* option defaults to true, clipping the mark to the frame’s dimensions. +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 @@ -1979,7 +1979,7 @@ The hexbin transform can be applied to any mark that consumes *x* and *y*, such #### 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 *radius* in pixels of the hexagonal lattice (defaults to 10). 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. +[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: From f6421fd8cd6383644747639080216a8f1e9d1fc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 24 Mar 2022 14:09:30 +0100 Subject: [PATCH 03/11] document the initialize option after 42ac4f08093c5819e9fa2dcecf3e09e12785990e --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1fbcc293f7..c3707517cd 100644 --- a/README.md +++ b/README.md @@ -2006,9 +2006,9 @@ When the hexbin transform has an *r* output, the bins are returned in decreasing See also the [hexgrid](#hexgrid) mark. -### Custom scale-aware transsforms +### Custom scale-aware transforms -When its *options* have an *initialize* property, the initialize function is called after the data has been faceted and scaled; it receives as inputs the index of the elements to layout, the scales descriptors, the values (the scaled channels as a key: array object), the dimensions, and the mark as this. It must return the index, values, and the channels that need to be scaled in a second pass. +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 From 526375e7925d269b78dac07a34c2d63248f9cd92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 15 Mar 2022 14:50:30 +0100 Subject: [PATCH 04/11] sort hex bins by radius (descending) group by z inline hexbin binWidth is the distance between two centers (rebased on mbostock/reinitialize) --- package.json | 1 - src/marks/hexgrid.js | 8 +- src/symbols.js | 3 +- src/transforms/hexbin.js | 104 +++++-- test/output/hexbin.svg | 382 ++++++++++++------------- test/output/hexbinOranges.svg | 151 ++++++++++ test/output/hexbinR.html | 521 ++++++++++++++++++++++++++++++++++ test/output/hexbinSymbol.html | 236 +++++++++++++++ test/output/hexbinText.svg | 195 +++++++++++++ test/output/hexbinZ.svg | 310 ++++++++++++++++++++ test/plots/hexbin-oranges.js | 19 ++ test/plots/hexbin-r.js | 20 ++ test/plots/hexbin-symbol.js | 18 ++ test/plots/hexbin-text.js | 21 ++ test/plots/hexbin-z.js | 22 ++ test/plots/index.js | 5 + yarn.lock | 5 - 17 files changed, 1792 insertions(+), 229 deletions(-) create mode 100644 test/output/hexbinOranges.svg create mode 100644 test/output/hexbinR.html create mode 100644 test/output/hexbinSymbol.html create mode 100644 test/output/hexbinText.svg create mode 100644 test/output/hexbinZ.svg create mode 100644 test/plots/hexbin-oranges.js create mode 100644 test/plots/hexbin-r.js create mode 100644 test/plots/hexbin-symbol.js create mode 100644 test/plots/hexbin-text.js create mode 100644 test/plots/hexbin-z.js diff --git a/package.json b/package.json index 8892247b1a..b4ba6a0d3a 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,6 @@ }, "dependencies": { "d3": "^7.3.0", - "d3-hexbin": "^0.2.2", "isoformat": "0.2" }, "engines": { diff --git a/src/marks/hexgrid.js b/src/marks/hexgrid.js index c18483f3a0..4fbb9f2244 100644 --- a/src/marks/hexgrid.js +++ b/src/marks/hexgrid.js @@ -17,15 +17,15 @@ export function hexgrid(options) { } export class Hexgrid extends Mark { - constructor({radius = 10, clip = true, ...options} = {}) { + constructor({binWidth = 20, clip = true, ...options} = {}) { super(undefined, undefined, {clip, ...options}, defaults); - this.radius = number(radius); + this.binWidth = number(binWidth); } render(index, scales, channels, dimensions) { - const {dx, dy, radius: rx} = this; + 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 ry = rx * sqrt4_3, hy = ry / 2, wx = rx * 2, wy = ry * 1.5; + 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; diff --git a/src/symbols.js b/src/symbols.js index f501631b4c..e35aaec694 100644 --- a/src/symbols.js +++ b/src/symbols.js @@ -1,7 +1,8 @@ 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); +export const sqrt3 = Math.sqrt(3); +export const sqrt4_3 = 2 / sqrt3; const symbolHexagon = { draw(context, size) { diff --git a/src/transforms/hexbin.js b/src/transforms/hexbin.js index 7a6c4503bc..f78e30229b 100644 --- a/src/transforms/hexbin.js +++ b/src/transforms/hexbin.js @@ -1,5 +1,6 @@ -import {hexbin as Hexbin} from "d3-hexbin"; // TODO inline -import {sqrt4_3} from "../symbols.js"; +import {group} from "d3"; +import {sqrt3} from "../symbols.js"; +import {maybeChannel, maybeColorChannel, valueof} from "../options.js"; import {hasOutput, maybeOutputs} from "./group.js"; // We don’t want the hexagons to align with the edges of the plot frame, as that @@ -10,52 +11,99 @@ import {hasOutput, maybeOutputs} from "./group.js"; export const ox = 0.5, oy = 0; export function hexbin(outputs = {fill: "count"}, options = {}) { - const {radius, ...rest} = outputs; - return hexbinn(rest, {radius, ...options}); + const {binWidth, ...rest} = outputs; + return hexbinn(rest, {binWidth, ...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); +function hexbinn(outputs, {binWidth = 20, fill, stroke, z, ...options}) { + binWidth = +binWidth; + const [GZ, setGZ] = maybeChannel(z); + const [vfill] = maybeColorChannel(fill); + const [vstroke] = maybeColorChannel(stroke); + const [GF = fill, setGF] = maybeChannel(vfill); + const [GS = stroke, setGS] = maybeChannel(vstroke); + outputs = maybeOutputs({ + ...setGF && {fill: "first"}, + ...setGS && {stroke: "first"}, + ...outputs + }, {fill, stroke, ...options}); return { symbol: "hexagon", - ...!hasOutput(outputs, "r") && {r: radius}, - ...hasOutput(outputs, "fill") && {stroke: "none"}, + ...!hasOutput(outputs, "r") && {r: binWidth / 2}, + ...!setGF && {fill}, + ...((hasOutput(outputs, "fill") || setGF) && stroke === undefined) ? {stroke: "none"} : {stroke}, ...options, initialize(data, facets, {x: X, y: Y}, {x, y}) { + if (setGF) setGF(valueof(data, vfill)); + if (setGS) setGS(valueof(data, vstroke)); + if (setGZ) setGZ(valueof(data, z)); + for (const o of outputs) o.initialize(data); if (X === undefined) throw new Error("missing channel: x"); if (Y === undefined) throw new Error("missing channel: y"); - X = X.value; - Y = Y.value; - const binsof = Hexbin().x(i => x(X[i]) - ox).y(i => y(Y[i]) - oy).radius(radius * sqrt4_3); + X = X.value.map(x); + Y = Y.value.map(y); + const F = setGF && GF.transform(); + const S = setGS && GS.transform(); + const Z = setGZ ? GZ.transform() : (F || S); const binFacets = []; const BX = []; const BY = []; - let i = 0; - for (const o of outputs) o.initialize(data); + let i = -1; 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 + ox); - BY.push(bin.y + oy); - for (const o of outputs) o.reduce(bin); + for (const index of Z ? group(facet, i => Z[i]).values() : [facet]) { + for (const bin of hbin(index, X, Y, binWidth)) { + 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()}])) - } + const channels = { + x: {value: BX}, + y: {value: BY}, + ...Object.fromEntries(outputs.map(({name, output}) => [name, {scale: true, binWidth: name === "r" ? binWidth : undefined, value: output.transform()}])) }; + if ("r" in channels) { + const R = channels.r.value; + binFacets.forEach(index => index.sort((i, j) => R[j] - R[i])); + } + return {facets: binFacets, channels}; } }; } + +function hbin(I, X, Y, dx) { + const dy = dx * sqrt3 / 2; + const bins = new Map(); + for (const i of I) { + let px = X[i] / dx; + let py = Y[i] / dy; + if (isNaN(px) || isNaN(py)) continue; + let pj = Math.round(py), + pi = Math.round(px = px - (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 g = bins.get(key); + if (g === undefined) { + bins.set(key, g = []); + g.x = (pi + (pj & 1) / 2) * dx; + g.y = pj * dy; + } + g.push(i); + } + return bins.values(); +} diff --git a/test/output/hexbin.svg b/test/output/hexbin.svg index 1165776539..b600b89934 100644 --- a/test/output/hexbin.svg +++ b/test/output/hexbin.svg @@ -88,195 +88,197 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/hexbinOranges.svg b/test/output/hexbinOranges.svg new file mode 100644 index 0000000000..a855e5439e --- /dev/null +++ b/test/output/hexbinOranges.svg @@ -0,0 +1,151 @@ + + + + + 35 + + + 40 + + + 45 + + + 50 + + + 55 + ↑ culmen_length_mm + + + + 14 + + + 15 + + + 16 + + + 17 + + + 18 + + + 19 + + + 20 + + + 21 + culmen_depth_mm → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/hexbinR.html b/test/output/hexbinR.html new file mode 100644 index 0000000000..be11949cf5 --- /dev/null +++ b/test/output/hexbinR.html @@ -0,0 +1,521 @@ +
+ + + + + 0 + + + 5 + + + 10 + + + 15 + + + 20 + Proportion of each facet (%) + + + + + + 35 + + + 40 + + + 45 + + + 50 + + + 55 + ↑ culmen_length_mm + + + + FEMALE + + + MALE + + + + sex + + + + 14 + + + 16 + + + 18 + + + 20 + + + + + 14 + + + 16 + + + 18 + + + 20 + + + + + 14 + + + 16 + + + 18 + + + 20 + culmen_depth_mm → + + + + + + 0.067 + + + 0.055 + + + 0.048 + + + 0.048 + + + 0.042 + + + 0.042 + + + 0.042 + + + 0.036 + + + 0.036 + + + 0.03 + + + 0.03 + + + 0.03 + + + 0.03 + + + 0.024 + + + 0.024 + + + 0.024 + + + 0.018 + + + 0.018 + + + 0.018 + + + 0.018 + + + 0.018 + + + 0.018 + + + 0.018 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + + + + + + 0.083 + + + 0.065 + + + 0.048 + + + 0.042 + + + 0.042 + + + 0.03 + + + 0.03 + + + 0.03 + + + 0.03 + + + 0.024 + + + 0.024 + + + 0.024 + + + 0.024 + + + 0.024 + + + 0.018 + + + 0.018 + + + 0.018 + + + 0.018 + + + 0.018 + + + 0.018 + + + 0.018 + + + 0.018 + + + 0.018 + + + 0.018 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + + + + + + 0.182 + + + 0.091 + + + 0.091 + + + 0.091 + + + 0.091 + + + 0.091 + + + 0.091 + + + 0.091 + + + +
\ No newline at end of file diff --git a/test/output/hexbinSymbol.html b/test/output/hexbinSymbol.html new file mode 100644 index 0000000000..11c1d0bb82 --- /dev/null +++ b/test/output/hexbinSymbol.html @@ -0,0 +1,236 @@ +
+
+ + + FEMALE + + MALE +
+ + + + + 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/output/hexbinText.svg b/test/output/hexbinText.svg new file mode 100644 index 0000000000..0e70e704da --- /dev/null +++ b/test/output/hexbinText.svg @@ -0,0 +1,195 @@ + + + + + 35 + + + 40 + + + 45 + + + 50 + + + 55 + ↑ culmen_length_mm + + + + FEMALE + + + MALE + + + + sex + + + + 15 + + + 20 + + + + + 15 + + + 20 + + + + + 15 + + + 20 + culmen_depth_mm → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 729103256610131211311412281224113111611124149142111 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 18212131514179311224111293442123113171034964311112221121 + + + + + + + + + + + + + + 11211111 + + \ No newline at end of file diff --git a/test/output/hexbinZ.svg b/test/output/hexbinZ.svg new file mode 100644 index 0000000000..d2de751545 --- /dev/null +++ b/test/output/hexbinZ.svg @@ -0,0 +1,310 @@ + + + + + 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-oranges.js b/test/plots/hexbin-oranges.js new file mode 100644 index 0000000000..29bb84bdeb --- /dev/null +++ b/test/plots/hexbin-oranges.js @@ -0,0 +1,19 @@ +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({ + color: {scheme: "oranges"}, + inset: 30, + marks: [ + Plot.frame(), + Plot.circle(penguins, Plot.hexbin({fill: "count"}, { + x: "culmen_depth_mm", + y: "culmen_length_mm", + binWidth: 35, + strokeWidth: 1 + })) + ] + }); +} diff --git a/test/plots/hexbin-r.js b/test/plots/hexbin-r.js new file mode 100644 index 0000000000..e63eb72ae5 --- /dev/null +++ b/test/plots/hexbin-r.js @@ -0,0 +1,20 @@ +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({ + width: 820, + height: 320, + color: {scheme: "reds", nice: true, tickFormat: d => 100 * d, label: "Proportion of each facet (%)", legend: true}, + facet: { + data: penguins, + x: "sex", + marginRight: 80 + }, + marks: [ + Plot.frame(), + Plot.dot(penguins, Plot.hexbin({title: "proportion-facet", r: "count", fill: "proportion-facet"}, {x: "culmen_depth_mm", y: "culmen_length_mm", strokeWidth: 1})) + ] + }); +} diff --git a/test/plots/hexbin-symbol.js b/test/plots/hexbin-symbol.js new file mode 100644 index 0000000000..d26da69415 --- /dev/null +++ b/test/plots/hexbin-symbol.js @@ -0,0 +1,18 @@ +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({ + grid: true, + marks: [ + Plot.dot(penguins, Plot.hexbin({r: "count", symbol: "mode"}, { + binWidth: 40, + symbol: "sex", + x: "culmen_depth_mm", + y: "culmen_length_mm" + })) + ], + symbol: {legend: true} + }); +} diff --git a/test/plots/hexbin-text.js b/test/plots/hexbin-text.js new file mode 100644 index 0000000000..8ab4086588 --- /dev/null +++ b/test/plots/hexbin-text.js @@ -0,0 +1,21 @@ +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({ + width: 820, + height: 320, + facet: { + data: penguins, + x: "sex", + marginRight: 80 + }, + inset: 14, + marks: [ + Plot.frame(), + Plot.dot(penguins, Plot.hexbin({fillOpacity: "count"}, {x: "culmen_depth_mm", y: "culmen_length_mm", fill: "brown", stroke: "black", strokeWidth: 0.5})), + Plot.text(penguins, Plot.hexbin({text: "count"}, {x: "culmen_depth_mm", y: "culmen_length_mm"})) + ] + }); +} diff --git a/test/plots/hexbin-z.js b/test/plots/hexbin-z.js new file mode 100644 index 0000000000..0ac30caff3 --- /dev/null +++ b/test/plots/hexbin-z.js @@ -0,0 +1,22 @@ +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({ + x: {inset: 10}, + y: {inset: 10}, + marks: [ + Plot.frame(), + Plot.hexgrid(), + Plot.dot(penguins, Plot.hexbin({r: "count"}, { + x: "culmen_depth_mm", + y: "culmen_length_mm", + strokeWidth: 2, + stroke: "sex", + fill: "sex", + fillOpacity: 0.5 + })) + ] + }); +} diff --git a/test/plots/index.js b/test/plots/index.js index 9fd8c0c3f4..6fb444376c 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -63,6 +63,11 @@ 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 hexbinOranges} from "./hexbin-oranges.js"; +export {default as hexbinR} from "./hexbin-r.js"; +export {default as hexbinSymbol} from "./hexbin-symbol.js"; +export {default as hexbinText} from "./hexbin-text.js"; +export {default as hexbinZ} from "./hexbin-z.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/yarn.lock b/yarn.lock index 17d49151bf..4f6392b915 100644 --- a/yarn.lock +++ b/yarn.lock @@ -536,11 +536,6 @@ 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" From 2c228d2283609e7a9b868ff6f6535a872bccbcb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 24 Mar 2022 14:56:02 +0100 Subject: [PATCH 05/11] dodge rebased on mbostock/reinitialize --- package.json | 2 + rollup.config.js | 2 + src/index.js | 1 + src/plot.js | 11 +- src/transforms/dodge.js | 103 ++++++ test/output/penguinDodge.svg | 383 +++++++++++++++++++ test/output/penguinFacetDodge.svg | 411 +++++++++++++++++++++ test/output/penguinFacetDodgeIsland.html | 446 +++++++++++++++++++++++ test/output/penguinFacetDodgeSymbol.html | 443 ++++++++++++++++++++++ test/plots/index.js | 4 + test/plots/penguin-dodge.js | 12 + test/plots/penguin-facet-dodge-island.js | 22 ++ test/plots/penguin-facet-dodge-symbol.js | 16 + test/plots/penguin-facet-dodge.js | 21 ++ yarn.lock | 63 +++- 15 files changed, 1933 insertions(+), 7 deletions(-) create mode 100644 src/transforms/dodge.js create mode 100644 test/output/penguinDodge.svg create mode 100644 test/output/penguinFacetDodge.svg create mode 100644 test/output/penguinFacetDodgeIsland.html create mode 100644 test/output/penguinFacetDodgeSymbol.html create mode 100644 test/plots/penguin-dodge.js create mode 100644 test/plots/penguin-facet-dodge-island.js create mode 100644 test/plots/penguin-facet-dodge-symbol.js create mode 100644 test/plots/penguin-facet-dodge.js diff --git a/package.json b/package.json index 8892247b1a..31526d6823 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ }, "sideEffects": false, "devDependencies": { + "@rollup/plugin-commonjs": "^21.0.1", "@rollup/plugin-json": "4", "@rollup/plugin-node-resolve": "13", "canvas": "2", @@ -50,6 +51,7 @@ }, "dependencies": { "d3": "^7.3.0", + "interval-tree-1d": "1", "d3-hexbin": "^0.2.2", "isoformat": "0.2" }, 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/index.js b/src/index.js index 90e4bd4ff8..0085e9e542 100644 --- a/src/index.js +++ b/src/index.js @@ -18,6 +18,7 @@ export {Vector, vector, vectorX, vectorY} from "./marks/vector.js"; export {valueof, channel} from "./options.js"; export {filter, reverse, sort, shuffle, basic as transform} from "./transforms/basic.js"; export {bin, binX, binY} from "./transforms/bin.js"; +export {dodgeX, dodgeY} from "./transforms/dodge.js"; export {group, groupX, groupY, groupZ} from "./transforms/group.js"; export {hexbin} from "./transforms/hexbin.js"; export {normalize, normalizeX, normalizeY} from "./transforms/normalize.js"; diff --git a/src/plot.js b/src/plot.js index 7e6eea516d..b3f2d7b1dd 100644 --- a/src/plot.js +++ b/src/plot.js @@ -90,11 +90,16 @@ 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.reinitialize != null) { - const {facets, channels} = mark.reinitialize(state.data, state.facets, state.channels, scales); + const {facets, channels} = mark.reinitialize(state.data, state.facets, state.channels, scales, subdimensions); if (facets !== undefined) state.facets = facets; if (channels !== undefined) { inferChannelScale(channels, mark); @@ -148,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)); @@ -158,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); diff --git a/src/transforms/dodge.js b/src/transforms/dodge.js new file mode 100644 index 0000000000..ca915d09a5 --- /dev/null +++ b/src/transforms/dodge.js @@ -0,0 +1,103 @@ +import {max} from "d3"; +import IntervalTree from "interval-tree-1d"; +import {finite, positive} from "../defined.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, +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, +padding, options); +} + +function dodge(y, x, anchor, padding, options) { + return { + initialize(data, facets, {[x]: X, r: R}, {[x]: xscale, r: rscale}, dimensions) { + if (!X) throw new Error(`missing channel ${x}`); + X = X.value.map(xscale); + const r = R ? undefined : this.r !== undefined ? this.r : options.r !== undefined ? +options.r : 3; + if (R) R = R.value.map(rscale); + if (X == null) throw new Error(`missing channel: ${x}`); + 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 {facets, channels: { + [x]: {value: X}, + [y]: {value: Y}, + ...R && {r: {value: R}} + }}; + }, + ...options + }; +} + +function compareSymmetric(a, b) { + return Math.abs(a) - Math.abs(b); +} + +function compareAscending(a, b) { + return (a < 0) - (b < 0) || (a - b); +} \ No newline at end of file diff --git a/test/output/penguinDodge.svg b/test/output/penguinDodge.svg new file mode 100644 index 0000000000..2292f7bb81 --- /dev/null +++ b/test/output/penguinDodge.svg @@ -0,0 +1,383 @@ + + + + + 3,000 + + + 3,500 + + + 4,000 + + + 4,500 + + + 5,000 + + + 5,500 + + + 6,000 + body_mass_g → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/penguinFacetDodge.svg b/test/output/penguinFacetDodge.svg new file mode 100644 index 0000000000..b81b2ede60 --- /dev/null +++ b/test/output/penguinFacetDodge.svg @@ -0,0 +1,411 @@ + + + + + Adelie + + + Chinstrap + + + Gentoo + + + + + 3,000 + + + + 3,500 + + + + 4,000 + + + + 4,500 + + + + 5,000 + + + + 5,500 + + + + 6,000 + + body_mass_g → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/penguinFacetDodgeIsland.html b/test/output/penguinFacetDodgeIsland.html new file mode 100644 index 0000000000..8a0a5c05df --- /dev/null +++ b/test/output/penguinFacetDodgeIsland.html @@ -0,0 +1,446 @@ +
+
+ BiscoeDreamTorgersen +
+ + + + Adelie + + + Chinstrap + + + Gentoo + + + + + 3,000 + + + + 3,500 + + + + 4,000 + + + + 4,500 + + + + 5,000 + + + + 5,500 + + + + 6,000 + + body_mass_g → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/output/penguinFacetDodgeSymbol.html b/test/output/penguinFacetDodgeSymbol.html new file mode 100644 index 0000000000..0b45525c2e --- /dev/null +++ b/test/output/penguinFacetDodgeSymbol.html @@ -0,0 +1,443 @@ +
+
+ + + Adelie + + Chinstrap + + Gentoo +
+ + + + + 2,500 + + + + 3,000 + + + + 3,500 + + + + 4,000 + + + + 4,500 + + + + 5,000 + + + + 5,500 + + + + 6,000 + + + + 6,500 + ↑ body_mass_g + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/plots/index.js b/test/plots/index.js index 9fd8c0c3f4..b4736c802f 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -102,6 +102,10 @@ export {default as musicRevenue} from "./music-revenue.js"; export {default as ordinalBar} from "./ordinal-bar.js"; export {default as penguinCulmen} from "./penguin-culmen.js"; export {default as penguinCulmenArray} from "./penguin-culmen-array.js"; +export {default as penguinDodge} from "./penguin-dodge.js"; +export {default as penguinFacetDodge} from "./penguin-facet-dodge.js"; +export {default as penguinFacetDodgeIsland} from "./penguin-facet-dodge-island.js"; +export {default as penguinFacetDodgeSymbol} from "./penguin-facet-dodge-symbol.js"; export {default as penguinIslandUnknown} from "./penguin-island-unknown.js"; export {default as penguinMass} from "./penguin-mass.js"; export {default as penguinMassSex} from "./penguin-mass-sex.js"; diff --git a/test/plots/penguin-dodge.js b/test/plots/penguin-dodge.js new file mode 100644 index 0000000000..47fc6ce892 --- /dev/null +++ b/test/plots/penguin-dodge.js @@ -0,0 +1,12 @@ +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({ + height: 200, + marks: [ + Plot.dot(penguins, Plot.dodgeY({x: "body_mass_g"})) + ] + }); +} diff --git a/test/plots/penguin-facet-dodge-island.js b/test/plots/penguin-facet-dodge-island.js new file mode 100644 index 0000000000..4c40151192 --- /dev/null +++ b/test/plots/penguin-facet-dodge-island.js @@ -0,0 +1,22 @@ +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({ + height: 300, + x: { + grid: true + }, + facet: { + data: penguins, + y: "species", + label: null, + marginLeft: 60 + }, + marks: [ + Plot.dot(penguins, Plot.dodgeY("middle", {x: "body_mass_g", fill: "island"})) + ], + color: {legend: true} + }); +} diff --git a/test/plots/penguin-facet-dodge-symbol.js b/test/plots/penguin-facet-dodge-symbol.js new file mode 100644 index 0000000000..a16dcb5737 --- /dev/null +++ b/test/plots/penguin-facet-dodge-symbol.js @@ -0,0 +1,16 @@ +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({ + height: 450, + width: 300, + marginRight: 60, + y: {grid: true, nice: true}, + symbol: {legend: true}, + marks: [ + Plot.dot(penguins, Plot.dodgeX("left", {y: "body_mass_g", symbol: "species", stroke: "species", dx: 2})) + ] + }); +} diff --git a/test/plots/penguin-facet-dodge.js b/test/plots/penguin-facet-dodge.js new file mode 100644 index 0000000000..77ed20a433 --- /dev/null +++ b/test/plots/penguin-facet-dodge.js @@ -0,0 +1,21 @@ +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({ + height: 300, + x: { + grid: true + }, + facet: { + data: penguins, + y: "species", + label: null, + marginLeft: 60 + }, + marks: [ + Plot.dot(penguins, Plot.dodgeY("middle", {x: "body_mass_g"})) + ] + }); +} diff --git a/yarn.lock b/yarn.lock index 17d49151bf..07fcc08aaf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -67,6 +67,19 @@ semver "^7.3.5" tar "^6.1.11" +"@rollup/plugin-commonjs@^21.0.1": + version "21.0.2" + resolved "https://registry.yarnpkg.com/@rollup/plugin-commonjs/-/plugin-commonjs-21.0.2.tgz#0b9c539aa1837c94abfaf87945838b0fc8564891" + integrity sha512-d/OmjaLVO4j/aQX69bwpWPpbvI3TJkQuxoAk7BH8ew1PyoMBLTOuvJTjzG8oEoW7drIIqB0KCJtfFLu/2GClWg== + dependencies: + "@rollup/pluginutils" "^3.1.0" + commondir "^1.0.1" + estree-walker "^2.0.1" + glob "^7.1.6" + is-reference "^1.2.1" + magic-string "^0.25.7" + resolve "^1.17.0" + "@rollup/plugin-json@4": version "4.1.0" resolved "https://registry.yarnpkg.com/@rollup/plugin-json/-/plugin-json-4.1.0.tgz#54e09867ae6963c593844d8bd7a9c718294496f3" @@ -100,6 +113,11 @@ resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A== +"@types/estree@*": + version "0.0.51" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.51.tgz#cfd70924a25a3fd32b218e5e420e6897e1ac4f40" + integrity sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ== + "@types/estree@0.0.39": version "0.0.39" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" @@ -242,6 +260,11 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== +binary-search-bounds@^2.0.0: + version "2.0.5" + resolved "https://registry.yarnpkg.com/binary-search-bounds/-/binary-search-bounds-2.0.5.tgz#125e5bd399882f71e6660d4bf1186384e989fba7" + integrity sha512-H0ea4Fd3lS1+sTEB2TgcLoK21lLhwEJzlQv3IN47pJS976Gx4zoWe0ak3q+uYh60ppQxg9F16Ri4tS1sfD4+jA== + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -388,6 +411,11 @@ commander@^2.19.0, commander@^2.20.0: resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== +commondir@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" + integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs= + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -1031,6 +1059,11 @@ estree-walker@^1.0.1: resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700" integrity sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg== +estree-walker@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" + integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== + esutils@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" @@ -1161,7 +1194,7 @@ glob-parent@~5.1.2: dependencies: is-glob "^4.0.1" -glob@7.2.0, glob@^7.1.3: +glob@7.2.0, glob@^7.1.3, glob@^7.1.6: version "7.2.0" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023" integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q== @@ -1294,6 +1327,13 @@ ini@^1.3.4: resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009" integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg== +interval-tree-1d@1: + version "1.0.4" + resolved "https://registry.yarnpkg.com/interval-tree-1d/-/interval-tree-1d-1.0.4.tgz#b44f657de7ddae69ea3f98e0a9ad4bb046b07d11" + integrity sha512-wY8QJH+6wNI0uh4pDQzMvl+478Qh7Rl4qLmqiluxALlNvl+I+o5x38Pw3/z7mDPTPS1dQalZJXsmbvxx5gclhQ== + dependencies: + binary-search-bounds "^2.0.0" + is-binary-path@~2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" @@ -1345,6 +1385,13 @@ is-potential-custom-element-name@^1.0.1: resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== +is-reference@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/is-reference/-/is-reference-1.2.1.tgz#8b2dac0b371f4bc994fdeaba9eb542d03002d0b7" + integrity sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ== + dependencies: + "@types/estree" "*" + is-unicode-supported@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" @@ -1485,6 +1532,13 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +magic-string@^0.25.7: + version "0.25.9" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c" + integrity sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ== + dependencies: + sourcemap-codec "^1.4.8" + make-dir@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" @@ -1816,7 +1870,7 @@ resolve-from@^4.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== -resolve@^1.19.0, resolve@^1.22.0: +resolve@^1.17.0, resolve@^1.19.0, resolve@^1.22.0: version "1.22.0" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.0.tgz#5e0b8c67c15df57a89bdbabe603a002f21731198" integrity sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw== @@ -1971,6 +2025,11 @@ source-map@~0.7.2: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ== +sourcemap-codec@^1.4.8: + version "1.4.8" + resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" + integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== + "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" From a9c79b39086e0a1640468fb352f5320a80a8cb63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 24 Mar 2022 16:05:22 +0100 Subject: [PATCH 06/11] compose intializers --- src/transforms/basic.js | 14 + src/transforms/dodge.js | 2 +- src/transforms/hexbin.js | 12 +- test/output/penguinDodgeHexbin.svg | 760 +++++++++++++++++++++++++++++ test/plots/index.js | 1 + test/plots/penguin-dodge-hexbin.js | 26 + 6 files changed, 809 insertions(+), 6 deletions(-) create mode 100644 test/output/penguinDodgeHexbin.svg create mode 100644 test/plots/penguin-dodge-hexbin.js diff --git a/src/transforms/basic.js b/src/transforms/basic.js index 2f99f3b2e3..d6e0e06b85 100644 --- a/src/transforms/basic.js +++ b/src/transforms/basic.js @@ -9,6 +9,7 @@ export function basic({ sort: s1, reverse: r1, transform: t1, + initialize: i1, ...options } = {}, t2) { if (t1 === undefined) { // explicit transform overrides filter, sort, and reverse @@ -16,6 +17,7 @@ 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("Data transforms must appear before any channel transform"); return { ...options, ...isOptions(s1) && {sort: s1}, @@ -32,6 +34,18 @@ function composeTransform(t1, t2) { }; } +export function composeInitialize({initialize: i1, ...options} = {}, i2) { + return i1 == null + ? {...options, initialize: i2} + : { + ...options, + initialize(data, facets, channels, scales, dimensions) { + ({data, facets, channels} = i1.call(this, data, facets, channels, scales, dimensions)); + return i2.call(this, data, facets, channels, scales, dimensions); + } + }; +} + export function filter(value, options) { return basic(options, filterTransform(value)); } diff --git a/src/transforms/dodge.js b/src/transforms/dodge.js index ca915d09a5..e24b9b4cbc 100644 --- a/src/transforms/dodge.js +++ b/src/transforms/dodge.js @@ -84,7 +84,7 @@ function dodge(y, x, anchor, padding, options) { } for (const i of I) Y[i] = Y[i] * ky + ty; } - return {facets, channels: { + return {data, facets, channels: { [x]: {value: X}, [y]: {value: Y}, ...R && {r: {value: R}} diff --git a/src/transforms/hexbin.js b/src/transforms/hexbin.js index f78e30229b..e1880fd3a2 100644 --- a/src/transforms/hexbin.js +++ b/src/transforms/hexbin.js @@ -1,7 +1,8 @@ import {group} from "d3"; import {sqrt3} from "../symbols.js"; -import {maybeChannel, maybeColorChannel, valueof} from "../options.js"; +import {identity, maybeChannel, maybeColorChannel, valueof} from "../options.js"; import {hasOutput, maybeOutputs} from "./group.js"; +import {composeInitialize} from "./basic.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) @@ -34,14 +35,15 @@ function hexbinn(outputs, {binWidth = 20, fill, stroke, z, ...options}) { ...!hasOutput(outputs, "r") && {r: binWidth / 2}, ...!setGF && {fill}, ...((hasOutput(outputs, "fill") || setGF) && stroke === undefined) ? {stroke: "none"} : {stroke}, - ...options, - initialize(data, facets, {x: X, y: Y}, {x, y}) { + ...composeInitialize(options, function(data, facets, {x: X, y: Y}, scales) { if (setGF) setGF(valueof(data, vfill)); if (setGS) setGS(valueof(data, vstroke)); if (setGZ) setGZ(valueof(data, z)); for (const o of outputs) o.initialize(data); if (X === undefined) throw new Error("missing channel: x"); if (Y === undefined) throw new Error("missing channel: y"); + const x = X.scale !== undefined ? scales[X.scale] : identity.transform; + const y = Y.scale !== undefined ? scales[Y.scale] : identity.transform; X = X.value.map(x); Y = Y.value.map(y); const F = setGF && GF.transform(); @@ -73,8 +75,8 @@ function hexbinn(outputs, {binWidth = 20, fill, stroke, z, ...options}) { const R = channels.r.value; binFacets.forEach(index => index.sort((i, j) => R[j] - R[i])); } - return {facets: binFacets, channels}; - } + return {data, facets: binFacets, channels}; + }) }; } diff --git a/test/output/penguinDodgeHexbin.svg b/test/output/penguinDodgeHexbin.svg new file mode 100644 index 0000000000..7af2735b03 --- /dev/null +++ b/test/output/penguinDodgeHexbin.svg @@ -0,0 +1,760 @@ + + + + + Adelie + + + Chinstrap + + + Gentoo + + + + + 3,000 + + + + 3,500 + + + + 4,000 + + + + 4,500 + + + + 5,000 + + + + 5,500 + + + + 6,000 + + body_mass_g → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/index.js b/test/plots/index.js index 9432849ef6..78ad9c4a6a 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -108,6 +108,7 @@ export {default as ordinalBar} from "./ordinal-bar.js"; export {default as penguinCulmen} from "./penguin-culmen.js"; export {default as penguinCulmenArray} from "./penguin-culmen-array.js"; export {default as penguinDodge} from "./penguin-dodge.js"; +export {default as penguinDodgeHexbin} from "./penguin-dodge-hexbin.js"; export {default as penguinFacetDodge} from "./penguin-facet-dodge.js"; export {default as penguinFacetDodgeIsland} from "./penguin-facet-dodge-island.js"; export {default as penguinFacetDodgeSymbol} from "./penguin-facet-dodge-symbol.js"; diff --git a/test/plots/penguin-dodge-hexbin.js b/test/plots/penguin-dodge-hexbin.js new file mode 100644 index 0000000000..c2b159f77b --- /dev/null +++ b/test/plots/penguin-dodge-hexbin.js @@ -0,0 +1,26 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +// test channel transform composition +export default async function() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + return Plot.plot({ + height: 300, + x: { + grid: true, + inset: 7 + }, + facet: { + data: penguins, + y: "species", + label: null, + marginLeft: 60 + }, + marks: [ + Plot.frame(), + Plot.dot(penguins, Plot.dodgeY("bottom", {x: "body_mass_g", stroke: "red", r: 3})), + Plot.dot(penguins, Plot.hexbin({binWidth: 7}, Plot.dodgeY("bottom", {x: "body_mass_g", fill: "black", r: 3}))) + ], + color: {legend: true} + }); +} From 6cdc9d5ebf07399e947c04e6744c8a6ec12c3f59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Sat, 26 Mar 2022 14:59:33 +0100 Subject: [PATCH 07/11] use composeInitialize to make dodge composable --- src/transforms/dodge.js | 98 ++++++++++++++++++++--------------------- 1 file changed, 48 insertions(+), 50 deletions(-) diff --git a/src/transforms/dodge.js b/src/transforms/dodge.js index e24b9b4cbc..8847daf097 100644 --- a/src/transforms/dodge.js +++ b/src/transforms/dodge.js @@ -1,6 +1,7 @@ import {max} from "d3"; import IntervalTree from "interval-tree-1d"; import {finite, positive} from "../defined.js"; +import {composeInitialize} from "./basic.js"; const anchorXLeft = ({marginLeft}) => [1, marginLeft]; const anchorXRight = ({width, marginRight}) => [-1, width - marginRight]; @@ -38,60 +39,57 @@ export function dodgeY(dodgeOptions = {}, options = {}) { } function dodge(y, x, anchor, padding, options) { - return { - initialize(data, facets, {[x]: X, r: R}, {[x]: xscale, r: rscale}, dimensions) { - if (!X) throw new Error(`missing channel ${x}`); - X = X.value.map(xscale); - const r = R ? undefined : this.r !== undefined ? this.r : options.r !== undefined ? +options.r : 3; - if (R) R = R.value.map(rscale); - if (X == null) throw new Error(`missing channel: ${x}`); - 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); + return composeInitialize(options, function(data, facets, {[x]: X, r: R}, {[x]: xscale, r: rscale}, dimensions) { + if (!X) throw new Error(`missing channel ${x}`); + X = X.value.map(xscale); + const r = R ? undefined : this.r !== undefined ? this.r : options.r !== undefined ? +options.r : 3; + if (R) R = R.value.map(rscale); + if (X == null) throw new Error(`missing channel: ${x}`); + 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; - } + // 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; + + // Insert the placed circle into the interval tree. + tree.insert([l, h, i]); } - return {data, facets, channels: { - [x]: {value: X}, - [y]: {value: Y}, - ...R && {r: {value: R}} - }}; - }, - ...options - }; + 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) { From 5734e393a83ae12e85eb77918fbe5edd11e39b84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Sat, 26 Mar 2022 14:59:56 +0100 Subject: [PATCH 08/11] add new channels as you compose initializers --- src/transforms/basic.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/transforms/basic.js b/src/transforms/basic.js index d6e0e06b85..cbf8bf5444 100644 --- a/src/transforms/basic.js +++ b/src/transforms/basic.js @@ -40,7 +40,9 @@ export function composeInitialize({initialize: i1, ...options} = {}, i2) { : { ...options, initialize(data, facets, channels, scales, dimensions) { - ({data, facets, channels} = i1.call(this, data, facets, channels, scales, dimensions)); + let newChannels; + ({data, facets, channels: newChannels} = i1.call(this, data, facets, channels, scales, dimensions)); + Object.assign(channels, newChannels); return i2.call(this, data, facets, channels, scales, dimensions); } }; From e3d0628f95bdb476c7753975e8a50d932cf8f10f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Sat, 26 Mar 2022 15:05:42 +0100 Subject: [PATCH 09/11] darker transform, to demonstrate composition with dodgeY (added as an example, but we could promote it to a transform) --- test/output/darkerDodge.svg | 200 ++++++++++++++++++++++++++++++++++++ test/plots/darker-dodge.js | 58 +++++++++++ test/plots/index.js | 1 + 3 files changed, 259 insertions(+) create mode 100644 test/output/darkerDodge.svg create mode 100644 test/plots/darker-dodge.js diff --git a/test/output/darkerDodge.svg b/test/output/darkerDodge.svg new file mode 100644 index 0000000000..2946ee7fc0 --- /dev/null +++ b/test/output/darkerDodge.svg @@ -0,0 +1,200 @@ + + + + + 0 + + + 1 + + + 2 + + + 3 + + + 4 + + + 5 + + + 6 + + + 7 + + + 8 + + + 9 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/darker-dodge.js b/test/plots/darker-dodge.js new file mode 100644 index 0000000000..53cc9db015 --- /dev/null +++ b/test/plots/darker-dodge.js @@ -0,0 +1,58 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; +import {composeInitialize} from "../../src/transforms/basic.js"; + +const darker = (darker = 0.7, options = {}) => { + if (typeof darker === "object") { + options = darker; + darker = "darker" in options ? options.darker : 0.7; + } + + function darken(data, facets, channels, scales) { + const F = channels.fill && new Array(channels.fill.value.length); + const S = channels.stroke && new Array(channels.stroke.value.length); + if (F) { + for (const facet of facets) { + for (const i of facet) { + let v = channels.fill.value[i]; + if (channels.fill.scale === "color") v = scales.color(v); + F[i] = d3.rgb(v).darker(darker).formatHex(); + } + } + } + if (S) { + for (const facet of facets) { + for (const i of facet) { + let v = channels.stroke.value[i]; + if (channels.stroke.scale === "color") v = scales.color(v); + S[i] = d3.rgb(v).darker(darker).formatHex(); + } + } + } + return { + data, + facets, + channels: { + ...(F && { fill: { value: F } }), + ...(S && { stroke: { value: S } }) + } + }; + } + + return composeInitialize(options, darken); +}; + +// In the following, darker and Plot.dodgeY are interchangeable +export default async function() { + return Plot.plot({ + marginTop: 10, + nice: true, + marks: [ + Plot.dotX( + Array.from({ length: 150 }, d3.randomLogNormal.source(d3.randomLcg(42))()), + Plot.dodgeY("middle", darker({ x: (d) => d, fill: (d) => d })) + ) + ], + height: 170 + }); +} diff --git a/test/plots/index.js b/test/plots/index.js index 78ad9c4a6a..3d41c6c7c9 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -39,6 +39,7 @@ export {default as collapsedHistogram} from "./collapsed-histogram.js"; export {default as covidIhmeProjectedDeaths} from "./covid-ihme-projected-deaths.js"; export {default as d3Survey2015Comfort} from "./d3-survey-2015-comfort.js"; export {default as d3Survey2015Why} from "./d3-survey-2015-why.js"; +export {default as darkerDodge} from "./darker-dodge.js"; export {default as decathlon} from "./decathlon.js"; export {default as diamondsCaratPrice} from "./diamonds-carat-price.js"; export {default as diamondsCaratPriceDots} from "./diamonds-carat-price-dots.js"; From bddf2749ad81d45bda475cd2d1c221c00dd24178 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Sun, 27 Mar 2022 10:27:27 +0200 Subject: [PATCH 10/11] a more generic "remap" --- test/plots/darker-dodge.js | 55 +++++++++++++++----------------------- 1 file changed, 22 insertions(+), 33 deletions(-) diff --git a/test/plots/darker-dodge.js b/test/plots/darker-dodge.js index 53cc9db015..4e3ac16870 100644 --- a/test/plots/darker-dodge.js +++ b/test/plots/darker-dodge.js @@ -2,45 +2,29 @@ import * as Plot from "@observablehq/plot"; import * as d3 from "d3"; import {composeInitialize} from "../../src/transforms/basic.js"; -const darker = (darker = 0.7, options = {}) => { - if (typeof darker === "object") { - options = darker; - darker = "darker" in options ? options.darker : 0.7; - } - - function darken(data, facets, channels, scales) { - const F = channels.fill && new Array(channels.fill.value.length); - const S = channels.stroke && new Array(channels.stroke.value.length); - if (F) { - for (const facet of facets) { - for (const i of facet) { - let v = channels.fill.value[i]; - if (channels.fill.scale === "color") v = scales.color(v); - F[i] = d3.rgb(v).darker(darker).formatHex(); - } - } - } - if (S) { - for (const facet of facets) { - for (const i of facet) { - let v = channels.stroke.value[i]; - if (channels.stroke.scale === "color") v = scales.color(v); - S[i] = d3.rgb(v).darker(darker).formatHex(); +function remap(outputs = {}, options) { + return composeInitialize(options, (data, facets, channels, scales) => { + const newChannels = {}; + for (const [key, map] of Object.entries(outputs)) { + const input = channels[key]; + if (input == null) throw new Error(`missing channel: ${key}`); + const V = Array.from(input.value); + if (input.scale != null) { + const scale = scales[input.scale]; + if (scale != null) { + for (let i = 0; i < V.length; ++i) V[i] = scale(V[i]); } } + for (let i = 0; i < V.length; ++i) V[i] = map(V[i]); + newChannels[key] = {value: V}; } return { data, facets, - channels: { - ...(F && { fill: { value: F } }), - ...(S && { stroke: { value: S } }) - } + channels: newChannels }; - } - - return composeInitialize(options, darken); -}; + }); +} // In the following, darker and Plot.dodgeY are interchangeable export default async function() { @@ -50,7 +34,12 @@ export default async function() { marks: [ Plot.dotX( Array.from({ length: 150 }, d3.randomLogNormal.source(d3.randomLcg(42))()), - Plot.dodgeY("middle", darker({ x: (d) => d, fill: (d) => d })) + Plot.dodgeY("middle", remap({ + fill: v => d3.rgb(v).darker(0.7).formatHex() + }, { + x: (d) => d, + fill: (d) => d + })) ) ], height: 170 From f5cf74d50c84636a596eb06307a1f6ea7cb225f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 29 Mar 2022 14:55:43 +0200 Subject: [PATCH 11/11] jiggle layout (using the same remap intializer as in the darkerDodge plot) --- test/output/carsJiggle.html | 499 ++++++++++++++++++++++++++++++++++++ test/plots/cars-jiggle.js | 48 ++++ test/plots/index.js | 1 + 3 files changed, 548 insertions(+) create mode 100644 test/output/carsJiggle.html create mode 100644 test/plots/cars-jiggle.js diff --git a/test/output/carsJiggle.html b/test/output/carsJiggle.html new file mode 100644 index 0000000000..e2b2d8cc82 --- /dev/null +++ b/test/output/carsJiggle.html @@ -0,0 +1,499 @@ +
+ + + + + 50 + + + 100 + + + 150 + + + 200 + power (hp) + + + + + + + 8 + + + + 6 + + + + 5 + + + + 4 + + + + 3 + cylinders + + + + 1,500 + + + 2,000 + + + 2,500 + + + 3,000 + + + 3,500 + + + 4,000 + + + 4,500 + + + 5,000 + + + 5,500 + weight (lb) → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/plots/cars-jiggle.js b/test/plots/cars-jiggle.js new file mode 100644 index 0000000000..bf36ee90b5 --- /dev/null +++ b/test/plots/cars-jiggle.js @@ -0,0 +1,48 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; +import {composeInitialize} from "../../src/transforms/basic.js"; + +function remap(outputs = {}, options) { + return composeInitialize(options, (data, facets, channels, scales) => { + const newChannels = {}; + for (const [key, map] of Object.entries(outputs)) { + const input = channels[key]; + if (input == null) throw new Error(`missing channel: ${key}`); + const V = Array.from(input.value); + if (input.scale != null) { + const scale = scales[input.scale]; + if (scale != null) { + for (let i = 0; i < V.length; ++i) V[i] = scale(V[i]); + } + } + for (let i = 0; i < V.length; ++i) V[i] = map(V[i]); + newChannels[key] = {value: V}; + } + return { + data, + facets, + channels: newChannels + }; + }); +} + +const random = d3.randomNormal.source(d3.randomLcg(42))(0, 7); + +export default async function() { + const data = await d3.csv("data/cars.csv", d3.autoType); + return Plot.plot({ + height: 350, + y: {type: "band", reverse: true, grid: true}, + color: {nice: true, scheme: "warm", reverse: true, legend: true}, + nice: true, + marks: [ + Plot.dot(data, remap({y: d => d + random()}, { + x: "weight (lb)", + y: "cylinders", + fill: "power (hp)", + stroke: "white", + strokeWidth: 0.5 + })) + ] + }); +} diff --git a/test/plots/index.js b/test/plots/index.js index 3d41c6c7c9..bbf4348153 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -28,6 +28,7 @@ export {default as binTimestamps} from "./bin-timestamps.js"; export {default as boxplot} from "./boxplot.js"; export {default as caltrain} from "./caltrain.js"; export {default as caltrainDirection} from "./caltrain-direction.js"; +export {default as carsJiggle} from "./cars-jiggle.js"; export {default as carsMpg} from "./cars-mpg.js"; export {default as carsParcoords} from "./cars-parcoords.js"; export {default as clamp} from "./clamp.js";