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..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"; @@ -15,6 +16,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 {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/dodge.js b/src/layouts/dodge.js index 5e7371519b..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,7 +76,8 @@ 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)}; + for (const i of I) Y[i] = Y[i] * ky + ty; + return {[y]: Y}; }); } diff --git a/src/layouts/hexbin.js b/src/layouts/hexbin.js new file mode 100644 index 0000000000..b0cc3f07f5 --- /dev/null +++ b/src/layouts/hexbin.js @@ -0,0 +1,92 @@ +import {groups} from "d3"; +import {layout} from "./index.js"; +import {basic} from "../transforms/basic.js"; +import {maybeOutputs, hasOutput} from "../transforms/group.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) { + // 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; + 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); + o.scope("facet", index); + } + 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})); +} 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/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/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/test/output/hexbin.svg b/test/output/hexbin.svg new file mode 100644 index 0000000000..03ffdfba66 --- /dev/null +++ b/test/output/hexbin.svg @@ -0,0 +1,568 @@ + + + + + 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/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/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/hexbinTextOpacity.svg b/test/output/hexbinTextOpacity.svg new file mode 100644 index 0000000000..34e5b24b53 --- /dev/null +++ b/test/output/hexbinTextOpacity.svg @@ -0,0 +1,222 @@ + + + + + 35 + + + 40 + + + 45 + + + 50 + + + 55 + ↑ culmen_length_mm + + + + FEMALE + + + MALE + + + + sex + + + + + + 15 + + + 20 + + + + + + + 15 + + + 20 + + + + + + + 15 + + + 20 + culmen_depth_mm → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 5623634511815455143111114341212122321112493487627111111 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 122211213311641022313221211112631221213116131583512252311122111111111 + + + + + + + + + + + + + 1121211 + + + \ No newline at end of file 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/hexbin-dot.js b/test/plots/hexbin-dot.js new file mode 100644 index 0000000000..3205b389cd --- /dev/null +++ b/test/plots/hexbin-dot.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({ + marks: [ + 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 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-opacity.js b/test/plots/hexbin-text-opacity.js new file mode 100644 index 0000000000..8ab4086588 --- /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.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 new file mode 100644 index 0000000000..c5795f1e85 --- /dev/null +++ b/test/plots/hexbin.js @@ -0,0 +1,36 @@ +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}, + facet: { + data: penguins, + x: "sex", + marginRight: 80 + }, + marks: [ + Plot.frame(), + Plot.hexgrid({radius: 12}), + 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", + fill: "white", + stroke: "black", + strokeWidth: 0.5, + r: 1.5 + }) + ] + }); +} diff --git a/test/plots/index.js b/test/plots/index.js index df7cc1044e..a439b10cdc 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"; @@ -89,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} + }); +}