diff --git a/src/index.js b/src/index.js index f815c6ba22..f5238603d9 100644 --- a/src/index.js +++ b/src/index.js @@ -18,6 +18,7 @@ export {valueof, channel} from "./options.js"; export {filter, reverse, sort, shuffle, basic as transform} from "./transforms/basic.js"; export {bin, binX, binY} from "./transforms/bin.js"; export {group, groupX, groupY, groupZ} from "./transforms/group.js"; +export {hexbin} from "./transforms/hexbin.js"; export {normalize, normalizeX, normalizeY} from "./transforms/normalize.js"; export {map, mapX, mapY} from "./transforms/map.js"; export {window, windowX, windowY} from "./transforms/window.js"; diff --git a/src/scales/ordinal.js b/src/scales/ordinal.js index 44b9db655b..182d226eb8 100644 --- a/src/scales/ordinal.js +++ b/src/scales/ordinal.js @@ -116,7 +116,7 @@ function inferDomain(channels) { // If all channels provide a consistent hint, propagate it to the scale. function inferSymbolHint(channels) { const hint = {}; - for (const {hint: channelHint} of channels) { + for (const {hint: channelHint = {}} of channels) { for (const key of ["fill", "stroke"]) { const value = channelHint[key]; if (!(key in hint)) hint[key] = value; diff --git a/src/transforms/hexbin.js b/src/transforms/hexbin.js new file mode 100644 index 0000000000..a93cab861f --- /dev/null +++ b/src/transforms/hexbin.js @@ -0,0 +1,52 @@ +import {hexbin as Hexbin} from "d3-hexbin"; +import {maybeOutputs} from "./group.js"; + +export function hexbin(outputs, options) { + // todo: z + let radius, symbol; + ({radius = 10, symbol = "hexagon", ...options} = options); + const rescales = { + r: {scale: "r", radius}, + fill: {scale: "color"}, + stroke: {scale: "color"}, + fillOpacity: {scale: "opacity"}, + strokeOpacity: {scale: "opacity"}, + symbol: {scale: "symbol"} + }; + 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({x: { reduce: bin => bin.x }, y: { reduce: bin => bin.y }, ...outputs}, {symbol, ...options}); + return { + initialize(index, {x: xi, y: yi}, {x, y}) { + if (xi == null) throw new Error("missing channel: x"); + if (yi == null) throw new Error("missing channel: y"); + const {value: X} = xi; + const {value: Y} = yi; + const facets = []; + const channels = {}; + for (const o of outputs) { + o.initialize(this.data); // todo: https://github.com/observablehq/plot/pull/775/files#r818957322 + channels[o.name] = {...rescales[o.name], value: []}; + } + let n = 0; + for (const I of index) { + const bins = Hexbin().x(i => x(X[i])).y(i => y(Y[i])).radius(radius)(I); + for (const o of outputs) o.scope("facet", I); + for (const bin of bins) for (const o of outputs) o.reduce(bin); + const facet = Uint32Array.from(bins, () => n++); + facets.push(facet); + for (const o of outputs) { + const values = o.output.transform(); + for (const i of facet) channels[o.name].value[i] = values[i]; + } + } + return {facets, channels}; + }, + r: radius, + symbol, + ...options + }; +} 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-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.js b/test/plots/hexbin.js index fcf256137d..b393fc5959 100644 --- a/test/plots/hexbin.js +++ b/test/plots/hexbin.js @@ -1,28 +1,16 @@ import * as Plot from "@observablehq/plot"; import * as d3 from "d3"; -import {hexbin as Hexbin} from "d3-hexbin"; export default async function() { const penguins = await d3.csv("data/penguins.csv", d3.autoType); return Plot.plot({ grid: true, marks: [ - Plot.dot(penguins, { + Plot.dot(penguins, Plot.hexbin({r: "count"}, { + radius: 20, x: "culmen_depth_mm", - y: "culmen_length_mm", - symbol: "hexagon", - initialize([index], {x: {value: X}, y: {value: Y}}, {x, y}) { - const bins = Hexbin().x(i => x(X[i])).y(i => y(Y[i])).radius(20)(index); - return { - facets: [d3.range(bins.length)], - channels: { - x: {value: bins.map(bin => bin.x)}, - y: {value: bins.map(bin => bin.y)}, - r: {value: bins.map(bin => bin.length), radius: 20, scale: "r"} - } - }; - } - }) + y: "culmen_length_mm" + })) ] }); } diff --git a/test/plots/index.js b/test/plots/index.js index ab58150106..f9d1c0af47 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -63,6 +63,8 @@ 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 hexbinR} from "./hexbin-r.js"; +export {default as hexbinText} from "./hexbin-text.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";