From 2dae26811a3b6e0ca67c707bbca8b419297135ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 8 Feb 2022 15:09:33 +0100 Subject: [PATCH 1/8] hexbin --- README.md | 36 ++ src/index.js | 1 + src/options.js | 10 + src/transforms/hexbin.js | 122 +++++ test/output/hexbin.svg | 798 ++++++++++++++++++++++++++++++ test/output/hexbinDot.svg | 190 +++++++ test/output/hexbinR.svg | 259 ++++++++++ test/output/hexbinTextOpacity.svg | 229 +++++++++ test/plots/hexbin-dot.js | 11 + test/plots/hexbin-r.js | 19 + test/plots/hexbin-text-opacity.js | 21 + test/plots/hexbin.js | 37 ++ test/plots/index.js | 4 + 13 files changed, 1737 insertions(+) create mode 100644 src/transforms/hexbin.js create mode 100644 test/output/hexbin.svg create mode 100644 test/output/hexbinDot.svg create mode 100644 test/output/hexbinR.svg create mode 100644 test/output/hexbinTextOpacity.svg create mode 100644 test/plots/hexbin-dot.js create mode 100644 test/plots/hexbin-r.js create mode 100644 test/plots/hexbin-text-opacity.js create mode 100644 test/plots/hexbin.js diff --git a/README.md b/README.md index 6fb96f2eb3..59dd5f54b3 100644 --- a/README.md +++ b/README.md @@ -1543,6 +1543,42 @@ Plot.groupZ({x: "proportion"}, {fill: "species"}) Groups on the first channel of *z*, *fill*, or *stroke*, if any. If none of *z*, *fill*, or *stroke* are channels, then all data (within each facet) is placed into a single group. +### Hexbin + +[Source](./src/transforms/hexbin.js) · [Examples](https://observablehq.com/@observablehq/plot-hexbin) · Groups the scaled values into hexagonal bins, and returns the ⟨x,y⟩ positions of these hexagons together with their aggregated value. Binning happens in pixel space, ensuring symmetrical hexagons. + +This transform is available in several flavors: + +#### Plot.hexbin(data, *options*) + +This shortcut extends the [dot](#dot) mark by applying a hexbinFill transform and requesting the hexagon symbol. + +#### Plot.hexbinFill(*hexbinOptions*, **options*) + +This transforms derives new output channels x, y, and fill, where x and y are the coordinates of the hexagons’ centers and fill depends on the number of data points they contain. + +#### Plot.hexbinOpacity(*hexbinOptions*, **options*) + +This transforms derives new output channels x, y, and fill, where x and y are the coordinates of the hexagons’ centers and opacity is proportional to the number of data points they contain, scaled so that the largest bin as an opacity of 1. + +#### Plot.hexbinR(*hexbinOptions*, **options*) + +This transforms derives new output channels x, y, and fill, where x and y are the coordinates of the hexagons’ centers and r specifies a symbol with a size proportional to the bin’s value, scaled so that the largest bin has the full radius. + +#### Plot.hexbinText(*hexbinOptions*, **options*) + +This transforms derives new output channels x, y, and fill, where x and y are the coordinates of the hexagons’ centers and text depends on the number of data points they contain. + +The following *hexbinOptions* are supported: +* *radius* - the radius of the hexagons tesselation, in pixels, which defaults to 10 +* *value* - the value of a bin: a function that receives as input the binned data, and default to the bin’s length +* *r* - the radius of a bin, as a function of the bin’s value (uses the scale r which defaults to a type sqrt) +* *opacity* - the opacity of a bin, as a function of the bin’s value (on the opacity scale) +* *fill* - the fill color of a bin, as a function of the bin’s value (on the color scale) +* *text* - a text that represents the contents of the bin, to use with Plot.text; defaults to its value +* *title* - a text that represents the contents of the bin, to use as a title; defaults to its value + +The other *options* are passed to the mark. ### Map [moving averages of daily highs and lows](https://observablehq.com/@observablehq/plot-map) diff --git a/src/index.js b/src/index.js index 2eadbeeab7..4271bed8b1 100644 --- a/src/index.js +++ b/src/index.js @@ -18,6 +18,7 @@ export {dodgeX, dodgeY} from "./layouts/dodge.js"; export {filter, reverse, sort, shuffle} from "./transforms/basic.js"; export {bin, binX, binY} from "./transforms/bin.js"; export {group, groupX, groupY, groupZ} from "./transforms/group.js"; +export {hexbin, hexbinFill, hexbinOpacity, hexbinR, hexbinText} from "./transforms/hexbin.js"; export {normalize, normalizeX, normalizeY} from "./transforms/normalize.js"; export {map, mapX, mapY} from "./transforms/map.js"; export {window, windowX, windowY} from "./transforms/window.js"; diff --git a/src/options.js b/src/options.js index 7effd60512..0d4916bafd 100644 --- a/src/options.js +++ b/src/options.js @@ -250,12 +250,22 @@ export function isColor(value) { || color(value) !== null; } +const hex = Array.from({length: 6}, (_, i) => [Math.sin(i * Math.PI / 3), Math.cos(i * Math.PI / 3)]); +const symbolHexagon = { + draw(context, size) { + const s = Math.sqrt(size / Math.PI); + for (let i = 0; i < 6; i++) context[i ? "lineTo" : "moveTo"](hex[i][0] * s, hex[i][1] * s); + context.closePath(); + } +}; + const symbols = new Map([ ["asterisk", symbolAsterisk], ["circle", symbolCircle], ["cross", symbolCross], ["diamond", symbolDiamond], ["diamond2", symbolDiamond2], + ["hexagon", symbolHexagon], ["plus", symbolPlus], ["square", symbolSquare], ["square2", symbolSquare2], diff --git a/src/transforms/hexbin.js b/src/transforms/hexbin.js new file mode 100644 index 0000000000..4ff21664bd --- /dev/null +++ b/src/transforms/hexbin.js @@ -0,0 +1,122 @@ +import {groups, max} from "d3"; +import {basic} from "./basic.js"; +import {layout} from "../layouts/index.js"; +import {Dot} from "../marks/dot.js"; +import {maybeTuple, range, take, valueof} from "../options.js"; + +const defaults = { + ariaLabel: "hex", + symbol: "hexagon", + fill: "currentColor", + stroke: "currentColor", + strokeWidth: 0.5 +}; + +// width factor (allows the hexbin transform to work with circular dots!) +const w0 = 1 / Math.sin(Math.PI / 3); + +function hbin(I, X, Y, r) { + const dx = r * 2 / w0; + const dy = r * 1.5; + return groups(I, i => { + let px = X[i] / dy; + let py = Y[i] / dx; + if (isNaN(px) || isNaN(py)) return; + let pj = Math.round(py), + pi = Math.round(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; + } + return `${pi}|${pj}`; + }) + .filter(([p]) => p) + .map(([p, bin]) => { + const [pi, pj] = p.split("|"); + bin.x = (+pi + (pj & 1) / 2) * dx; + bin.y = +pj * dy; + return bin; + }); +} + +function hexbinLayout({_store, radius, r: _r, opacity: _o, fill: _f, text: _t, title: _l, value: _v}, options) { + radius = +radius; + _r = !!_r; + _o = !!_o; + _f = !!_f; + return layout({_store, ...options}, function(I, {r, color, opacity}, {x: X, y: Y}) { + if (!_store.channels) { + const bins = _store.facets.map(I => hbin(I, X, Y, radius)); + const values = bins.map(b => valueof(b, _v)); + const maxValue = max(values.flat()); + color = _f && color.copy().domain([1, maxValue]); + opacity = _o && opacity.copy().domain([1, maxValue]); + r = _r && r && r.copy().domain([0, maxValue]).range([0, radius / w0]); + const {data} = this; + _store.channels = Array.from(bins, (bin, i) => ({ + x: valueof(bin, "x"), + y: valueof(bin, "y"), + ..._t != null && {text: valueof(bin.map(I => take(data, I)), _t)}, + ..._l != null && {title: valueof(bin.map(I => take(data, I)), _l)}, + ..._r && r && {r: values[i].map(r)}, + ..._o && opacity && {fillOpacity: values[i].map(opacity)}, + ..._f && color && {fill: values[i].map(color)} + })); + } + for (let j = 0; j < _store.facets.length; ++j) { + if (_store.facets[j].some(i => I.includes(i))) { + const channels = _store.channels[j]; + // mutates I! + I.splice(0, I.length, ...range(channels.x)); + return channels; + } + } + throw new Error("what are we doing here?"); + }); +} + +// The transform does nothing but divert some information that is necessary +// to do the layout on multiple facets +// It is a bit of a hack: we want to do all facets at once in order to compute +// the maximum value. We also convert the facets to plain arrays, since we’re +// going to splice them +export function hexbinTransform(hexbinOptions, options) { + const _store = {}; + return basic(hexbinLayout({_store, ...hexbinOptions}, options), (data, facets) => { + facets = Array.from(facets, facet => Array.from(facet)); + _store.facets = facets; + return {data, facets}; + }); +} + + +// todo: hexbinMesh, or a mesh option? +// todo: clip? +export function hexbin(data, options) { + return new Dot(data, hexbinFill(options)); +} + +export function hexbinR({x, y, value = "length", radius = 10, title, ...options} = {}) { + if (options.frameAnchor === undefined) ([x, y] = maybeTuple(x, y)); + return hexbinTransform({value, radius, title, r: true}, {...defaults, x, y, r: () => 1, ...options}); +} + +export function hexbinFill({x, y, value = "length", radius = 10, title, ...options} = {}) { + if (options.frameAnchor === undefined) ([x, y] = maybeTuple(x, y)); + return hexbinTransform({value, radius, title, fill: true}, {...defaults, x, y, r: radius, fill: () => 1, ...options}); +} + +export function hexbinOpacity({x, y, value = "length", radius = 10, title, ...options} = {}) { + if (options.frameAnchor === undefined) ([x, y] = maybeTuple(x, y)); + return hexbinTransform({value, radius, title, opacity: true}, {...defaults, x, y, r: radius, opacity: () => 1,...options}); +} + +export function hexbinText({x, y, value = "length", radius = 10, text = d => d.length, ...options} = {}) { + if (options.frameAnchor === undefined) ([x, y] = maybeTuple(x, y)); + return hexbinTransform({value, radius, text, r: true}, {x, y, r: () => 1, ...options}); +} diff --git a/test/output/hexbin.svg b/test/output/hexbin.svg new file mode 100644 index 0000000000..067ff5d1e4 --- /dev/null +++ b/test/output/hexbin.svg @@ -0,0 +1,798 @@ + + + + + 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 → + + + + + + + 8 penguins. + + + 4 penguins. + + + 3 penguins. + + + 10 penguins. + + + 6 penguins. + + + 4 penguins. + + + 3 penguins. + + + 11 penguins. + + + 1 penguins. + + + 5 penguins. + + + 4 penguins. + + + 7 penguins. + + + 1 penguins. + + + 1 penguins. + + + 1 penguins. + + + 1 penguins. + + + 1 penguins. + + + 1 penguins. + + + 1 penguins. + + + 1 penguins. + + + 6 penguins. + + + 4 penguins. + + + 1 penguins. + + + 5 penguins. + + + 1 penguins. + + + 3 penguins. + + + 2 penguins. + + + 3 penguins. + + + 3 penguins. + + + 1 penguins. + + + 1 penguins. + + + 1 penguins. + + + 1 penguins. + + + 1 penguins. + + + 2 penguins. + + + 4 penguins. + + + 10 penguins. + + + 11 penguins. + + + 6 penguins. + + + 1 penguins. + + + 7 penguins. + + + 7 penguins. + + + 5 penguins. + + + 1 penguins. + + + 1 penguins. + + + 1 penguins. + + + 1 penguins. + + + 1 penguins. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 15 penguins. + + + 2 penguins. + + + 5 penguins. + + + 2 penguins. + + + 1 penguins. + + + 3 penguins. + + + 1 penguins. + + + 3 penguins. + + + 3 penguins. + + + 4 penguins. + + + 4 penguins. + + + 9 penguins. + + + 1 penguins. + + + 5 penguins. + + + 6 penguins. + + + 3 penguins. + + + 1 penguins. + + + 1 penguins. + + + 1 penguins. + + + 1 penguins. + + + 1 penguins. + + + 1 penguins. + + + 7 penguins. + + + 5 penguins. + + + 3 penguins. + + + 3 penguins. + + + 1 penguins. + + + 3 penguins. + + + 1 penguins. + + + 7 penguins. + + + 1 penguins. + + + 1 penguins. + + + 2 penguins. + + + 11 penguins. + + + 13 penguins. + + + 2 penguins. + + + 3 penguins. + + + 5 penguins. + + + 6 penguins. + + + 5 penguins. + + + 1 penguins. + + + 1 penguins. + + + 2 penguins. + + + 1 penguins. + + + 1 penguins. + + + 3 penguins. + + + 1 penguins. + + + 1 penguins. + + + 3 penguins. + + + 1 penguins. + + + 1 penguins. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 penguins. + + + 1 penguins. + + + 1 penguins. + + + 1 penguins. + + + 1 penguins. + + + 1 penguins. + + + 1 penguins. + + + 1 penguins. + + + 1 penguins. + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/hexbinDot.svg b/test/output/hexbinDot.svg new file mode 100644 index 0000000000..957d52a8ae --- /dev/null +++ b/test/output/hexbinDot.svg @@ -0,0 +1,190 @@ + + + + + 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/hexbinR.svg b/test/output/hexbinR.svg new file mode 100644 index 0000000000..304e7f0032 --- /dev/null +++ b/test/output/hexbinR.svg @@ -0,0 +1,259 @@ + + + + + 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 → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/hexbinTextOpacity.svg b/test/output/hexbinTextOpacity.svg new file mode 100644 index 0000000000..48ab06d527 --- /dev/null +++ b/test/output/hexbinTextOpacity.svg @@ -0,0 +1,229 @@ + + + + + 35 + + + 40 + + + 45 + + + 50 + + + 55 + ↑ culmen_length_mm + + + + FEMALE + + + MALE + + + + sex + + + + + + 15 + + + 20 + + + + + + + 15 + + + 20 + + + + + + + 15 + + + 20 + culmen_depth_mm → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 58385423811554121111311332551223111111123941961518123111 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 10231111421912311464122122111125422212121173185256612141112212121121111 + + + + + + + + + + + + + + + 111111111 + + + \ No newline at end of file diff --git a/test/plots/hexbin-dot.js b/test/plots/hexbin-dot.js new file mode 100644 index 0000000000..1f372e169b --- /dev/null +++ b/test/plots/hexbin-dot.js @@ -0,0 +1,11 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export default async function() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + return Plot.plot({ + marks: [ + Plot.dot(penguins, Plot.hexbinR({x: "culmen_depth_mm", y: "culmen_length_mm", radius: 20})) + ] + }); +} diff --git a/test/plots/hexbin-r.js b/test/plots/hexbin-r.js new file mode 100644 index 0000000000..38a4d3d5a9 --- /dev/null +++ b/test/plots/hexbin-r.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({ + width: 820, + height: 320, + facet: { + data: penguins, + x: "sex", + marginRight: 80 + }, + marks: [ + Plot.frame(), + Plot.dot(penguins, Plot.hexbinR({x: "culmen_depth_mm", y: "culmen_length_mm"})) + ] + }); +} diff --git a/test/plots/hexbin-text-opacity.js b/test/plots/hexbin-text-opacity.js new file mode 100644 index 0000000000..9fe42aa7a5 --- /dev/null +++ b/test/plots/hexbin-text-opacity.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.hexbinOpacity({x: "culmen_depth_mm", y: "culmen_length_mm", fill: "brown"})), + Plot.text(penguins, Plot.hexbinText({x: "culmen_depth_mm", y: "culmen_length_mm"})) + ] + }); +} diff --git a/test/plots/hexbin.js b/test/plots/hexbin.js new file mode 100644 index 0000000000..50c7c28a36 --- /dev/null +++ b/test/plots/hexbin.js @@ -0,0 +1,37 @@ +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: "cividis", type: "log", reverse: true}, + width: 820, + height: 320, + x: {inset: 20, ticks: 5}, + y: {inset: 10}, + grid: true, + facet: { + data: penguins, + x: "sex", + marginRight: 80 + }, + marks: [ + Plot.frame(), + Plot.hexbin(penguins, { + x: "culmen_depth_mm", + y: "culmen_length_mm", + radius: 12, + fill: "brown", + title: bin => `${bin.length} penguins.` + }), + Plot.dot(penguins, { + x: "culmen_depth_mm", + y: "culmen_length_mm", + fill: "white", + stroke: "black", + strokeWidth: 0.5, + r: 1.5 + }) + ] + }); +} diff --git a/test/plots/index.js b/test/plots/index.js index df7cc1044e..e319d4bf4d 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -51,6 +51,10 @@ export {default as gistempAnomalyTransform} from "./gistemp-anomaly-transform.js export {default as googleTrendsRidgeline} from "./google-trends-ridgeline.js"; export {default as gridChoropleth} from "./grid-choropleth.js"; export {default as hadcrutWarmingStripes} from "./hadcrut-warming-stripes.js"; +export {default as hexbin} from "./hexbin.js"; +export {default as hexbinDot} from "./hexbin-dot.js"; +export {default as hexbinR} from "./hexbin-r.js"; +export {default as hexbinTextOpacity} from "./hexbin-text-opacity.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"; From b9f97d0cc66fdc6a5dde0cfa7ba872b44c04352d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 8 Feb 2022 19:05:03 +0100 Subject: [PATCH 2/8] layouts can reindex and rescale --- src/layouts/dodge.js | 6 +- src/plot.js | 115 +++++++++- src/transforms/hexbin.js | 92 ++++---- test/output/hexbin.svg | 340 ++++++++++++++---------------- test/output/hexbinDot.svg | 193 ++++++++--------- test/output/hexbinR.svg | 276 ++++++++++++------------ test/output/hexbinTextOpacity.svg | 269 ++++++++++++----------- test/plots/hexbin.js | 1 - 8 files changed, 673 insertions(+), 619 deletions(-) diff --git a/src/layouts/dodge.js b/src/layouts/dodge.js index 5e7371519b..2c4644f74e 100644 --- a/src/layouts/dodge.js +++ b/src/layouts/dodge.js @@ -75,7 +75,11 @@ function dodge(y, x, anchor, padding, options) { // Insert the placed circle into the interval tree. tree.insert([l, r, i]); } - return {[y]: Y.map(y => y * ky + ty)}; + return { + reindex: true, + [x]: Float64Array.from(I, i => X[i]), + [y]: Float64Array.from(I, i => Y[i] * ky + ty) + }; }); } diff --git a/src/plot.js b/src/plot.js index 857d6fff41..3c6d83a04c 100644 --- a/src/plot.js +++ b/src/plot.js @@ -52,7 +52,6 @@ export function plot(options = {}) { } const scaleDescriptors = Scales(scaleChannels, options); - const scales = ScaleFunctions(scaleDescriptors); const axes = Axes(scaleDescriptors, options); const dimensions = Dimensions(scaleDescriptors, axes, options); @@ -60,9 +59,52 @@ export function plot(options = {}) { autoScaleLabels(scaleChannels, scaleDescriptors, axes, dimensions, options); autoAxisTicks(scaleDescriptors, axes); + // layouts might return new data to scale with existing or new scales + const scales = ScaleFunctions(scaleDescriptors); + const markValues = new Map(); + const newChannels = new Map(); + const newOptions = {}; + for (const mark of marks) { + const channels = markChannels.get(mark) ?? []; + const values = applyScales(channels, scales); + let index = filter(markIndex.get(mark), channels, values); + const rescale = []; + if (mark.layout != null) { + let {reindex, ...newValues} = mark.layout(index, scales, values, dimensions) || {}; + for (let key in newValues) { + let c = newValues[key]; + const {scale} = c; + if (scale) { + if (!newChannels.has(scale)) newChannels.set(scale, []); + newChannels.get(scale).push({scale, value: c.values}); + newOptions[scale] = {...c.options, ...options[scale]}; + values[key] = c.values; + rescale.push([scale, values[key]]); + } else { + values[key] = c; + } + if (reindex) { + index = range(values[key]); + reindex = false; + } + } + } + markValues.set(mark, {index, values, rescale}); + } + const newScaleDescriptors = Scales(newChannels, newOptions); + Object.assign(scaleDescriptors, newScaleDescriptors); + Object.assign(scales, ScaleFunctions(newScaleDescriptors)); + for (const [, {rescale}] of markValues) { + for (const [scale, values] of rescale) { + for (let i = 0; i < values.length; i++) { + values[i] = scales[scale](values[i]); + } + } + } + // When faceting, render axes for fx and fy instead of x and y. - const x = facet !== undefined && scales.fx ? "fx" : "x"; - const y = facet !== undefined && scales.fy ? "fy" : "y"; + const x = facet !== undefined && scaleDescriptors.fx ? "fx" : "x"; + const y = facet !== undefined && scaleDescriptors.fy ? "fy" : "y"; if (axes[x]) marks.unshift(axes[x]); if (axes[y]) marks.unshift(axes[y]); @@ -94,10 +136,7 @@ export function plot(options = {}) { .node(); for (const mark of marks) { - const channels = markChannels.get(mark) ?? []; - let values = applyScales(channels, scales); - const index = filter(markIndex.get(mark), channels, values); - if (mark.layout != null) values = mark.layout(index, scales, values, dimensions); + const {index, values} = markValues.get(mark) || {}; const node = mark.render(index, scales, values, dimensions, axes); if (node != null) svg.appendChild(node); } @@ -220,6 +259,7 @@ class Facet extends Mark { // The following fields are set by initialize: this.marksChannels = undefined; // array of mark channels this.marksIndexByFacet = undefined; // map from facet key to array of mark indexes + this.marksLayouts = undefined; } initialize() { const {index, channels} = super.initialize(); @@ -229,6 +269,7 @@ class Facet extends Mark { const subchannels = []; const marksChannels = this.marksChannels = []; const marksIndexByFacet = this.marksIndexByFacet = facetMap(channels); + const marksLayouts = this.marksLayouts = []; for (const facetKey of facetsKeys) { marksIndexByFacet.set(facetKey, new Array(this.marks.length)); } @@ -260,18 +301,73 @@ class Facet extends Mark { subchannels.push([, channel]); } marksChannels.push(markChannels); + if (mark.layout) marksLayouts.push(mark); } + this.layout = function(index, scales, channels, dimensions) { + 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}; + + this.marksValues = marksChannels.map(channels => applyScales(channels, scales)); + const rescaleChannels = []; + for (let i = 0; i < this.marks.length; ++i) { + const mark = this.marks[i]; + if (!mark.layout) continue; + let values = facetsKeys.map(facet => [ + facet, + mark.layout( + filter(marksIndexByFacet.get(facet)[i], marksChannels[i], this.marksValues[i]), + scales, + this.marksValues[i], + subdimensions + ) + ]); + if (values.some(([, d]) => d.reindex)) { + const index = []; + const newValues = new Map(); + for (const [facet, value] of values) { + const j = index.length; + const newIndex = new Set(); + for (let key in value) { + if (key === "reindex") continue; // TODO: better internal API + if (!newValues.has(key)) newValues.set(key, []); + const V = newValues.get(key); + const {scale, options} = value[key]; + if (scale) Object.assign(V, {scale, options}); + const U = scale !== undefined ? value[key].values : value[key]; + for (let i = 0; i < U.length; i++) { + const k = i + j; + newIndex.add(k); + V[k] = U[i]; + } + } + for (const i of newIndex) index.push(i); + marksIndexByFacet.get(facet)[i] = [...newIndex]; + } + values = Object.fromEntries(newValues); + } else { + values = values[0][1]; + } + this.marksValues[i] = values; + for (let k in values) { + if (values[k].scale !== undefined) { + rescaleChannels.push([rescaleChannels.length, {values: values[k], scale: values[k].scale, options: values[k].options}]); + } + } + } + return Object.fromEntries(rescaleChannels); + }; return {index, channels: [...channels, ...subchannels]}; } render(I, scales, channels, dimensions, axes) { - const {marks, marksChannels, marksIndexByFacet} = this; + const {marks, marksChannels, marksValues, marksIndexByFacet} = this; const {fx, fy} = scales; 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 marksValues = marksChannels.map(channels => applyScales(channels, scales)); return create("svg:g") .call(g => { if (fy && axes.y) { @@ -316,7 +412,6 @@ class Facet extends Mark { const mark = marks[i]; let values = marksValues[i]; const index = filter(marksFacetIndex[i], marksChannels[i], values); - if (mark.layout != null) values = mark.layout(index, scales, values, subdimensions); const node = mark.render(index, scales, values, subdimensions); if (node != null) this.appendChild(node); } diff --git a/src/transforms/hexbin.js b/src/transforms/hexbin.js index 4ff21664bd..4fb8770941 100644 --- a/src/transforms/hexbin.js +++ b/src/transforms/hexbin.js @@ -1,8 +1,8 @@ -import {groups, max} from "d3"; +import {groups} from "d3"; import {basic} from "./basic.js"; import {layout} from "../layouts/index.js"; import {Dot} from "../marks/dot.js"; -import {maybeTuple, range, take, valueof} from "../options.js"; +import {maybeTuple, take, valueof} from "../options.js"; const defaults = { ariaLabel: "hex", @@ -13,18 +13,19 @@ const defaults = { }; // width factor (allows the hexbin transform to work with circular dots!) -const w0 = 1 / Math.sin(Math.PI / 3); +const w0 = Math.sin(Math.PI / 3); function hbin(I, X, Y, r) { - const dx = r * 2 / w0; + const dx = r * 2 * w0; const dy = r * 1.5; + const keys = new Map(); return groups(I, i => { - let px = X[i] / dy; - let py = Y[i] / dx; + let px = X[i] / dx; + let py = Y[i] / dy; if (isNaN(px) || isNaN(py)) return; let pj = Math.round(py), - pi = Math.round(px - (pj & 1) / 2), - py1 = py - pj; + 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, @@ -33,63 +34,46 @@ function hbin(I, X, Y, r) { py2 = py - pj2; if (px1 * px1 + py1 * py1 > px2 * px2 + py2 * py2) pi = pi2 + (pj & 1 ? 1 : -1) / 2, pj = pj2; } - return `${pi}|${pj}`; + const key = `${pi}|${pj}`; + keys.set(key, [pi, pj]); + return key; }) .filter(([p]) => p) .map(([p, bin]) => { - const [pi, pj] = p.split("|"); - bin.x = (+pi + (pj & 1) / 2) * dx; - bin.y = +pj * dy; + const [pi, pj] = keys.get(p); + bin.x = (pi + (pj & 1) / 2) * dx; + bin.y = pj * dy; return bin; }); } -function hexbinLayout({_store, radius, r: _r, opacity: _o, fill: _f, text: _t, title: _l, value: _v}, options) { +function hexbinLayout({radius, r, opacity, fill, text, title, value}, options) { radius = +radius; - _r = !!_r; - _o = !!_o; - _f = !!_f; - return layout({_store, ...options}, function(I, {r, color, opacity}, {x: X, y: Y}) { - if (!_store.channels) { - const bins = _store.facets.map(I => hbin(I, X, Y, radius)); - const values = bins.map(b => valueof(b, _v)); - const maxValue = max(values.flat()); - color = _f && color.copy().domain([1, maxValue]); - opacity = _o && opacity.copy().domain([1, maxValue]); - r = _r && r && r.copy().domain([0, maxValue]).range([0, radius / w0]); - const {data} = this; - _store.channels = Array.from(bins, (bin, i) => ({ - x: valueof(bin, "x"), - y: valueof(bin, "y"), - ..._t != null && {text: valueof(bin.map(I => take(data, I)), _t)}, - ..._l != null && {title: valueof(bin.map(I => take(data, I)), _l)}, - ..._r && r && {r: values[i].map(r)}, - ..._o && opacity && {fillOpacity: values[i].map(opacity)}, - ..._f && color && {fill: values[i].map(color)} - })); - } - for (let j = 0; j < _store.facets.length; ++j) { - if (_store.facets[j].some(i => I.includes(i))) { - const channels = _store.channels[j]; - // mutates I! - I.splice(0, I.length, ...range(channels.x)); - return channels; - } - } - throw new Error("what are we doing here?"); + r = !!r; + opacity = !!opacity; + fill = !!fill; + return layout(options, function(index, scales, {x: X, y: Y}) { + const bins = hbin(index, X, Y, radius); + const values = (r || opacity || fill) && valueof(bins, value); + const {data} = this; + return { + reindex: true, // we're sending transformed data! + x: valueof(bins, "x"), + y: valueof(bins, "y"), + ...text != null && {text: valueof(bins, I => text(take(data, I)))}, + ...title != null && {title: valueof(bins, I => title(take(data, I)))}, + ...r && {r: {values, scale: "r", options: {label: "frequency", range: [0, radius * w0]}}}, + ...opacity && {fillOpacity: {values, scale: "opacity", options: {label: "frequency"}}}, + ...fill && {fill: {values, scale: "color", options: {scheme: "blues", label: "frequency"}}} + }; }); } -// The transform does nothing but divert some information that is necessary -// to do the layout on multiple facets -// It is a bit of a hack: we want to do all facets at once in order to compute -// the maximum value. We also convert the facets to plain arrays, since we’re +// The transform does nothing but convert the facets to plain arrays, since we’re // going to splice them export function hexbinTransform(hexbinOptions, options) { - const _store = {}; - return basic(hexbinLayout({_store, ...hexbinOptions}, options), (data, facets) => { + return basic(hexbinLayout(hexbinOptions, options), (data, facets) => { facets = Array.from(facets, facet => Array.from(facet)); - _store.facets = facets; return {data, facets}; }); } @@ -103,17 +87,17 @@ export function hexbin(data, options) { export function hexbinR({x, y, value = "length", radius = 10, title, ...options} = {}) { if (options.frameAnchor === undefined) ([x, y] = maybeTuple(x, y)); - return hexbinTransform({value, radius, title, r: true}, {...defaults, x, y, r: () => 1, ...options}); + return hexbinTransform({value, radius, title, r: true}, {...defaults, x, y, ...options}); } export function hexbinFill({x, y, value = "length", radius = 10, title, ...options} = {}) { if (options.frameAnchor === undefined) ([x, y] = maybeTuple(x, y)); - return hexbinTransform({value, radius, title, fill: true}, {...defaults, x, y, r: radius, fill: () => 1, ...options}); + return hexbinTransform({value, radius, title, fill: true}, {...defaults, x, y, r: radius, ...options}); } export function hexbinOpacity({x, y, value = "length", radius = 10, title, ...options} = {}) { if (options.frameAnchor === undefined) ([x, y] = maybeTuple(x, y)); - return hexbinTransform({value, radius, title, opacity: true}, {...defaults, x, y, r: radius, opacity: () => 1,...options}); + return hexbinTransform({value, radius, title, opacity: true}, {...defaults, x, y, r: radius, ...options}); } export function hexbinText({x, y, value = "length", radius = 10, text = d => d.length, ...options} = {}) { diff --git a/test/output/hexbin.svg b/test/output/hexbin.svg index 067ff5d1e4..edfa6d3334 100644 --- a/test/output/hexbin.svg +++ b/test/output/hexbin.svg @@ -109,149 +109,137 @@ - - + + + 9 penguins. + + 8 penguins. - - 4 penguins. + + 2 penguins. - + + 2 penguins. + + 3 penguins. - - 10 penguins. + + 2 penguins. - - 6 penguins. + + 9 penguins. - - 4 penguins. + + 8 penguins. - - 3 penguins. + + 2 penguins. - + 11 penguins. - + 1 penguins. - - 5 penguins. - - - 4 penguins. - - + 7 penguins. - - 1 penguins. + + 3 penguins. - + 1 penguins. - - 1 penguins. + + 3 penguins. - + 1 penguins. - + 1 penguins. - + 1 penguins. - + 1 penguins. - + 1 penguins. - - 6 penguins. + + 7 penguins. - + 4 penguins. - - 1 penguins. - - - 5 penguins. + + 3 penguins. - + 1 penguins. - - 3 penguins. + + 2 penguins. - + 2 penguins. - - 3 penguins. + + 2 penguins. - - 3 penguins. + + 4 penguins. - + 1 penguins. - - 1 penguins. + + 2 penguins. - + 1 penguins. - + 1 penguins. - + 1 penguins. - - 2 penguins. - - - 4 penguins. + + 11 penguins. - + 10 penguins. - - 11 penguins. + + 14 penguins. - - 6 penguins. - - - 1 penguins. + + 3 penguins. - - 7 penguins. + + 4 penguins. - + 7 penguins. - - 5 penguins. - - - 1 penguins. + + 2 penguins. - - 1 penguins. + + 4 penguins. - + 1 penguins. - + 1 penguins. - + 1 penguins. @@ -425,158 +413,155 @@ - - - 15 penguins. - - - 2 penguins. + + + 10 penguins. - - 5 penguins. + + 3 penguins. - - 2 penguins. + + 4 penguins. - + 1 penguins. - - 3 penguins. + + 2 penguins. - + 1 penguins. - - 3 penguins. - - - 3 penguins. - - + 4 penguins. - + 4 penguins. - - 9 penguins. + + 1 penguins. - + + 14 penguins. + + 1 penguins. - - 5 penguins. + + 2 penguins. - + 6 penguins. - + + 5 penguins. + + 3 penguins. - + 1 penguins. - - 1 penguins. + + 5 penguins. - + 1 penguins. - - 1 penguins. + + 2 penguins. - + 1 penguins. - + 1 penguins. - - 7 penguins. + + 1 penguins. - - 5 penguins. + + 8 penguins. - - 3 penguins. + + 9 penguins. - - 3 penguins. + + 4 penguins. - - 1 penguins. + + 2 penguins. - - 3 penguins. + + 6 penguins. - + 1 penguins. - - 7 penguins. - - + 1 penguins. - + 1 penguins. - - 2 penguins. + + 1 penguins. - - 11 penguins. + + 1 penguins. - - 13 penguins. + + 6 penguins. - - 2 penguins. + + 14 penguins. - + 3 penguins. - - 5 penguins. + + 7 penguins. - - 6 penguins. + + 4 penguins. - - 5 penguins. + + 4 penguins. - - 1 penguins. + + 3 penguins. - + 1 penguins. - - 2 penguins. + + 4 penguins. - + 1 penguins. - - 1 penguins. + + 2 penguins. - - 3 penguins. + + 2 penguins. - - 1 penguins. + + 3 penguins. - + 1 penguins. - + 3 penguins. - + + 1 penguins. + + 1 penguins. - + 1 penguins. @@ -753,32 +738,29 @@ - - + + 1 penguins. - + 1 penguins. - - 1 penguins. - - - 1 penguins. + + 2 penguins. - + 1 penguins. - + 1 penguins. - + 1 penguins. - + 1 penguins. - + 1 penguins. diff --git a/test/output/hexbinDot.svg b/test/output/hexbinDot.svg index 957d52a8ae..44361343c5 100644 --- a/test/output/hexbinDot.svg +++ b/test/output/hexbinDot.svg @@ -81,110 +81,113 @@ culmen_depth_mm → - - - - - - - - - - - - - - - - - - - + + + + + + + + + + - - - - - - - + + + + + + + + + + + + + + + + + - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - - - - - - - + + + + + - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + - - - + - + + + + + + + + - - - - - - - + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/hexbinR.svg b/test/output/hexbinR.svg index 304e7f0032..f990ba626d 100644 --- a/test/output/hexbinR.svg +++ b/test/output/hexbinR.svg @@ -93,166 +93,160 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - + + - - + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + - - - - - + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + - - - + + diff --git a/test/output/hexbinTextOpacity.svg b/test/output/hexbinTextOpacity.svg index 48ab06d527..dcf47fc386 100644 --- a/test/output/hexbinTextOpacity.svg +++ b/test/output/hexbinTextOpacity.svg @@ -75,155 +75,148 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - 58385423811554121111311332551223111111123941961518123111 + 5623634511815455143111114341212122321112493487627111111 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - 10231111421912311464122122111125422212121173185256612141112212121121111 + 122211213311641022313221211112631221213116131583512252311122111111111 - - - - - - - - - + + + + + + + - 111111111 + 1121211 \ No newline at end of file diff --git a/test/plots/hexbin.js b/test/plots/hexbin.js index 50c7c28a36..8d18f31f27 100644 --- a/test/plots/hexbin.js +++ b/test/plots/hexbin.js @@ -21,7 +21,6 @@ export default async function() { x: "culmen_depth_mm", y: "culmen_length_mm", radius: 12, - fill: "brown", title: bin => `${bin.length} penguins.` }), Plot.dot(penguins, { From d0ba485e8cbd01440be137e696a9f97df0ce6c56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Wed, 16 Feb 2022 18:45:12 +0100 Subject: [PATCH 3/8] reindexing after dodge had introduced a bug: you could not set the fill channel; this solution creates a shared Y between facets (attached to the channels object). --- src/layouts/dodge.js | 14 +- test/output/penguinFacetDodgeIsland.html | 448 +++++++++++++++++++++++ test/plots/index.js | 1 + test/plots/penguin-facet-dodge-island.js | 22 ++ 4 files changed, 477 insertions(+), 8 deletions(-) create mode 100644 test/output/penguinFacetDodgeIsland.html create mode 100644 test/plots/penguin-facet-dodge-island.js diff --git a/src/layouts/dodge.js b/src/layouts/dodge.js index 2c4644f74e..d34408b79c 100644 --- a/src/layouts/dodge.js +++ b/src/layouts/dodge.js @@ -40,13 +40,14 @@ export function dodgeY(dodgeOptions = {}, options = {}) { function dodge(y, x, anchor, padding, options) { const [, r] = maybeNumberChannel(options.r, 3); - return layout(options, (I, scales, {[x]: X, r: R}, dimensions) => { + return layout(options, (I, scales, channels, dimensions) => { + let {[x]: X, [y]: Y, r: R} = channels; 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(I, i => R[i]) : r) + padding); else ky = 1; - if (!R) R = new Float64Array(X.length).fill(r); - const Y = new Float64Array(X.length); + if (!R) R = channels.r = new Float64Array(X.length).fill(r); + if (!Y) Y = channels.y = new Float64Array(X.length).fill(0); const tree = IntervalTree(); for (const i of I) { const intervals = []; @@ -75,11 +76,8 @@ function dodge(y, x, anchor, padding, options) { // Insert the placed circle into the interval tree. tree.insert([l, r, i]); } - return { - reindex: true, - [x]: Float64Array.from(I, i => X[i]), - [y]: Float64Array.from(I, i => Y[i] * ky + ty) - }; + for (const i of I) Y[i] = Y[i] * ky + ty; + return {[y]: Y}; }); } diff --git a/test/output/penguinFacetDodgeIsland.html b/test/output/penguinFacetDodgeIsland.html new file mode 100644 index 0000000000..350776e4dc --- /dev/null +++ b/test/output/penguinFacetDodgeIsland.html @@ -0,0 +1,448 @@ +
+
+ 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/plots/index.js b/test/plots/index.js index e319d4bf4d..a439b10cdc 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -93,6 +93,7 @@ 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 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-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} + }); +} From c80d7dfab9ed8019d41112d0bf9cf61c8185d139 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 17 Feb 2022 23:56:12 +0100 Subject: [PATCH 4/8] reuse transform/bin reducers cleaner API (Plot.hex is the shortcut, and Plot.hexbin the options transform) --- src/index.js | 2 +- src/layouts/hexbin.js | 84 ++++++ src/transforms/hexbin.js | 106 -------- test/output/hexbin.svg | 414 ++++++++---------------------- test/output/hexbinDot.html | 223 ++++++++++++++++ test/output/hexbinDot.svg | 193 -------------- test/output/hexbinR.svg | 6 +- test/output/hexbinTextOpacity.svg | 6 +- test/plots/hexbin-dot.js | 5 +- test/plots/hexbin-r.js | 2 +- test/plots/hexbin-text-opacity.js | 4 +- test/plots/hexbin.js | 4 +- 12 files changed, 427 insertions(+), 622 deletions(-) create mode 100644 src/layouts/hexbin.js delete mode 100644 src/transforms/hexbin.js create mode 100644 test/output/hexbinDot.html delete mode 100644 test/output/hexbinDot.svg diff --git a/src/index.js b/src/index.js index 4271bed8b1..03c5b6edce 100644 --- a/src/index.js +++ b/src/index.js @@ -15,10 +15,10 @@ export {TickX, TickY, tickX, tickY} from "./marks/tick.js"; export {Vector, vector} from "./marks/vector.js"; export {valueof} from "./options.js"; export {dodgeX, dodgeY} from "./layouts/dodge.js"; +export {hex, hexbin} from "./layouts/hexbin.js"; export {filter, reverse, sort, shuffle} from "./transforms/basic.js"; export {bin, binX, binY} from "./transforms/bin.js"; export {group, groupX, groupY, groupZ} from "./transforms/group.js"; -export {hexbin, hexbinFill, hexbinOpacity, hexbinR, hexbinText} from "./transforms/hexbin.js"; export {normalize, normalizeX, normalizeY} from "./transforms/normalize.js"; export {map, mapX, mapY} from "./transforms/map.js"; export {window, windowX, windowY} from "./transforms/window.js"; diff --git a/src/layouts/hexbin.js b/src/layouts/hexbin.js new file mode 100644 index 0000000000..f001772352 --- /dev/null +++ b/src/layouts/hexbin.js @@ -0,0 +1,84 @@ +import {groups} from "d3"; +import {layout} from "./index.js"; +import {basic} from "../transforms/basic.js"; +import {maybeOutputs, hasOutput} from "../transforms/group.js"; +import {Dot} from "../marks/dot.js"; +import {valueof} from "../options.js"; + +const defaults = { + ariaLabel: "hex", + symbol: "hexagon" +}; + +// width factor (allows the hexbin transform to work with circular dots!) +const w0 = Math.sin(Math.PI / 3); + +function hbin(I, X, Y, r) { + const dx = r * 2 * w0; + const dy = r * 1.5; + const keys = new Map(); + return groups(I, i => { + let px = X[i] / dx; + let py = Y[i] / dy; + if (isNaN(px) || isNaN(py)) return; + 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}`; + keys.set(key, [pi, pj]); + return key; + }) + .filter(([p]) => p) + .map(([p, bin]) => { + const [pi, pj] = keys.get(p); + bin.x = (pi + (pj & 1) / 2) * dx; + bin.y = pj * dy; + return bin; + }); +} + +// Allow hexbin options to be specified as part of outputs; merge them into options. +function mergeOptions({radius = 10, ...outputs}, options) { + return [outputs, {radius, ...options}]; +} + +function hexbinLayout(radius, outputs, options) { + outputs = maybeOutputs(outputs, options); + const rescales = { + r: {scale: "r", options: {range: [0, radius * w0]}}, + fill: {scale: "color"}, + fillOpacity: {scale: "opacity"} + }; + return layout({...defaults, ...options}, function(index, scales, {x: X, y: Y}) { + const bins = hbin(index, X, Y, radius); + for (const o of outputs) o.initialize(this.data); + for (const bin of bins) { + for (const o of outputs) o.reduce(bin); + } + return { + reindex: true, // we're sending transformed data! + x: valueof(bins, "x"), + y: valueof(bins, "y"), + ...!hasOutput(outputs, "r") && {r: new Float64Array(bins.length).fill(radius)}, // TODO: constant?? + ...Object.fromEntries(outputs.map(({name, output}) => [name, name in rescales ? {values: output.transform(), ...rescales[name]} : output.transform()])) + }; + }); +} + +export function hexbin(outputs, options) { + ([outputs, options] = mergeOptions(outputs, options)); + const {radius, ...inputs} = options; + return basic(hexbinLayout(radius, outputs, inputs), (data, facets) => ({data, facets})); +} + +export function hex(data, options) { + return new Dot(data, hexbin({fill: "count"}, options)); +} diff --git a/src/transforms/hexbin.js b/src/transforms/hexbin.js deleted file mode 100644 index 4fb8770941..0000000000 --- a/src/transforms/hexbin.js +++ /dev/null @@ -1,106 +0,0 @@ -import {groups} from "d3"; -import {basic} from "./basic.js"; -import {layout} from "../layouts/index.js"; -import {Dot} from "../marks/dot.js"; -import {maybeTuple, take, valueof} from "../options.js"; - -const defaults = { - ariaLabel: "hex", - symbol: "hexagon", - fill: "currentColor", - stroke: "currentColor", - strokeWidth: 0.5 -}; - -// width factor (allows the hexbin transform to work with circular dots!) -const w0 = Math.sin(Math.PI / 3); - -function hbin(I, X, Y, r) { - const dx = r * 2 * w0; - const dy = r * 1.5; - const keys = new Map(); - return groups(I, i => { - let px = X[i] / dx; - let py = Y[i] / dy; - if (isNaN(px) || isNaN(py)) return; - 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}`; - keys.set(key, [pi, pj]); - return key; - }) - .filter(([p]) => p) - .map(([p, bin]) => { - const [pi, pj] = keys.get(p); - bin.x = (pi + (pj & 1) / 2) * dx; - bin.y = pj * dy; - return bin; - }); -} - -function hexbinLayout({radius, r, opacity, fill, text, title, value}, options) { - radius = +radius; - r = !!r; - opacity = !!opacity; - fill = !!fill; - return layout(options, function(index, scales, {x: X, y: Y}) { - const bins = hbin(index, X, Y, radius); - const values = (r || opacity || fill) && valueof(bins, value); - const {data} = this; - return { - reindex: true, // we're sending transformed data! - x: valueof(bins, "x"), - y: valueof(bins, "y"), - ...text != null && {text: valueof(bins, I => text(take(data, I)))}, - ...title != null && {title: valueof(bins, I => title(take(data, I)))}, - ...r && {r: {values, scale: "r", options: {label: "frequency", range: [0, radius * w0]}}}, - ...opacity && {fillOpacity: {values, scale: "opacity", options: {label: "frequency"}}}, - ...fill && {fill: {values, scale: "color", options: {scheme: "blues", label: "frequency"}}} - }; - }); -} - -// The transform does nothing but convert the facets to plain arrays, since we’re -// going to splice them -export function hexbinTransform(hexbinOptions, options) { - return basic(hexbinLayout(hexbinOptions, options), (data, facets) => { - facets = Array.from(facets, facet => Array.from(facet)); - return {data, facets}; - }); -} - - -// todo: hexbinMesh, or a mesh option? -// todo: clip? -export function hexbin(data, options) { - return new Dot(data, hexbinFill(options)); -} - -export function hexbinR({x, y, value = "length", radius = 10, title, ...options} = {}) { - if (options.frameAnchor === undefined) ([x, y] = maybeTuple(x, y)); - return hexbinTransform({value, radius, title, r: true}, {...defaults, x, y, ...options}); -} - -export function hexbinFill({x, y, value = "length", radius = 10, title, ...options} = {}) { - if (options.frameAnchor === undefined) ([x, y] = maybeTuple(x, y)); - return hexbinTransform({value, radius, title, fill: true}, {...defaults, x, y, r: radius, ...options}); -} - -export function hexbinOpacity({x, y, value = "length", radius = 10, title, ...options} = {}) { - if (options.frameAnchor === undefined) ([x, y] = maybeTuple(x, y)); - return hexbinTransform({value, radius, title, opacity: true}, {...defaults, x, y, r: radius, ...options}); -} - -export function hexbinText({x, y, value = "length", radius = 10, text = d => d.length, ...options} = {}) { - if (options.frameAnchor === undefined) ([x, y] = maybeTuple(x, y)); - return hexbinTransform({value, radius, text, r: true}, {x, y, r: () => 1, ...options}); -} diff --git a/test/output/hexbin.svg b/test/output/hexbin.svg index edfa6d3334..254e6035ff 100644 --- a/test/output/hexbin.svg +++ b/test/output/hexbin.svg @@ -109,139 +109,51 @@ - - - 9 penguins. - - - 8 penguins. - - - 2 penguins. - - - 2 penguins. - - - 3 penguins. - - - 2 penguins. - - - 9 penguins. - - - 8 penguins. - - - 2 penguins. - - - 11 penguins. - - - 1 penguins. - - - 7 penguins. - - - 3 penguins. - - - 1 penguins. - - - 3 penguins. - - - 1 penguins. - - - 1 penguins. - - - 1 penguins. - - - 1 penguins. - - - 1 penguins. - - - 7 penguins. - - - 4 penguins. - - - 3 penguins. - - - 1 penguins. - - - 2 penguins. - - - 2 penguins. - - - 2 penguins. - - - 4 penguins. - - - 1 penguins. - - - 2 penguins. - - - 1 penguins. - - - 1 penguins. - - - 1 penguins. - - - 11 penguins. - - - 10 penguins. - - - 14 penguins. - - - 3 penguins. - - - 4 penguins. - - - 7 penguins. - - - 2 penguins. - - - 4 penguins. - - - 1 penguins. - - - 1 penguins. - - - 1 penguins. - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -413,157 +325,57 @@ - - - 10 penguins. - - - 3 penguins. - - - 4 penguins. - - - 1 penguins. - - - 2 penguins. - - - 1 penguins. - - - 4 penguins. - - - 4 penguins. - - - 1 penguins. - - - 14 penguins. - - - 1 penguins. - - - 2 penguins. - - - 6 penguins. - - - 5 penguins. - - - 3 penguins. - - - 1 penguins. - - - 5 penguins. - - - 1 penguins. - - - 2 penguins. - - - 1 penguins. - - - 1 penguins. - - - 1 penguins. - - - 8 penguins. - - - 9 penguins. - - - 4 penguins. - - - 2 penguins. - - - 6 penguins. - - - 1 penguins. - - - 1 penguins. - - - 1 penguins. - - - 1 penguins. - - - 1 penguins. - - - 6 penguins. - - - 14 penguins. - - - 3 penguins. - - - 7 penguins. - - - 4 penguins. - - - 4 penguins. - - - 3 penguins. - - - 1 penguins. - - - 4 penguins. - - - 1 penguins. - - - 2 penguins. - - - 2 penguins. - - - 3 penguins. - - - 1 penguins. - - - 3 penguins. - - - 1 penguins. - - - 1 penguins. - - - 1 penguins. - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -738,31 +550,15 @@ - - - 1 penguins. - - - 1 penguins. - - - 2 penguins. - - - 1 penguins. - - - 1 penguins. - - - 1 penguins. - - - 1 penguins. - - - 1 penguins. - + + + + + + + + + diff --git a/test/output/hexbinDot.html b/test/output/hexbinDot.html new file mode 100644 index 0000000000..569746072c --- /dev/null +++ b/test/output/hexbinDot.html @@ -0,0 +1,223 @@ +
+ + + + + 3,000 + + + 4,000 + + + 5,000 + + + 6,000 + body mass (g) + + + + + + 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/hexbinDot.svg b/test/output/hexbinDot.svg deleted file mode 100644 index 44361343c5..0000000000 --- a/test/output/hexbinDot.svg +++ /dev/null @@ -1,193 +0,0 @@ - - - - - 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/hexbinR.svg b/test/output/hexbinR.svg index f990ba626d..a9ed401023 100644 --- a/test/output/hexbinR.svg +++ b/test/output/hexbinR.svg @@ -92,7 +92,7 @@
- + @@ -161,7 +161,7 @@ - + @@ -238,7 +238,7 @@ - + diff --git a/test/output/hexbinTextOpacity.svg b/test/output/hexbinTextOpacity.svg index dcf47fc386..34e5b24b53 100644 --- a/test/output/hexbinTextOpacity.svg +++ b/test/output/hexbinTextOpacity.svg @@ -74,7 +74,7 @@ - + @@ -135,7 +135,7 @@ - + @@ -207,7 +207,7 @@ - + diff --git a/test/plots/hexbin-dot.js b/test/plots/hexbin-dot.js index 1f372e169b..3205b389cd 100644 --- a/test/plots/hexbin-dot.js +++ b/test/plots/hexbin-dot.js @@ -5,7 +5,8 @@ export default async function() { const penguins = await d3.csv("data/penguins.csv", d3.autoType); return Plot.plot({ marks: [ - Plot.dot(penguins, Plot.hexbinR({x: "culmen_depth_mm", y: "culmen_length_mm", radius: 20})) - ] + Plot.dot(penguins, Plot.hexbin({r: "count", fill: "median"}, {x: "culmen_depth_mm", y: "culmen_length_mm", fill: "body_mass_g", radius: 20, symbol: "circle"})) + ], + color: {scheme: "viridis", legend: true, label: "body mass (g)"} }); } diff --git a/test/plots/hexbin-r.js b/test/plots/hexbin-r.js index 38a4d3d5a9..515f53841b 100644 --- a/test/plots/hexbin-r.js +++ b/test/plots/hexbin-r.js @@ -13,7 +13,7 @@ export default async function() { }, marks: [ Plot.frame(), - Plot.dot(penguins, Plot.hexbinR({x: "culmen_depth_mm", y: "culmen_length_mm"})) + Plot.dot(penguins, Plot.hexbin({r: "count"}, {x: "culmen_depth_mm", y: "culmen_length_mm", fill: "#333"})) ] }); } diff --git a/test/plots/hexbin-text-opacity.js b/test/plots/hexbin-text-opacity.js index 9fe42aa7a5..8ab4086588 100644 --- a/test/plots/hexbin-text-opacity.js +++ b/test/plots/hexbin-text-opacity.js @@ -14,8 +14,8 @@ export default async function() { inset: 14, marks: [ Plot.frame(), - Plot.dot(penguins, Plot.hexbinOpacity({x: "culmen_depth_mm", y: "culmen_length_mm", fill: "brown"})), - Plot.text(penguins, Plot.hexbinText({x: "culmen_depth_mm", y: "culmen_length_mm"})) + 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.js b/test/plots/hexbin.js index 8d18f31f27..d6d12a0ad1 100644 --- a/test/plots/hexbin.js +++ b/test/plots/hexbin.js @@ -17,11 +17,11 @@ export default async function() { }, marks: [ Plot.frame(), - Plot.hexbin(penguins, { + Plot.hex(penguins, { x: "culmen_depth_mm", y: "culmen_length_mm", radius: 12, - title: bin => `${bin.length} penguins.` + strokeWidth: 0.5 }), Plot.dot(penguins, { x: "culmen_depth_mm", From d14b0b375fa24765334ed19be59d612fa58170db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 18 Feb 2022 06:58:40 +0100 Subject: [PATCH 5/8] remove the hex shortcut --- src/index.js | 2 +- src/layouts/hexbin.js | 5 ----- test/plots/hexbin.js | 4 ++-- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/index.js b/src/index.js index 03c5b6edce..98394fff2b 100644 --- a/src/index.js +++ b/src/index.js @@ -15,7 +15,7 @@ export {TickX, TickY, tickX, tickY} from "./marks/tick.js"; export {Vector, vector} from "./marks/vector.js"; export {valueof} from "./options.js"; export {dodgeX, dodgeY} from "./layouts/dodge.js"; -export {hex, hexbin} from "./layouts/hexbin.js"; +export {hexbin} from "./layouts/hexbin.js"; export {filter, reverse, sort, shuffle} from "./transforms/basic.js"; export {bin, binX, binY} from "./transforms/bin.js"; export {group, groupX, groupY, groupZ} from "./transforms/group.js"; diff --git a/src/layouts/hexbin.js b/src/layouts/hexbin.js index f001772352..626fd8f899 100644 --- a/src/layouts/hexbin.js +++ b/src/layouts/hexbin.js @@ -2,7 +2,6 @@ import {groups} from "d3"; import {layout} from "./index.js"; import {basic} from "../transforms/basic.js"; import {maybeOutputs, hasOutput} from "../transforms/group.js"; -import {Dot} from "../marks/dot.js"; import {valueof} from "../options.js"; const defaults = { @@ -78,7 +77,3 @@ export function hexbin(outputs, options) { const {radius, ...inputs} = options; return basic(hexbinLayout(radius, outputs, inputs), (data, facets) => ({data, facets})); } - -export function hex(data, options) { - return new Dot(data, hexbin({fill: "count"}, options)); -} diff --git a/test/plots/hexbin.js b/test/plots/hexbin.js index d6d12a0ad1..efbb630a42 100644 --- a/test/plots/hexbin.js +++ b/test/plots/hexbin.js @@ -17,12 +17,12 @@ export default async function() { }, marks: [ Plot.frame(), - Plot.hex(penguins, { + Plot.dot(penguins, Plot.hexbin({fill: "count"}, { x: "culmen_depth_mm", y: "culmen_length_mm", radius: 12, strokeWidth: 0.5 - }), + })), Plot.dot(penguins, { x: "culmen_depth_mm", y: "culmen_length_mm", From c4f31c1044a00b5087d12b16b59ac4422d86de03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 18 Feb 2022 14:46:10 +0100 Subject: [PATCH 6/8] check x,y --- src/layouts/hexbin.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/layouts/hexbin.js b/src/layouts/hexbin.js index 626fd8f899..5ce69fd6cc 100644 --- a/src/layouts/hexbin.js +++ b/src/layouts/hexbin.js @@ -56,6 +56,9 @@ function hexbinLayout(radius, outputs, options) { fill: {scale: "color"}, fillOpacity: {scale: "opacity"} }; + const {x, y} = options; + if (x == null) throw new Error("missing channel: x"); + if (y == null) throw new Error("missing channel: y"); return layout({...defaults, ...options}, function(index, scales, {x: X, y: Y}) { const bins = hbin(index, X, Y, radius); for (const o of outputs) o.initialize(this.data); From 3320e9300c86db56698171ed3ab4889d76c5904c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 18 Feb 2022 15:25:30 +0100 Subject: [PATCH 7/8] checked and fixed all reducers --- src/layouts/hexbin.js | 12 +- test/output/hexbinR.html | 574 +++++++++++++++++++++++++++++++++++++++ test/output/hexbinR.svg | 253 ----------------- test/plots/hexbin-r.js | 3 +- 4 files changed, 587 insertions(+), 255 deletions(-) create mode 100644 test/output/hexbinR.html delete mode 100644 test/output/hexbinR.svg diff --git a/src/layouts/hexbin.js b/src/layouts/hexbin.js index 5ce69fd6cc..b0cc3f07f5 100644 --- a/src/layouts/hexbin.js +++ b/src/layouts/hexbin.js @@ -50,10 +50,17 @@ function mergeOptions({radius = 10, ...outputs}, options) { } function hexbinLayout(radius, outputs, options) { + // we defer to Plot.bin’s reducers, but some of them are not supported + for (const reduce of Object.values(outputs)) { + if (typeof reduce === "string" + && !reduce.match(/^(first|last|count|distinct|sum|deviation|min|min-index|max|max-index|mean|median|variance|mode|proportion|proportion-facet)$/i)) + throw new Error(`invalid reduce ${reduce}`); + } outputs = maybeOutputs(outputs, options); const rescales = { r: {scale: "r", options: {range: [0, radius * w0]}}, fill: {scale: "color"}, + stroke: {scale: "color"}, fillOpacity: {scale: "opacity"} }; const {x, y} = options; @@ -61,7 +68,10 @@ function hexbinLayout(radius, outputs, options) { if (y == null) throw new Error("missing channel: y"); return layout({...defaults, ...options}, function(index, scales, {x: X, y: Y}) { const bins = hbin(index, X, Y, radius); - for (const o of outputs) o.initialize(this.data); + for (const o of outputs) { + o.initialize(this.data); + o.scope("facet", index); + } for (const bin of bins) { for (const o of outputs) o.reduce(bin); } diff --git a/test/output/hexbinR.html b/test/output/hexbinR.html new file mode 100644 index 0000000000..67275000fe --- /dev/null +++ b/test/output/hexbinR.html @@ -0,0 +1,574 @@ +
+ + + + + 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.036 + + + 0.024 + + + 0.012 + + + 0.018 + + + 0.018 + + + 0.012 + + + 0.006 + + + 0.042 + + + 0.006 + + + 0.018 + + + 0.018 + + + 0.042 + + + 0.006 + + + 0.024 + + + 0.024 + + + 0.018 + + + 0.018 + + + 0.006 + + + 0.024 + + + 0.018 + + + 0.012 + + + 0.006 + + + 0.006 + + + 0.012 + + + 0.006 + + + 0.006 + + + 0.024 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.024 + + + 0.006 + + + 0.012 + + + 0.012 + + + 0.006 + + + 0.012 + + + 0.006 + + + 0.006 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.018 + + + 0.018 + + + 0.042 + + + 0.012 + + + 0.006 + + + 0.018 + + + 0.018 + + + 0.048 + + + 0.006 + + + 0.018 + + + 0.024 + + + 0.048 + + + 0.018 + + + 0.024 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + + + + + + 0.03 + + + 0.012 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.012 + + + 0.012 + + + 0.006 + + + 0.048 + + + 0.054 + + + 0.012 + + + 0.012 + + + 0.006 + + + 0.006 + + + 0.018 + + + 0.012 + + + 0.018 + + + 0.006 + + + 0.018 + + + 0.006 + + + 0.012 + + + 0.006 + + + 0.012 + + + 0.006 + + + 0.018 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.012 + + + 0.03 + + + 0.012 + + + 0.018 + + + 0.012 + + + 0.012 + + + 0.006 + + + 0.006 + + + 0.03 + + + 0.03 + + + 0.006 + + + 0.018 + + + 0.006 + + + 0.012 + + + 0.006 + + + 0.012 + + + 0.042 + + + 0.006 + + + 0.03 + + + 0.024 + + + 0.03 + + + 0.065 + + + 0.006 + + + 0.018 + + + 0.006 + + + 0.006 + + + 0.012 + + + 0.012 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.006 + + + 0.018 + + + 0.006 + + + 0.006 + + + + + + + + 0.111 + + + 0.111 + + + 0.222 + + + 0.111 + + + 0.111 + + + 0.111 + + + 0.111 + + + 0.111 + + + + +
\ No newline at end of file diff --git a/test/output/hexbinR.svg b/test/output/hexbinR.svg deleted file mode 100644 index a9ed401023..0000000000 --- a/test/output/hexbinR.svg +++ /dev/null @@ -1,253 +0,0 @@ - - - - - 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 → - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/test/plots/hexbin-r.js b/test/plots/hexbin-r.js index 515f53841b..e63eb72ae5 100644 --- a/test/plots/hexbin-r.js +++ b/test/plots/hexbin-r.js @@ -6,6 +6,7 @@ export default async function() { 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", @@ -13,7 +14,7 @@ export default async function() { }, marks: [ Plot.frame(), - Plot.dot(penguins, Plot.hexbin({r: "count"}, {x: "culmen_depth_mm", y: "culmen_length_mm", fill: "#333"})) + Plot.dot(penguins, Plot.hexbin({title: "proportion-facet", r: "count", fill: "proportion-facet"}, {x: "culmen_depth_mm", y: "culmen_length_mm", strokeWidth: 1})) ] }); } From 67384c179ffc0542068de71ec80b5a71e06cc49f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 18 Feb 2022 16:25:03 +0100 Subject: [PATCH 8/8] hexgrid (should work with the clip: true option in Plot 0.4.1) --- src/index.js | 1 + src/marks/hexgrid.js | 52 ++++++++++++++++++++++++++++++++++++++++++ test/output/hexbin.svg | 50 +++++++++++++++++----------------------- test/plots/hexbin.js | 2 +- 4 files changed, 75 insertions(+), 30 deletions(-) create mode 100644 src/marks/hexgrid.js diff --git a/src/index.js b/src/index.js index 98394fff2b..bc39443542 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,7 @@ export {Arrow, arrow} from "./marks/arrow.js"; export {BarX, BarY, barX, barY} from "./marks/bar.js"; export {Cell, cell, cellX, cellY} from "./marks/cell.js"; export {Dot, dot, dotX, dotY} from "./marks/dot.js"; +export {Hexgrid, hexgrid} from "./marks/hexgrid.js"; export {Frame, frame} from "./marks/frame.js"; export {Image, image} from "./marks/image.js"; export {Line, line, lineX, lineY} from "./marks/line.js"; diff --git a/src/marks/hexgrid.js b/src/marks/hexgrid.js new file mode 100644 index 0000000000..bf0c6e5ebe --- /dev/null +++ b/src/marks/hexgrid.js @@ -0,0 +1,52 @@ +import {create} from "d3"; +import {Mark} from "../plot.js"; +import {number} from "../options.js"; +import {applyDirectStyles, applyIndirectStyles, applyTransform, offset} from "../style.js"; + +// width factor (allows the hexbin transform to work with circular dots!) +const w0 = Math.sin(Math.PI / 3); + +const defaultsMesh = { + ariaLabel: "hexagonal mesh", + fill: "none", + stroke: "currentColor", + strokeWidth: 0.25 +}; + +export function hexgrid(options) { + return new Hexgrid(options); +} + +export class Hexgrid extends Mark { + constructor({radius = 10, clip = true, ...options} = {}) { + super(undefined, undefined, {clip, ...options}, defaultsMesh); + this.radius = number(radius); + } + render(I, scales, channels, dimensions) { + const {dx, dy, radius} = this; + const {marginTop, marginRight, marginBottom, marginLeft, width, height} = dimensions; + return create("svg:g") + .call(applyIndirectStyles, this, dimensions) + .call(g => g.append("path") + .call(applyDirectStyles, this) + .call(applyTransform, null, null, offset + dx, offset + dy) + .attr("d", mesh(radius, marginLeft, width - marginRight, marginTop, height - marginBottom))) + .node(); + } +} + +function mesh(r, x0, x1, y0, y1) { + const dx = r * 2 * w0; + const dy = r * 1.5; + x1 += dx / 2; + y1 += r; + const fragment = Array.from({length: 4}, (_, i) => [r * Math.sin((i + 1) * Math.PI / 3), r * Math.cos((i + 1) * Math.PI / 3)]).join("l"); + const m = []; + let j = Math.round(y0 / dy); + for (let y = dy * j; y < y1; y += dy, ++j) { + for (let x = (Math.round(x0 / dx) + (j & 1) / 2) * dx; x < x1; x += dx) { + m.push(`M${x - dx / 2},${y + dy / 3}m${fragment}`); + } + } + return m.join(""); +} \ No newline at end of file diff --git a/test/output/hexbin.svg b/test/output/hexbin.svg index 254e6035ff..03ffdfba66 100644 --- a/test/output/hexbin.svg +++ b/test/output/hexbin.svg @@ -16,23 +16,18 @@ 35 - 40 - 45 - 50 - 55 - ↑ culmen_length_mm @@ -50,65 +45,56 @@ - - 14 + 14 - - 16 + 16 - - 18 + 18 - - 20 + 20 - - 14 + 14 - - 16 + 16 - - 18 + 18 - - 20 + 20 - - 14 + 14 - - 16 + 16 - - 18 + 18 - - 20 + 20 culmen_depth_mm → + + + @@ -325,6 +311,9 @@ + + + @@ -550,6 +539,9 @@ + + + diff --git a/test/plots/hexbin.js b/test/plots/hexbin.js index efbb630a42..c5795f1e85 100644 --- a/test/plots/hexbin.js +++ b/test/plots/hexbin.js @@ -9,7 +9,6 @@ export default async function() { height: 320, x: {inset: 20, ticks: 5}, y: {inset: 10}, - grid: true, facet: { data: penguins, x: "sex", @@ -17,6 +16,7 @@ export default async function() { }, marks: [ Plot.frame(), + Plot.hexgrid({radius: 12}), Plot.dot(penguins, Plot.hexbin({fill: "count"}, { x: "culmen_depth_mm", y: "culmen_length_mm",