From be34f4a9df1ae91a7dab18a0a261ae184d5eda4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Wed, 23 Feb 2022 10:23:01 +0100 Subject: [PATCH 1/7] layouts: dodge, hexbin Co-authored-by: Mike Bostock --- README.md | 79 +++- package.json | 2 + rollup.config.js | 2 + src/index.js | 3 + src/layouts/dodge.js | 93 ++++ src/layouts/hexbin.js | 112 +++++ src/layouts/index.js | 19 + src/marks/hexgrid.js | 52 ++ src/options.js | 9 + src/plot.js | 116 ++++- src/scales/ordinal.js | 2 +- test/output/hexbin.svg | 577 +++++++++++++++++++++++ test/output/hexbinDot.html | 223 +++++++++ test/output/hexbinR.html | 574 ++++++++++++++++++++++ test/output/hexbinTextOpacity.svg | 222 +++++++++ test/output/penguinDodge.svg | 383 +++++++++++++++ test/output/penguinFacetDodge.svg | 413 ++++++++++++++++ test/output/penguinFacetDodgeIsland.html | 448 ++++++++++++++++++ test/output/penguinFacetDodgeSymbol.html | 443 +++++++++++++++++ test/plots/hexbin-dot.js | 12 + test/plots/hexbin-r.js | 20 + test/plots/hexbin-text-opacity.js | 21 + test/plots/hexbin.js | 36 ++ test/plots/index.js | 8 + test/plots/penguin-dodge.js | 12 + test/plots/penguin-facet-dodge-island.js | 22 + test/plots/penguin-facet-dodge-symbol.js | 16 + test/plots/penguin-facet-dodge.js | 21 + yarn.lock | 63 ++- 29 files changed, 3982 insertions(+), 21 deletions(-) create mode 100644 src/layouts/dodge.js create mode 100644 src/layouts/hexbin.js create mode 100644 src/layouts/index.js create mode 100644 src/marks/hexgrid.js create mode 100644 test/output/hexbin.svg create mode 100644 test/output/hexbinDot.html create mode 100644 test/output/hexbinR.html create mode 100644 test/output/hexbinTextOpacity.svg create mode 100644 test/output/penguinDodge.svg create mode 100644 test/output/penguinFacetDodge.svg create mode 100644 test/output/penguinFacetDodgeIsland.html create mode 100644 test/output/penguinFacetDodgeSymbol.html create mode 100644 test/plots/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 create mode 100644 test/plots/penguin-dodge.js create mode 100644 test/plots/penguin-facet-dodge-island.js create mode 100644 test/plots/penguin-facet-dodge-symbol.js create mode 100644 test/plots/penguin-facet-dodge.js diff --git a/README.md b/README.md index 7851a0d5a5..6d9d10e0bb 100644 --- a/README.md +++ b/README.md @@ -72,9 +72,9 @@ When drawing a single mark, you can call *mark*.**plot**(*options*) as shorthand ```js Plot.barY(alphabet, {x: "letter", y: "frequency"}).plot() ``` -### Layout options +### Geometry options -These options determine the overall layout of the plot; all are specified as numbers in pixels: +These options determine the overall geometry of the plot; all are specified as numbers in pixels: * **marginTop** - the top margin * **marginRight** - the right margin @@ -948,6 +948,14 @@ Plot.dotY(cars.map(d => d["economy (mpg)"])) Equivalent to [Plot.dot](#plotdotdata-options) except that if the **y** option is not specified, it defaults to the identity function and assumes that *data* = [*y₀*, *y₁*, *y₂*, …]. +### Hexgrid + +The hexgrid mark can be used to support marks using the [hexbin](#hexbin) layout. + +#### Plot.hexgrid([*options*]) + +The *radius* option specifies the radius of the hexagonal mesh, in pixels (defaults to 10). The *clip* option is set, by default, to clip the mark to the frame’s dimensions. + ### Image [a scatterplot of Presidential portraits](https://observablehq.com/@observablehq/plot-image) @@ -1435,10 +1443,11 @@ The following aggregation methods are supported: * *pXX* - the percentile value, where XX is a number in [00,99] * *deviation* - the standard deviation * *variance* - the variance per [Welford’s algorithm](https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Welford's_online_algorithm) -* *x* - the middle the bin’s *x*-extent (when binning on *x*) +* *mode* - the value with the most occurrences +* *x* - the middle of the bin’s *x*-extent (when binning on *x*) * *x1* - the lower bound of the bin’s *x*-extent (when binning on *x*) * *x2* - the upper bound of the bin’s *x*-extent (when binning on *x*) -* *y* - the middle the bin’s *y*-extent (when binning on *y*) +* *y* - the middle of the bin’s *y*-extent (when binning on *y*) * *y1* - the lower bound of the bin’s *y*-extent (when binning on *y*) * *y2* - the upper bound of the bin’s *y*-extent (when binning on *y*) * a function to be passed the array of values for each bin and the extent of the bin @@ -1937,6 +1946,68 @@ This helper for constructing derived channels returns a [*channel*, *setChannel* Plot.channel is typically used by options transforms to define new channels; these channels are populated (derived) when the custom *transform* function is invoked. +## Layouts + +A layout processes the transformed and scaled values of a mark before rendering. A layout might, for example, modify the marks’ positions to avoid occlusion. A layout operates in representation space (such as pixels and colors, *i.e.* after scales have been applied) rather than data space. + +### Dodge + +The dodge layout can be applied to any mark that consumes *x* or *y*, such as the Dot, Image, Text and Vector marks. + +#### Plot.dodgeY([*layoutOptions*, ]*options*) + +```js +Plot.dodgeY({x: "date"}) +``` + +If the marks are arranged along the *x* axis, the dodgeY layout piles them vertically, keeping their *x* position unchanged, and creating a *y* position that avoids overlapping. + +#### Plot.dodgeX([*layoutOptions*, ]*options*) + +```js +Plot.dodgeX({y: "value"}) +``` + +Equivalent to Plot.dodgeY, but the piling is horizontal, keeping the marks’ *y* position unchanged, and creating an *x* position that avoids overlapping. + +The dodge layouts accept the following layout options: + +* **padding** — a number of pixels added to the radius of the mark to estimate its size +* **anchor** - the layout’s anchor: one of *middle*, *right*, and *left* (default) for dodgeX, and one of *middle*, *top*, and *bottom* (default) for dodgeY. + +### Hexbin + +The hexbin layout can be applied to any mark that consumes *x* and *y*, such as the Dot, Image, Text and Vector marks. It aggregates the values into hexagonal bins of the given *radius* (in pixel space), and computes new values *x* and *y* as the centers of each bin. It can also return new channels by applying a reducer to each bin, such as the number of elements in the bin. + +#### Plot.hexbin(*outputs*, *options*) + +[Source](./src/layouts/hexbin.js) · [Examples](https://observablehq.com/@observablehq/plot-hexbin) · Aggregates the given inputs into hexagonal bins, and creates output channels with the reduced data. The options must specify the *x* and *y* channels, and can indicate the *radius* in pixels of the hexagonal lattice (defaults to 10). The *outputs* options are similar to Plot.bin’s outputs; each output channel receives as input, for each hexagon, the subset of the data which has been matched to its center. The outputs object specifies the aggregation method for each output channel.The following aggregation methods are supported: + +* *first* - the first value, in input order +* *last* - the last value, in input order +* *count* - the number of elements (frequency) +* *distinct* - the number of distinct values +* *sum* - the sum of values +* *proportion* - the sum proportional to the overall total (weighted frequency) +* *proportion-facet* - the sum proportional to the facet total +* *min* - the minimum value +* *min-index* - the zero-based index of the minimum value +* *max* - the maximum value +* *max-index* - the zero-based index of the maximum value +* *mean* - the mean value (average) +* *median* - the median value +* *deviation* - the standard deviation +* *variance* - the variance per [Welford’s algorithm](https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Welford's_online_algorithm) +* *mode* - the value with the most occurrences +* a function to be passed the array of values for each bin and the extent of the bin +* an object with a *reduce* method + +See also the [hexgrid](#hexgrid) mark. + +### Custom layouts + +When its *options* have a *layout* property, the layout function is called after the data has been faceted and scaled; it receives as inputs the index of the elements to layout, the scales descriptors, the values (the scaled channels as a key: array object), the dimensions, and the mark as this. It must return the index, values, and the channels that need to be scaled in a second pass. + ## Curves A curve defines how to turn a discrete representation of a line as a sequence of points [[*x₀*, *y₀*], [*x₁*, *y₁*], [*x₂*, *y₂*], …] into a continuous path; *i.e.*, how to interpolate between points. Curves are used by the [line](#line), [area](#area), and [link](#link) mark, and are implemented by [d3-shape](https://github.com/d3/d3-shape/blob/master/README.md#curves). diff --git a/package.json b/package.json index b4ba6a0d3a..65b3b47e8b 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ }, "sideEffects": false, "devDependencies": { + "@rollup/plugin-commonjs": "^21.0.1", "@rollup/plugin-json": "4", "@rollup/plugin-node-resolve": "13", "canvas": "2", @@ -50,6 +51,7 @@ }, "dependencies": { "d3": "^7.3.0", + "interval-tree-1d": "1", "isoformat": "0.2" }, "engines": { diff --git a/rollup.config.js b/rollup.config.js index f2cded1105..ae8f8778b5 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,5 +1,6 @@ import fs from "fs"; import {terser} from "rollup-plugin-terser"; +import commonjs from "@rollup/plugin-commonjs"; import json from "@rollup/plugin-json"; import node from "@rollup/plugin-node-resolve"; import * as meta from "./package.json"; @@ -25,6 +26,7 @@ const config = { banner: `// ${meta.name} v${meta.version} Copyright ${copyrights.join(", ")}` }, plugins: [ + commonjs(), json(), node() ] diff --git a/src/index.js b/src/index.js index f815c6ba22..5b74d1c3c8 100644 --- a/src/index.js +++ b/src/index.js @@ -6,6 +6,7 @@ export {boxX, boxY} from "./marks/box.js"; export {Cell, cell, cellX, cellY} from "./marks/cell.js"; export {Dot, dot, dotX, dotY} from "./marks/dot.js"; export {Frame, frame} from "./marks/frame.js"; +export {Hexgrid, hexgrid} from "./marks/hexgrid.js"; export {Image, image} from "./marks/image.js"; export {Line, line, lineX, lineY} from "./marks/line.js"; export {Link, link} from "./marks/link.js"; @@ -23,6 +24,8 @@ export {map, mapX, mapY} from "./transforms/map.js"; export {window, windowX, windowY} from "./transforms/window.js"; export {select, selectFirst, selectLast, selectMaxX, selectMaxY, selectMinX, selectMinY} from "./transforms/select.js"; export {stackX, stackX1, stackX2, stackY, stackY1, stackY2} from "./transforms/stack.js"; +export {dodgeX, dodgeY} from "./layouts/dodge.js"; +export {hexbin} from "./layouts/hexbin.js"; export {formatIsoDate, formatWeekday, formatMonth} from "./format.js"; export {scale} from "./scales.js"; export {legend} from "./legends.js"; diff --git a/src/layouts/dodge.js b/src/layouts/dodge.js new file mode 100644 index 0000000000..862a9c9d6c --- /dev/null +++ b/src/layouts/dodge.js @@ -0,0 +1,93 @@ +import {max} from "d3"; +import IntervalTree from "interval-tree-1d"; +import {layout} from "./index.js"; +import {finite, positive} from "../defined.js"; + +const anchorXLeft = ({marginLeft}) => [1, marginLeft]; +const anchorXRight = ({width, marginRight}) => [-1, width - marginRight]; +const anchorXMiddle = ({width, marginLeft, marginRight}) => [0, (marginLeft + width - marginRight) / 2]; +const anchorYTop = ({marginTop}) => [1, marginTop]; +const anchorYBottom = ({height, marginBottom}) => [-1, height - marginBottom]; +const anchorYMiddle = ({height, marginTop, marginBottom}) => [0, (marginTop + height - marginBottom) / 2]; + +function maybeAnchor(anchor) { + return typeof anchor === "string" ? {anchor} : anchor; +} + +export function dodgeX(dodgeOptions = {}, options = {}) { + if (arguments.length === 1) [options, dodgeOptions] = [dodgeOptions, options]; + let {anchor = "left", padding = 1} = maybeAnchor(dodgeOptions); + switch (`${anchor}`.toLowerCase()) { + case "left": anchor = anchorXLeft; break; + case "right": anchor = anchorXRight; break; + case "middle": anchor = anchorXMiddle; break; + default: throw new Error(`unknown dodge anchor: ${anchor}`); + } + return dodge("x", "y", anchor, +padding, options); +} + +export function dodgeY(dodgeOptions = {}, options = {}) { + if (arguments.length === 1) [options, dodgeOptions] = [dodgeOptions, options]; + let {anchor = "bottom", padding = 1} = maybeAnchor(dodgeOptions); + switch (`${anchor}`.toLowerCase()) { + case "top": anchor = anchorYTop; break; + case "bottom": anchor = anchorYBottom; break; + case "middle": anchor = anchorYMiddle; break; + default: throw new Error(`unknown dodge anchor: ${anchor}`); + } + return dodge("y", "x", anchor, +padding, options); +} + +function dodge(y, x, anchor, padding, options) { + return layout(options, function(index, scales, values, dimensions) { + let {[x]: X, [y]: Y, r: R} = values; + const r = R ? undefined : this.r !== undefined ? this.r : options.r !== undefined ? +options.r : 3; + 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(index.flat(), i => R[i]) : r) + padding); else ky = 1; + if (!R) R = values.r = new Float64Array(X.length).fill(r); + if (!Y) Y = values[y] = new Float64Array(X.length); + for (let I of index) { + const tree = IntervalTree(); + I = I.filter(i => finite(X[i]) && positive(R[i])); + for (const i of I) { + const intervals = []; + const l = X[i] - R[i]; + const r = X[i] + R[i]; + + // For any previously placed circles that may overlap this circle, compute + // the y-positions that place this circle tangent to these other circles. + // https://observablehq.com/@mbostock/circle-offset-along-line + tree.queryInterval(l - padding, r + padding, ([,, j]) => { + const yj = Y[j]; + const dx = X[i] - X[j]; + const dr = R[i] + padding + R[j]; + const dy = Math.sqrt(dr * dr - dx * dx); + intervals.push([yj - dy, yj + dy]); + }); + + // Find the best y-value where this circle can fit. + for (let y of intervals.flat().sort(compare)) { + if (intervals.every(([lo, hi]) => y <= lo || y >= hi)) { + Y[i] = y; + break; + } + } + + // Insert the placed circle into the interval tree. + tree.insert([l, r, i]); + } + for (const i of I) Y[i] = Y[i] * ky + ty; + } + return {index, values}; + }); +} + +function compareSymmetric(a, b) { + return Math.abs(a) - Math.abs(b); +} + +function compareAscending(a, b) { + return (a < 0) - (b < 0) || (a - b); +} diff --git a/src/layouts/hexbin.js b/src/layouts/hexbin.js new file mode 100644 index 0000000000..ededa2a6ba --- /dev/null +++ b/src/layouts/hexbin.js @@ -0,0 +1,112 @@ +import {groups} from "d3"; +import {layout} from "./index.js"; +import {basic} from "../transforms/basic.js"; +import {maybeOutputs} from "../transforms/group.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"}, + strokeOpacity: {scale: "opacity"}, + symbol: {scale: "symbol"} + }; + 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 values = {x: [], y: [], r: []}; + const channels = []; + const newIndex = []; + for (const o of outputs) { + o.initialize(this.data); + o.scope("data", index); + } + let n = 0; + for (const I of index) { + const facetIndex = []; + newIndex.push(facetIndex); + const bins = hbin(I, X, Y, radius); + for (const o of outputs) { + o.scope("facet", I); + for (const bin of bins) o.reduce(bin); + } + for (const bin of bins) { + values.x[n] = bin.x; + values.y[n] = bin.y; + facetIndex.push(n++); + } + } + for (const o of outputs) { + if (o.name in rescales) { + const {scale, options} = rescales[o.name]; + const value = o.output.transform(); + channels.push([o.name, {scale, value, options}]); + } else { + values[o.name] = o.output.transform(); + } + } + if (!channels.find(([key]) => key === "r")) values.r = Array.from(values.x).fill(radius); + + return {index: newIndex, values, channels}; + }); +} + +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/layouts/index.js b/src/layouts/index.js new file mode 100644 index 0000000000..cee6396209 --- /dev/null +++ b/src/layouts/index.js @@ -0,0 +1,19 @@ +export function layout({layout: layout1, ...options}, layout2) { + if (layout2 == null) throw new Error("invalid layout"); + layout2 = partialLayout(layout2); + if (layout1 != null) layout2 = composeLayout(layout1, layout2); + return {...options, layout: layout2}; +} + +function composeLayout(l1, l2) { + return function(index, scales, values, dimensions) { + values = l1.call(this, index, scales, values, dimensions); + return l2.call(this, index, scales, values, dimensions); + }; +} + +function partialLayout(l) { + return function(index, scales, values, dimensions) { + return {...values, ...l.call(this, index, scales, values, dimensions)}; + }; +} diff --git a/src/marks/hexgrid.js b/src/marks/hexgrid.js new file mode 100644 index 0000000000..cb4e145a37 --- /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(""); +} diff --git a/src/options.js b/src/options.js index 0984f98938..91239c3395 100644 --- a/src/options.js +++ b/src/options.js @@ -304,6 +304,14 @@ export function isNone(value) { export function isRound(value) { return /^\s*round\s*$/i.test(value); } +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], @@ -311,6 +319,7 @@ const symbols = new Map([ ["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 754567c142..78ca1844b5 100644 --- a/src/plot.js +++ b/src/plot.js @@ -41,10 +41,9 @@ export function plot(options = {}) { for (const [, channel] of channels) { const {scale} = channel; if (scale !== undefined) { - const scaled = scaleChannels.get(scale); const {percent, transform = percent ? x => x * 100 : undefined} = options[scale] || {}; if (transform != null) channel.value = Array.from(channel.value, transform); - if (scaled) scaled.push(channel); + if (scaleChannels.has(scale)) scaleChannels.get(scale).push(channel); else scaleChannels.set(scale, [channel]); } } @@ -52,8 +51,8 @@ export function plot(options = {}) { markIndex.set(mark, index); } - const scaleDescriptors = Scales(scaleChannels, options); - const scales = ScaleFunctions(scaleDescriptors); + let scaleDescriptors = Scales(scaleChannels, options); + let scales = ScaleFunctions(scaleDescriptors); const axes = Axes(scaleDescriptors, options); const dimensions = Dimensions(scaleDescriptors, axes, options); @@ -62,8 +61,8 @@ export function plot(options = {}) { autoAxisTicks(scaleDescriptors, axes); // 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]); @@ -96,12 +95,38 @@ export function plot(options = {}) { .call(applyInlineStyles, style) .node(); - for (const mark of marks) { + // A mark layout is responsible for: + // * applying the scales, possibly pushing a new one (for legends) + // * returning a filtered index and values to render, possibly new channels to rescale + const newScaleChannels = new Map(); + const layouts = []; + const newOptions = {}; + for (let i = 0; i < marks.length; ++i) { + const mark = marks[i]; + const {channels} = layouts[i] = applyLayout.call(mark, mark.layout, [markIndex.get(mark)], scaleDescriptors, markChannels.get(mark), dimensions, options); + if (channels) { + for (const [, {scale, options: hint}] of channels) { + if (scale) newOptions[scale] = {...hint, ...options[scale]}; + } + addScaleChannels(newScaleChannels, channels, newOptions); + markChannels.set(mark, channels); + } + } + + // compute descriptors for new scales + const newScaleDescriptors = Scales(newScaleChannels, newOptions); + scaleDescriptors = {...newScaleDescriptors, ...scaleDescriptors}; + scales = ScaleFunctions(scaleDescriptors); + for (const layout of layouts) { + if (layout.channels) layout.values = {...layout.values, ...applyScales(layout.channels, scales)}; + } + + for (let i = 0; i < marks.length; ++i) { + const mark = marks[i]; const channels = markChannels.get(mark) ?? []; - const values = applyScales(channels, scales); - let index = markIndex.get(mark); - if (mark.filter != null) index = mark.filter(index, channels, values); - const node = mark.render(index, scales, values, dimensions, axes); + let {index: [I], values} = layouts[i]; + if (mark.filter != null) I = mark.filter(I, channels || [], values); + const node = marks[i].render(I, scales, values, dimensions, axes); if (node != null) svg.appendChild(node); } @@ -148,6 +173,24 @@ function defaultFilter(index, channels, values) { return index; } +function addScaleChannels(scaleChannels, channels, options) { + for (const [, channel] of channels) { + const {scale} = channel; + if (scale !== undefined) { + const {percent, transform = percent ? x => x * 100 : undefined} = options[scale] || {}; + if (transform != null) channel.value = Array.from(channel.value, transform); + if (scaleChannels.has(scale)) scaleChannels.get(scale).push(channel); + else scaleChannels.set(scale, [channel]); + } + } +} + +function applyLayout(layout, index, scaleDescriptors, channels, dimensions, options) { + const scales = ScaleFunctions(scaleDescriptors); + const values = channels ? applyScales(channels, scales) : {}; + return layout ? layout.call(this, index, scaleDescriptors, values, dimensions, options) : {index, values}; +} + export class Mark { constructor(data, channels = [], options = {}, defaults) { const {facet = "auto", sort, dx, dy, clip} = options; @@ -158,6 +201,7 @@ export class Mark { const {transform} = basic(options); this.filter = defaults?.filter === undefined ? defaultFilter : defaults.filter; this.transform = transform; + this.layout = options.layout; if (defaults !== undefined) channels = styles(this, options, channels, defaults); this.channels = channels.filter(channel => { const {name, value, optional} = channel; @@ -237,15 +281,17 @@ class Facet extends Mark { this.marks = marks.flat(Infinity).map(markify); // The following fields are set by initialize: this.marksChannels = undefined; // array of mark channels + this.facetsKeys = undefined; // list of facet keys this.marksIndexByFacet = undefined; // map from facet key to array of mark indexes } initialize() { const {index, channels} = super.initialize(); const facets = index === undefined ? [] : facetGroups(index, channels); - const facetsKeys = Array.from(facets, first); + const facetsKeys = this.facetsKeys = Array.from(facets, first); const facetsIndex = Array.from(facets, second); const subchannels = []; const marksChannels = this.marksChannels = []; + const marksIndex = this.marksIndex = []; const marksIndexByFacet = this.marksIndexByFacet = facetMap(channels); for (const facetKey of facetsKeys) { marksIndexByFacet.set(facetKey, new Array(this.marks.length)); @@ -265,10 +311,12 @@ class Facet extends Mark { // when faceted. if (I !== undefined) { if (markFacets) { + marksIndex[i] = I; for (let j = 0; j < facetsKeys.length; ++j) { marksIndexByFacet.get(facetsKeys[j])[i] = I[j]; } } else { + marksIndex[i] = [I]; for (let j = 0; j < facetsKeys.length; ++j) { marksIndexByFacet.get(facetsKeys[j])[i] = I; } @@ -279,17 +327,57 @@ class Facet extends Mark { } marksChannels.push(markChannels); } + this.layout = function(index, scaleDescriptors, values, dimensions, options) { + const {marks, marksChannels, facetsKeys, marksIndexByFacet} = this; + const marksValues = this.marksValues = new Array(marks.length); + const scales = ScaleFunctions(scaleDescriptors); + 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}; + + // apply each mark’s layout + const newScaleChannels = new Map(); + const layouts = []; + const newOptions = {}; + for (let i = 0; i < marks.length; ++i) { + const mark = marks[i]; + marksValues[i] = applyScales(marksChannels[i], scales); + if (true) { + const index = facetsKeys.map(key => marksIndexByFacet.get(key)[i]); + const {index: newIndex, values: newValues, channels} = layouts[i] = applyLayout.call(mark, mark.layout, index, scaleDescriptors, marksChannels[i], subdimensions, options); + marksValues[i] = {...values, ...newValues}; + facetsKeys.forEach((key, j) => marksIndexByFacet.get(key)[i] = newIndex[j]); + if (channels) { + for (const [, {scale, options: hint}] of channels) { + if (scale) newOptions[scale] = {...hint, ...options[scale]}; + } + addScaleChannels(newScaleChannels, channels, newOptions); + marksChannels[i] = channels; + } + } + } + + const newScaleDescriptors = Scales(newScaleChannels, newOptions); + Object.assign(scaleDescriptors, newScaleDescriptors, scaleDescriptors); + const rescales = {...ScaleFunctions(newScaleDescriptors), ...scales}; + for (let i = 0; i < marks.length; ++i) { + marksValues[i] = {...marksValues[i], ...layouts[i].values, ...applyScales(layouts[i].channels || [], rescales)}; + } + + return {index, values}; // the facets are unchanged + }; return {index, channels: [...channels, ...subchannels]}; } render(I, scales, _, dimensions, axes) { - const {marks, marksChannels, marksIndexByFacet} = this; + const {marks, marksChannels, marksIndexByFacet, marksValues} = 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) { diff --git a/src/scales/ordinal.js b/src/scales/ordinal.js index 90ee306dec..540b6a940c 100644 --- a/src/scales/ordinal.js +++ b/src/scales/ordinal.js @@ -115,7 +115,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/test/output/hexbin.svg b/test/output/hexbin.svg new file mode 100644 index 0000000000..efe42afb36 --- /dev/null +++ b/test/output/hexbin.svg @@ -0,0 +1,577 @@ + + + + + 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..2f9bfc6323 --- /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..06e7e0677c --- /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.091 + + + 0.091 + + + 0.182 + + + 0.091 + + + 0.091 + + + 0.091 + + + 0.091 + + + 0.091 + + + + +
\ No newline at end of file diff --git a/test/output/hexbinTextOpacity.svg b/test/output/hexbinTextOpacity.svg new file mode 100644 index 0000000000..e1797a52c5 --- /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/penguinDodge.svg b/test/output/penguinDodge.svg new file mode 100644 index 0000000000..2292f7bb81 --- /dev/null +++ b/test/output/penguinDodge.svg @@ -0,0 +1,383 @@ + + + + + 3,000 + + + 3,500 + + + 4,000 + + + 4,500 + + + 5,000 + + + 5,500 + + + 6,000 + body_mass_g → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/penguinFacetDodge.svg b/test/output/penguinFacetDodge.svg new file mode 100644 index 0000000000..5f38a28b8e --- /dev/null +++ b/test/output/penguinFacetDodge.svg @@ -0,0 +1,413 @@ + + + + + Adelie + + + Chinstrap + + + Gentoo + + + + + 3,000 + + + + 3,500 + + + + 4,000 + + + + 4,500 + + + + 5,000 + + + + 5,500 + + + + 6,000 + + body_mass_g → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/penguinFacetDodgeIsland.html b/test/output/penguinFacetDodgeIsland.html new file mode 100644 index 0000000000..9f5bdca346 --- /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/output/penguinFacetDodgeSymbol.html b/test/output/penguinFacetDodgeSymbol.html new file mode 100644 index 0000000000..0b45525c2e --- /dev/null +++ b/test/output/penguinFacetDodgeSymbol.html @@ -0,0 +1,443 @@ +
+
+ + + Adelie + + Chinstrap + + Gentoo +
+ + + + + 2,500 + + + + 3,000 + + + + 3,500 + + + + 4,000 + + + + 4,500 + + + + 5,000 + + + + 5,500 + + + + 6,000 + + + + 6,500 + ↑ body_mass_g + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/plots/hexbin-dot.js b/test/plots/hexbin-dot.js new file mode 100644 index 0000000000..cf2beb525d --- /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({ + color: {scheme: "viridis", legend: true, label: "body mass (g)"}, + 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"})) + ] + }); +} 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 8c147c5c04..05c7e85cb7 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -62,6 +62,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"; @@ -100,6 +104,10 @@ export {default as musicRevenue} from "./music-revenue.js"; export {default as ordinalBar} from "./ordinal-bar.js"; export {default as penguinCulmen} from "./penguin-culmen.js"; export {default as penguinCulmenArray} from "./penguin-culmen-array.js"; +export {default as penguinDodge} from "./penguin-dodge.js"; +export {default as penguinFacetDodge} from "./penguin-facet-dodge.js"; +export {default as penguinFacetDodgeIsland} from "./penguin-facet-dodge-island.js"; +export {default as penguinFacetDodgeSymbol} from "./penguin-facet-dodge-symbol.js"; export {default as penguinIslandUnknown} from "./penguin-island-unknown.js"; export {default as penguinMass} from "./penguin-mass.js"; export {default as penguinMassSex} from "./penguin-mass-sex.js"; diff --git a/test/plots/penguin-dodge.js b/test/plots/penguin-dodge.js new file mode 100644 index 0000000000..47fc6ce892 --- /dev/null +++ b/test/plots/penguin-dodge.js @@ -0,0 +1,12 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export default async function() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + return Plot.plot({ + height: 200, + marks: [ + Plot.dot(penguins, Plot.dodgeY({x: "body_mass_g"})) + ] + }); +} diff --git a/test/plots/penguin-facet-dodge-island.js b/test/plots/penguin-facet-dodge-island.js new file mode 100644 index 0000000000..4c40151192 --- /dev/null +++ b/test/plots/penguin-facet-dodge-island.js @@ -0,0 +1,22 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export default async function() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + return Plot.plot({ + height: 300, + x: { + grid: true + }, + facet: { + data: penguins, + y: "species", + label: null, + marginLeft: 60 + }, + marks: [ + Plot.dot(penguins, Plot.dodgeY("middle", {x: "body_mass_g", fill: "island"})) + ], + color: {legend: true} + }); +} diff --git a/test/plots/penguin-facet-dodge-symbol.js b/test/plots/penguin-facet-dodge-symbol.js new file mode 100644 index 0000000000..a16dcb5737 --- /dev/null +++ b/test/plots/penguin-facet-dodge-symbol.js @@ -0,0 +1,16 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export default async function() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + return Plot.plot({ + height: 450, + width: 300, + marginRight: 60, + y: {grid: true, nice: true}, + symbol: {legend: true}, + marks: [ + Plot.dot(penguins, Plot.dodgeX("left", {y: "body_mass_g", symbol: "species", stroke: "species", dx: 2})) + ] + }); +} diff --git a/test/plots/penguin-facet-dodge.js b/test/plots/penguin-facet-dodge.js new file mode 100644 index 0000000000..77ed20a433 --- /dev/null +++ b/test/plots/penguin-facet-dodge.js @@ -0,0 +1,21 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export default async function() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + return Plot.plot({ + height: 300, + x: { + grid: true + }, + facet: { + data: penguins, + y: "species", + label: null, + marginLeft: 60 + }, + marks: [ + Plot.dot(penguins, Plot.dodgeY("middle", {x: "body_mass_g"})) + ] + }); +} diff --git a/yarn.lock b/yarn.lock index 4f6392b915..b538895b8f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -67,6 +67,19 @@ semver "^7.3.5" tar "^6.1.11" +"@rollup/plugin-commonjs@^21.0.1": + version "21.0.1" + resolved "https://registry.yarnpkg.com/@rollup/plugin-commonjs/-/plugin-commonjs-21.0.1.tgz#1e57c81ae1518e4df0954d681c642e7d94588fee" + integrity sha512-EA+g22lbNJ8p5kuZJUYyhhDK7WgJckW5g4pNN7n4mAFUM96VuwUnNT3xr2Db2iCZPI1pJPbGyfT5mS9T1dHfMg== + dependencies: + "@rollup/pluginutils" "^3.1.0" + commondir "^1.0.1" + estree-walker "^2.0.1" + glob "^7.1.6" + is-reference "^1.2.1" + magic-string "^0.25.7" + resolve "^1.17.0" + "@rollup/plugin-json@4": version "4.1.0" resolved "https://registry.yarnpkg.com/@rollup/plugin-json/-/plugin-json-4.1.0.tgz#54e09867ae6963c593844d8bd7a9c718294496f3" @@ -100,6 +113,11 @@ resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A== +"@types/estree@*": + version "0.0.51" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.51.tgz#cfd70924a25a3fd32b218e5e420e6897e1ac4f40" + integrity sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ== + "@types/estree@0.0.39": version "0.0.39" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" @@ -242,6 +260,11 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== +binary-search-bounds@^2.0.0: + version "2.0.5" + resolved "https://registry.yarnpkg.com/binary-search-bounds/-/binary-search-bounds-2.0.5.tgz#125e5bd399882f71e6660d4bf1186384e989fba7" + integrity sha512-H0ea4Fd3lS1+sTEB2TgcLoK21lLhwEJzlQv3IN47pJS976Gx4zoWe0ak3q+uYh60ppQxg9F16Ri4tS1sfD4+jA== + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -388,6 +411,11 @@ commander@^2.19.0, commander@^2.20.0: resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== +commondir@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" + integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs= + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -1026,6 +1054,11 @@ estree-walker@^1.0.1: resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700" integrity sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg== +estree-walker@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" + integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== + esutils@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" @@ -1156,7 +1189,7 @@ glob-parent@~5.1.2: dependencies: is-glob "^4.0.1" -glob@7.2.0, glob@^7.1.3: +glob@7.2.0, glob@^7.1.3, glob@^7.1.6: version "7.2.0" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023" integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q== @@ -1289,6 +1322,13 @@ ini@^1.3.4: resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009" integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg== +interval-tree-1d@1: + version "1.0.4" + resolved "https://registry.yarnpkg.com/interval-tree-1d/-/interval-tree-1d-1.0.4.tgz#b44f657de7ddae69ea3f98e0a9ad4bb046b07d11" + integrity sha512-wY8QJH+6wNI0uh4pDQzMvl+478Qh7Rl4qLmqiluxALlNvl+I+o5x38Pw3/z7mDPTPS1dQalZJXsmbvxx5gclhQ== + dependencies: + binary-search-bounds "^2.0.0" + is-binary-path@~2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" @@ -1340,6 +1380,13 @@ is-potential-custom-element-name@^1.0.1: resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== +is-reference@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/is-reference/-/is-reference-1.2.1.tgz#8b2dac0b371f4bc994fdeaba9eb542d03002d0b7" + integrity sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ== + dependencies: + "@types/estree" "*" + is-unicode-supported@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" @@ -1480,6 +1527,13 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +magic-string@^0.25.7: + version "0.25.7" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.7.tgz#3f497d6fd34c669c6798dcb821f2ef31f5445051" + integrity sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA== + dependencies: + sourcemap-codec "^1.4.4" + make-dir@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" @@ -1811,7 +1865,7 @@ resolve-from@^4.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== -resolve@^1.19.0, resolve@^1.22.0: +resolve@^1.17.0, resolve@^1.19.0, resolve@^1.22.0: version "1.22.0" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.0.tgz#5e0b8c67c15df57a89bdbabe603a002f21731198" integrity sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw== @@ -1966,6 +2020,11 @@ source-map@~0.7.2: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ== +sourcemap-codec@^1.4.4: + version "1.4.8" + resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" + integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== + "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" From 0100971fd8c4479056945f23a2b23baa0c1e6af9 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Tue, 1 Mar 2022 13:58:56 -0800 Subject: [PATCH 2/7] extract symbols --- src/marks/dot.js | 3 ++- src/options.js | 53 ----------------------------------------- src/scales.js | 3 ++- src/scales/ordinal.js | 3 ++- src/symbols.js | 55 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 61 insertions(+), 56 deletions(-) create mode 100644 src/symbols.js diff --git a/src/marks/dot.js b/src/marks/dot.js index 3d8c3b28b1..555992bca5 100644 --- a/src/marks/dot.js +++ b/src/marks/dot.js @@ -1,8 +1,9 @@ import {create, path, symbolCircle} from "d3"; import {positive} from "../defined.js"; -import {identity, maybeFrameAnchor, maybeNumberChannel, maybeSymbolChannel, maybeTuple} from "../options.js"; +import {identity, maybeFrameAnchor, maybeNumberChannel, maybeTuple} from "../options.js"; import {Mark} from "../plot.js"; import {applyChannelStyles, applyDirectStyles, applyFrameAnchor, applyIndirectStyles, applyTransform, offset} from "../style.js"; +import {maybeSymbolChannel} from "../symbols.js"; const defaults = { ariaLabel: "dot", diff --git a/src/options.js b/src/options.js index 91239c3395..5f1fd9cef3 100644 --- a/src/options.js +++ b/src/options.js @@ -1,7 +1,5 @@ import {parse as isoParse} from "isoformat"; import {color, descending, quantile} from "d3"; -import {symbolAsterisk, symbolDiamond2, symbolPlus, symbolSquare2, symbolTriangle2, symbolX as symbolTimes} from "d3"; -import {symbolCircle, symbolCross, symbolDiamond, symbolSquare, symbolStar, symbolTriangle, symbolWye} from "d3"; // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray const TypedArray = Object.getPrototypeOf(Uint8Array); @@ -304,57 +302,6 @@ export function isNone(value) { export function isRound(value) { return /^\s*round\s*$/i.test(value); } -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], - ["star", symbolStar], - ["times", symbolTimes], - ["triangle", symbolTriangle], - ["triangle2", symbolTriangle2], - ["wye", symbolWye] -]); - -function isSymbolObject(value) { - return value && typeof value.draw === "function"; -} - -export function isSymbol(value) { - if (isSymbolObject(value)) return true; - if (typeof value !== "string") return false; - return symbols.has(value.toLowerCase()); -} - -export function maybeSymbol(symbol) { - if (symbol == null || isSymbolObject(symbol)) return symbol; - const value = symbols.get(`${symbol}`.toLowerCase()); - if (value) return value; - throw new Error(`invalid symbol: ${symbol}`); -} - -export function maybeSymbolChannel(symbol) { - if (symbol == null || isSymbolObject(symbol)) return [undefined, symbol]; - if (typeof symbol === "string") { - const value = symbols.get(`${symbol}`.toLowerCase()); - if (value) return [undefined, value]; - } - return [symbol, undefined]; -} export function maybeFrameAnchor(value = "middle") { return keyword(value, "frameAnchor", ["middle", "top-left", "top", "top-right", "right", "bottom-right", "bottom", "bottom-left", "left"]); diff --git a/src/scales.js b/src/scales.js index 7b89b86874..227f44717c 100644 --- a/src/scales.js +++ b/src/scales.js @@ -1,11 +1,12 @@ import {parse as isoParse} from "isoformat"; -import {isColor, isEvery, isOrdinal, isFirst, isSymbol, isTemporal, maybeSymbol, order, isTemporalString, isNumericString, isScaleOptions} from "./options.js"; +import {isColor, isEvery, isOrdinal, isFirst, isTemporal, order, isTemporalString, isNumericString, isScaleOptions} from "./options.js"; import {registry, color, position, radius, opacity, symbol, length} from "./scales/index.js"; import {ScaleLinear, ScaleSqrt, ScalePow, ScaleLog, ScaleSymlog, ScaleQuantile, ScaleThreshold, ScaleIdentity} from "./scales/quantitative.js"; import {ScaleDiverging, ScaleDivergingSqrt, ScaleDivergingPow, ScaleDivergingLog, ScaleDivergingSymlog} from "./scales/diverging.js"; import {ScaleTime, ScaleUtc} from "./scales/temporal.js"; import {ScaleOrdinal, ScalePoint, ScaleBand, ordinalImplicit} from "./scales/ordinal.js"; import {warn} from "./warnings.js"; +import {isSymbol, maybeSymbol} from "./symbols.js"; export function Scales(channels, { inset: globalInset = 0, diff --git a/src/scales/ordinal.js b/src/scales/ordinal.js index 540b6a940c..459c165293 100644 --- a/src/scales/ordinal.js +++ b/src/scales/ordinal.js @@ -1,7 +1,8 @@ import {InternSet, quantize, reverse as reverseof, sort, symbolsFill, symbolsStroke} from "d3"; import {scaleBand, scaleOrdinal, scalePoint, scaleImplicit} from "d3"; import {ascendingDefined} from "../defined.js"; -import {maybeSymbol, isNoneish} from "../options.js"; +import {isNoneish} from "../options.js"; +import {maybeSymbol} from "../symbols.js"; import {registry, color, symbol} from "./index.js"; import {maybeBooleanRange, ordinalScheme, quantitativeScheme} from "./schemes.js"; diff --git a/src/symbols.js b/src/symbols.js new file mode 100644 index 0000000000..5c68d5fbf2 --- /dev/null +++ b/src/symbols.js @@ -0,0 +1,55 @@ +import {symbolAsterisk, symbolDiamond2, symbolPlus, symbolSquare2, symbolTriangle2, symbolX as symbolTimes} from "d3"; +import {symbolCircle, symbolCross, symbolDiamond, symbolSquare, symbolStar, symbolTriangle, symbolWye} from "d3"; + +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], + ["star", symbolStar], + ["times", symbolTimes], + ["triangle", symbolTriangle], + ["triangle2", symbolTriangle2], + ["wye", symbolWye] +]); + +function isSymbolObject(value) { + return value && typeof value.draw === "function"; +} + +export function isSymbol(value) { + if (isSymbolObject(value)) return true; + if (typeof value !== "string") return false; + return symbols.has(value.toLowerCase()); +} + +export function maybeSymbol(symbol) { + if (symbol == null || isSymbolObject(symbol)) return symbol; + const value = symbols.get(`${symbol}`.toLowerCase()); + if (value) return value; + throw new Error(`invalid symbol: ${symbol}`); +} + +export function maybeSymbolChannel(symbol) { + if (symbol == null || isSymbolObject(symbol)) return [undefined, symbol]; + if (typeof symbol === "string") { + const value = symbols.get(`${symbol}`.toLowerCase()); + if (value) return [undefined, value]; + } + return [symbol, undefined]; +} From d6f750f3e181124d819557c96a5226d5b90aeeb0 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 2 Mar 2022 07:56:21 -0800 Subject: [PATCH 3/7] inline hexagon --- src/symbols.js | 11 +- test/output/hexbin.svg | 204 ++++++++++----------- test/output/hexbinR.html | 288 +++++++++++++++--------------- test/output/hexbinTextOpacity.svg | 256 +++++++++++++------------- 4 files changed, 382 insertions(+), 377 deletions(-) diff --git a/src/symbols.js b/src/symbols.js index 5c68d5fbf2..4063cccfc4 100644 --- a/src/symbols.js +++ b/src/symbols.js @@ -1,12 +1,17 @@ import {symbolAsterisk, symbolDiamond2, symbolPlus, symbolSquare2, symbolTriangle2, symbolX as symbolTimes} from "d3"; import {symbolCircle, symbolCross, symbolDiamond, symbolSquare, symbolStar, symbolTriangle, symbolWye} from "d3"; -const hex = Array.from({length: 6}, (_, i) => [Math.sin(i * Math.PI / 3), Math.cos(i * Math.PI / 3)]); +const t = Math.sqrt(3) / 2; 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); + const s = Math.sqrt(size / Math.PI), hs = s / 2, ts = s * t; + context.moveTo(0, s); + context.lineTo(ts, hs); + context.lineTo(ts, -hs); + context.lineTo(0, -s); + context.lineTo(-ts, -hs); + context.lineTo(-ts, hs); context.closePath(); } }; diff --git a/test/output/hexbin.svg b/test/output/hexbin.svg index efe42afb36..bcabffef84 100644 --- a/test/output/hexbin.svg +++ b/test/output/hexbin.svg @@ -99,50 +99,50 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -321,56 +321,56 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -552,14 +552,14 @@ - - - - - - - - + + + + + + + + diff --git a/test/output/hexbinR.html b/test/output/hexbinR.html index 06e7e0677c..3be722b246 100644 --- a/test/output/hexbinR.html +++ b/test/output/hexbinR.html @@ -126,196 +126,196 @@ - + 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 @@ -323,220 +323,220 @@ - + 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 @@ -544,28 +544,28 @@ - + 0.091 - + 0.091 - + 0.182 - + 0.091 - + 0.091 - + 0.091 - + 0.091 - + 0.091 diff --git a/test/output/hexbinTextOpacity.svg b/test/output/hexbinTextOpacity.svg index e1797a52c5..4aa07fbc31 100644 --- a/test/output/hexbinTextOpacity.svg +++ b/test/output/hexbinTextOpacity.svg @@ -75,146 +75,146 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 5623634511815455143111114341212122321112493487627111111 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 122211213311641022313221211112631221213116131583512252311122111111111 - - - - - - - + + + + + + + 1121211 From 28eab01ad8a22a016031f1e44331063e6074be34 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 2 Mar 2022 11:24:23 -0800 Subject: [PATCH 4/7] remove unnecessary transform --- src/layouts/hexbin.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/layouts/hexbin.js b/src/layouts/hexbin.js index ededa2a6ba..2c3e0450c2 100644 --- a/src/layouts/hexbin.js +++ b/src/layouts/hexbin.js @@ -108,5 +108,5 @@ function hexbinLayout(radius, outputs, options) { export function hexbin(outputs, options) { ([outputs, options] = mergeOptions(outputs, options)); const {radius, ...inputs} = options; - return basic(hexbinLayout(radius, outputs, inputs), (data, facets) => ({data, facets})); + return basic(hexbinLayout(radius, outputs, inputs)); } From 1e5b7671a5b41d04ebd3d52ad7b1378623c52003 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 2 Mar 2022 11:30:01 -0800 Subject: [PATCH 5/7] tweak defaults --- src/marks/hexgrid.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/marks/hexgrid.js b/src/marks/hexgrid.js index cb4e145a37..fc04dac44c 100644 --- a/src/marks/hexgrid.js +++ b/src/marks/hexgrid.js @@ -6,8 +6,8 @@ import {applyDirectStyles, applyIndirectStyles, applyTransform, offset} from ".. // width factor (allows the hexbin transform to work with circular dots!) const w0 = Math.sin(Math.PI / 3); -const defaultsMesh = { - ariaLabel: "hexagonal mesh", +const defaults = { + ariaLabel: "hexgrid", fill: "none", stroke: "currentColor", strokeWidth: 0.25 @@ -19,7 +19,7 @@ export function hexgrid(options) { export class Hexgrid extends Mark { constructor({radius = 10, clip = true, ...options} = {}) { - super(undefined, undefined, {clip, ...options}, defaultsMesh); + super(undefined, undefined, {clip, ...options}, defaults); this.radius = number(radius); } render(I, scales, channels, dimensions) { From 38e39c96266b188985ef523b0bb6101ca1405db1 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 2 Mar 2022 11:45:03 -0800 Subject: [PATCH 6/7] inline and optimize mesh construction --- src/marks/hexgrid.js | 34 +++++++++++++--------------------- test/output/hexbin.svg | 12 ++++++------ 2 files changed, 19 insertions(+), 27 deletions(-) diff --git a/src/marks/hexgrid.js b/src/marks/hexgrid.js index fc04dac44c..cc1858f6c8 100644 --- a/src/marks/hexgrid.js +++ b/src/marks/hexgrid.js @@ -3,9 +3,6 @@ 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 defaults = { ariaLabel: "hexgrid", fill: "none", @@ -23,30 +20,25 @@ export class Hexgrid extends Mark { this.radius = number(radius); } render(I, scales, channels, dimensions) { - const {dx, dy, radius} = this; + const {dx, dy, radius: r} = this; const {marginTop, marginRight, marginBottom, marginLeft, width, height} = dimensions; + const rx = r * 2 * Math.sin(Math.PI / 3); // scaling allows the hexbin transform to work with circular dots! + const ry = r * 1.5; + const x0 = marginLeft, x1 = width - marginRight - rx / 2, y0 = marginTop, y1 = height - marginBottom + r + ry / 3; + 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 = []; + for (let j = Math.round(y0 / ry), y = ry * j + ry / 3; y < y1; y += ry, ++j) { + for (let x = (Math.round(x0 / rx) + (j & 1) / 2) * rx - rx / 2; x < x1; x += rx) { + m.push(`M${x},${y}m${fragment}`); + } + } + const d = m.join(""); 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))) + .attr("d", d)) .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(""); -} diff --git a/test/output/hexbin.svg b/test/output/hexbin.svg index bcabffef84..f89dd31f6e 100644 --- a/test/output/hexbin.svg +++ b/test/output/hexbin.svg @@ -92,11 +92,11 @@ - + - + @@ -314,11 +314,11 @@ - + - + @@ -545,11 +545,11 @@ - + - + From 352d08670b8dfda49f60f5595d079a7b9808399c Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 3 Mar 2022 09:36:15 -0800 Subject: [PATCH 7/7] fix formatting --- src/layouts/dodge.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/layouts/dodge.js b/src/layouts/dodge.js index 862a9c9d6c..e01b37cf4c 100644 --- a/src/layouts/dodge.js +++ b/src/layouts/dodge.js @@ -55,7 +55,7 @@ function dodge(y, x, anchor, padding, options) { const intervals = []; const l = X[i] - R[i]; const r = X[i] + R[i]; - + // For any previously placed circles that may overlap this circle, compute // the y-positions that place this circle tangent to these other circles. // https://observablehq.com/@mbostock/circle-offset-along-line @@ -66,7 +66,7 @@ function dodge(y, x, anchor, padding, options) { const dy = Math.sqrt(dr * dr - dx * dx); intervals.push([yj - dy, yj + dy]); }); - + // Find the best y-value where this circle can fit. for (let y of intervals.flat().sort(compare)) { if (intervals.every(([lo, hi]) => y <= lo || y >= hi)) { @@ -74,7 +74,7 @@ function dodge(y, x, anchor, padding, options) { break; } } - + // Insert the placed circle into the interval tree. tree.insert([l, r, i]); }