From 7a4735168f746d769a55d691f43f4b746d4626a8 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 10 Mar 2022 15:31:36 -0800 Subject: [PATCH 01/13] mark initializers --- package.json | 1 + src/channel.js | 43 +++++- src/marks/dot.js | 3 +- src/options.js | 45 +------ src/plot.js | 151 +++++++++++++-------- src/scales.js | 87 +++++------- src/scales/ordinal.js | 5 +- src/scales/quantitative.js | 6 +- src/symbols.js | 60 +++++++++ test/output/hexbin.svg | 214 ++++++++++++++++++++++++++++++ test/plots/hexbin.js | 28 ++++ test/plots/index.js | 1 + test/transforms/normalize-test.js | 4 +- test/transforms/reduce-test.js | 4 +- yarn.lock | 5 + 15 files changed, 490 insertions(+), 167 deletions(-) create mode 100644 src/symbols.js create mode 100644 test/output/hexbin.svg create mode 100644 test/plots/hexbin.js diff --git a/package.json b/package.json index b4ba6a0d3a..d85612fa3d 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@rollup/plugin-json": "4", "@rollup/plugin-node-resolve": "13", "canvas": "2", + "d3-hexbin": "^0.2.2", "eslint": "8", "htl": "0.3", "js-beautify": "1", diff --git a/src/channel.js b/src/channel.js index 61c93061f3..edd281992e 100644 --- a/src/channel.js +++ b/src/channel.js @@ -3,6 +3,25 @@ import {first, labelof, maybeValue, range, valueof} from "./options.js"; import {registry} from "./scales/index.js"; import {maybeReduce} from "./transforms/group.js"; +export function channelObject(channelDescriptors, data) { + const channels = {}; + for (const channel of channelDescriptors) { + channels[channel.name] = Channel(data, channel); + } + return channels; +} + +// TODO use Float64Array.from for position and radius scales? +export function valueObject(channels, scales) { + const values = {}; + for (const channelName in channels) { + const {scale: scaleName, value} = channels[channelName]; + const scale = scales[scaleName]; + values[channelName] = scale === undefined ? value : Array.from(value, scale); + } + return values; +} + // TODO Type coercion? export function Channel(data, {scale, type, value, filter, hint}) { return { @@ -15,6 +34,9 @@ export function Channel(data, {scale, type, value, filter, hint}) { }; } +// Note: mutates channel.domain! This is set to a function so that it is lazily +// computed; i.e., if the scale’s domain is set explicitly, that takes priority +// over the sort option, and we don’t need to do additional work. export function channelSort(channels, facetChannels, data, options) { const {reverse: defaultReverse, reduce: defaultReduce = true, limit: defaultLimit} = options; for (const x in options) { @@ -22,12 +44,12 @@ export function channelSort(channels, facetChannels, data, options) { let {value: y, reverse = defaultReverse, reduce = defaultReduce, limit = defaultLimit} = maybeValue(options[x]); if (reverse === undefined) reverse = y === "width" || y === "height"; // default to descending for lengths if (reduce == null || reduce === false) continue; // disabled reducer - const X = channels.find(([, {scale}]) => scale === x) || facetChannels && facetChannels.find(([, {scale}]) => scale === x); + const X = findScaleChannel(channels, x) || facetChannels && findScaleChannel(facetChannels, x); if (!X) throw new Error(`missing channel for scale: ${x}`); - const XV = X[1].value; + const XV = X.value; const [lo = 0, hi = Infinity] = limit && typeof limit[Symbol.iterator] === "function" ? limit : limit < 0 ? [limit] : [0, limit]; if (y == null) { - X[1].domain = () => { + X.domain = () => { let domain = XV; if (reverse) domain = domain.slice().reverse(); if (lo !== 0 || hi !== Infinity) domain = domain.slice(lo, hi); @@ -39,7 +61,7 @@ export function channelSort(channels, facetChannels, data, options) { : y === "width" ? difference(channels, "x1", "x2") : values(channels, y, y === "y" ? "y2" : y === "x" ? "x2" : undefined); const reducer = maybeReduce(reduce === true ? "max" : reduce, YV); - X[1].domain = () => { + X.domain = () => { let domain = rollup(range(XV), I => reducer.reduce(I, YV), i => XV[i]); domain = sort(domain, reverse ? descendingGroup : ascendingGroup); if (lo !== 0 || hi !== Infinity) domain = domain.slice(lo, hi); @@ -49,6 +71,13 @@ export function channelSort(channels, facetChannels, data, options) { } } +function findScaleChannel(channels, scale) { + for (const name in channels) { + const channel = channels[name]; + if (channel.scale === scale) return channel; + } +} + function difference(channels, k1, k2) { const X1 = values(channels, k1); const X2 = values(channels, k2); @@ -56,9 +85,9 @@ function difference(channels, k1, k2) { } function values(channels, name, alias) { - let channel = channels.find(([n]) => n === name); - if (!channel && alias !== undefined) channel = channels.find(([n]) => n === alias); - if (channel) return channel[1].value; + let channel = channels[name]; + if (!channel && alias !== undefined) channel = channels[alias]; + if (channel) return channel.value; throw new Error(`missing channel: ${name}`); } diff --git a/src/marks/dot.js b/src/marks/dot.js index b1b06e7321..21341f2f64 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 0984f98938..af451fc186 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); @@ -22,6 +20,7 @@ export const field = name => d => d[name]; export const indexOf = (d, i) => i; export const identity = {transform: d => d}; export const zero = () => 0; +export const yes = () => true; export const string = x => x == null ? x : `${x}`; export const number = x => x == null ? x : +x; export const boolean = x => x == null ? x : !!x; @@ -305,48 +304,6 @@ export function isRound(value) { return /^\s*round\s*$/i.test(value); } -const symbols = new Map([ - ["asterisk", symbolAsterisk], - ["circle", symbolCircle], - ["cross", symbolCross], - ["diamond", symbolDiamond], - ["diamond2", symbolDiamond2], - ["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/plot.js b/src/plot.js index 5e3dfa468c..84cc4ec3cd 100644 --- a/src/plot.js +++ b/src/plot.js @@ -1,11 +1,12 @@ import {create, cross, difference, groups, InternMap, select} from "d3"; import {Axes, autoAxisTicks, autoScaleLabels} from "./axes.js"; -import {Channel, channelSort} from "./channel.js"; +import {Channel, channelObject, channelSort, valueObject} from "./channel.js"; import {defined} from "./defined.js"; import {Dimensions} from "./dimensions.js"; import {Legends, exposeLegends} from "./legends.js"; -import {arrayify, isOptions, keyword, range, second, where} from "./options.js"; -import {Scales, ScaleFunctions, autoScaleRange, applyScales, exposeScales} from "./scales.js"; +import {arrayify, isOptions, isScaleOptions, keyword, range, second, where, yes} from "./options.js"; +import {Scales, ScaleFunctions, autoScaleRange, exposeScales} from "./scales.js"; +import {registry as scaleRegistry} from "./scales/index.js"; import {applyInlineStyles, maybeClassName, maybeClip, styles} from "./style.js"; import {basic} from "./transforms/basic.js"; import {consumeWarnings} from "./warnings.js"; @@ -29,25 +30,35 @@ export function plot(options = {}) { // A Map from scale name to an array of associated channels. const channelsByScale = new Map(); + // If a scale is explicitly declared in options, initialize its associated + // channels to the empty array; this will guarantee that a corresponding scale + // will be created later (even if there are no other channels). But ignore + // facet scale declarations if faceting is not enabled. + for (const key of scaleRegistry.keys()) { + if (isScaleOptions(options[key]) && key !== "fx" && key !== "fy") { + channelsByScale.set(key, []); + } + } + // Faceting! let facets; // array of facet definitions (e.g. [["foo", [0, 1, 3, …]], …]) let facetIndex; // index over the facet data, e.g. [0, 1, 2, 3, …] - let facetChannels; // e.g. [["fx", {value}], ["fy", {value}]] + let facetChannels; // e.g. {fx: {value}, fy: {value}} let facetsIndex; // nested array of facet indexes [[0, 1, 3, …], [2, 5, …], …] let facetsExclude; // lazily-constructed opposite of facetsIndex if (facet !== undefined) { const {x, y} = facet; if (x != null || y != null) { const facetData = arrayify(facet.data); - facetChannels = []; + facetChannels = {}; if (x != null) { const fx = Channel(facetData, {value: x, scale: "fx"}); - facetChannels.push(["fx", fx]); + facetChannels.fx = fx; channelsByScale.set("fx", [fx]); } if (y != null) { const fy = Channel(facetData, {value: y, scale: "fy"}); - facetChannels.push(["fy", fy]); + facetChannels.fy = fy; channelsByScale.set("fy", [fy]); } facetIndex = range(facetData); @@ -56,33 +67,20 @@ export function plot(options = {}) { } } - // Initialize the marks’ channels, indexing them by mark and scale as needed. + // Initialize the marks’ state. for (const mark of marks) { if (stateByMark.has(mark)) throw new Error("duplicate mark"); - const markFacets = facets === undefined ? undefined + const markFacets = facetsIndex === undefined ? undefined : mark.facet === "auto" ? mark.data === facet.data ? facetsIndex : undefined : mark.facet === "include" ? facetsIndex : mark.facet === "exclude" ? facetsExclude || (facetsExclude = facetsIndex.map(f => Uint32Array.from(difference(facetIndex, f)))) : undefined; - const {index, channels} = mark.initialize(markFacets, facetChannels); - for (const [, channel] of channels) { - const {scale} = channel; - if (scale !== undefined) { - const channels = channelsByScale.get(scale); - if (channels !== undefined) channels.push(channel); - else channelsByScale.set(scale, [channel]); - } - } - stateByMark.set(mark, {index, channels, faceted: markFacets !== undefined}); - } - - // Apply scale transforms, mutating channel.value. - for (const [scale, channels] of channelsByScale) { - const {percent, transform = percent ? x => x * 100 : undefined} = options[scale] || {}; - if (transform != null) for (const c of channels) c.value = Array.from(c.value, transform); + const {facets, channels} = mark.initialize(markFacets, facetChannels); + stateByMark.set(mark, {facets, channels: applyScaleTransforms(channels, options)}); } - const scaleDescriptors = Scales(channelsByScale, options); + // Initalize the scales and axes. + const scaleDescriptors = Scales(addScaleChannels(channelsByScale, stateByMark), options); const scales = ScaleFunctions(scaleDescriptors); const axes = Axes(scaleDescriptors, options); const dimensions = Dimensions(scaleDescriptors, axes, options); @@ -91,9 +89,30 @@ export function plot(options = {}) { autoScaleLabels(channelsByScale, scaleDescriptors, axes, dimensions, options); autoAxisTicks(scaleDescriptors, axes); + // Reinitialize; for deriving channels dependent on other channels. + const newByScale = new Set(); + for (const [mark, state] of stateByMark) { + if (mark.reinitialize != null) { + const {facets, channels} = mark.reinitialize(state.facets, state.channels, scales); + if (facets !== undefined) state.facets = facets; + if (channels !== undefined) { + Object.assign(state.channels, applyScaleTransforms(channels, options)); + for (const name in channels) newByScale.add(channels[name].scale); + } + } + } + + // Reconstruct scales if new scaled channels were created during reinitialization. + if (newByScale.size) { + const newScaleDescriptors = Scales(addScaleChannels(new Map(), stateByMark, key => newByScale.has(key)), options); + const newScales = ScaleFunctions(newScaleDescriptors); + Object.assign(scaleDescriptors, newScaleDescriptors); + Object.assign(scales, newScales); + } + // Compute value objects, applying scales as needed. for (const state of stateByMark.values()) { - state.values = applyScales(state.channels, scales); + state.values = valueObject(state.channels, scales); } const {width, height} = dimensions; @@ -175,16 +194,16 @@ export function plot(options = {}) { .attr("transform", facetTranslate(fx, fy)) .each(function(key) { const j = indexByFacet.get(key); - for (const [mark, {channels, values, index, faceted}] of stateByMark) { - const renderIndex = mark.filter(faceted ? index[j] : index, channels, values); - const node = mark.render(renderIndex, scales, values, subdimensions); + for (const [mark, {channels, values, facets}] of stateByMark) { + const facet = facets ? mark.filter(facets[j] ?? facets[0], channels, values) : null; + const node = mark.render(facet, scales, values, subdimensions); if (node != null) this.appendChild(node); } }); } else { - for (const [mark, {channels, values, index}] of stateByMark) { - const renderIndex = mark.filter(index, channels, values); - const node = mark.render(renderIndex, scales, values, dimensions); + for (const [mark, {channels, values, facets}] of stateByMark) { + const facet = facets ? mark.filter(facets[0], channels, values) : null; + const node = mark.render(facet, scales, values, dimensions); if (node != null) svg.appendChild(node); } } @@ -227,6 +246,7 @@ export class Mark { const {facet = "auto", sort, dx, dy, clip} = options; const names = new Set(); this.data = data; + this.reinitialize = options.initialize; this.sort = isOptions(sort) ? sort : null; this.facet = facet == null || facet === false ? null : keyword(facet === true ? "include" : facet, "facet", ["auto", "include", "exclude"]); const {transform} = basic(options); @@ -249,25 +269,18 @@ export class Mark { this.dy = +dy || 0; this.clip = maybeClip(clip); } - initialize(facetIndex, facetChannels) { + initialize(facets, facetChannels) { let data = arrayify(this.data); - let index = facetIndex === undefined && data != null ? range(data) : facetIndex; - if (data !== undefined && this.transform !== undefined) { - if (facetIndex === undefined) index = index.length ? [index] : []; - ({facets: index, data} = this.transform(data, index)); - data = arrayify(data); - if (facetIndex === undefined && index.length) ([index] = index); - } - const channels = this.channels.map(channel => { - const {name} = channel; - return [name == null ? undefined : `${name}`, Channel(data, channel)]; - }); + if (facets === undefined && data != null) facets = [range(data)]; + if (this.transform != null) ({facets, data} = this.transform(data, facets)), data = arrayify(data); + const channels = channelObject(this.channels, data); if (this.sort != null) channelSort(channels, facetChannels, data, this.sort); - return {index, channels}; + return {facets, channels}; } filter(index, channels, values) { - for (const [name, {filter = defined}] of channels) { - if (name !== undefined && filter !== null) { + for (const name in channels) { + const {filter = defined} = channels[name]; + if (filter !== null) { const value = values[name]; index = index.filter(i => filter(value[i])); } @@ -298,6 +311,34 @@ class Render extends Mark { render() {} } +// Note: mutates channel.value to apply the scale transform, if any. +function applyScaleTransforms(channels, options) { + for (const name in channels) { + const channel = channels[name]; + const {scale} = channel; + if (scale != null) { + const {percent, transform = percent ? x => x * 100 : undefined} = options[scale] || {}; + if (transform != null) channel.value = Array.from(channel.value, transform); + } + } + return channels; +} + +function addScaleChannels(channelsByScale, stateByMark, filter = yes) { + for (const {channels} of stateByMark.values()) { + for (const name in channels) { + const channel = channels[name]; + const {scale} = channel; + if (scale != null && filter(scale)) { + const channels = channelsByScale.get(scale); + if (channels !== undefined) channels.push(channel); + else channelsByScale.set(scale, [channel]); + } + } + } + return channelsByScale; +} + // Derives a copy of the specified axis with the label disabled. function nolabel(axis) { return axis === undefined || axis.label === undefined @@ -316,15 +357,17 @@ function facetKeys({fx, fy}) { // Returns an array of [[key1, index1], [key2, index2], …] representing the data // indexes associated with each facet. For two-dimensional faceting, each key // is a two-element array; see also facetMap. -function facetGroups(index, channels) { - return (channels.length > 1 ? facetGroup2 : facetGroup1)(index, ...channels); +function facetGroups(index, {fx, fy}) { + return fx && fy ? facetGroup2(index, fx, fy) + : fx ? facetGroup1(index, fx) + : facetGroup1(index, fy); } -function facetGroup1(index, [, {value: F}]) { +function facetGroup1(index, {value: F}) { return groups(index, i => F[i]); } -function facetGroup2(index, [, {value: FX}], [, {value: FY}]) { +function facetGroup2(index, {value: FX}, {value: FY}) { return groups(index, i => FX[i], i => FY[i]) .flatMap(([x, xgroup]) => xgroup .map(([y, ygroup]) => [[x, y], ygroup])); @@ -337,8 +380,8 @@ function facetTranslate(fx, fy) { : ky => `translate(0,${fy(ky)})`; } -function facetMap(channels) { - return new (channels.length > 1 ? FacetMap2 : FacetMap); +function facetMap({fx, fy}) { + return new (fx && fy ? FacetMap2 : FacetMap); } class FacetMap { diff --git a/src/scales.js b/src/scales.js index 7b89b86874..94dc0ecccd 100644 --- a/src/scales.js +++ b/src/scales.js @@ -1,13 +1,14 @@ 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, { +export function Scales(channelsByScale, { inset: globalInset = 0, insetTop: globalInsetTop = globalInset, insetRight: globalInsetRight = globalInset, @@ -21,42 +22,39 @@ export function Scales(channels, { ...options } = {}) { const scales = {}; - for (const key of registry.keys()) { - const scaleChannels = channels.get(key); + for (const [key, channels] of channelsByScale) { const scaleOptions = options[key]; - if (scaleChannels || scaleOptions) { - const scale = Scale(key, scaleChannels, { - round: registry.get(key) === position ? round : undefined, // only for position - nice, - clamp, - align, - padding, - ...scaleOptions - }); - if (scale) { - // populate generic scale options (percent, transform, insets) - let { - percent, - transform, - inset, - insetTop = inset !== undefined ? inset : key === "y" ? globalInsetTop : 0, // not fy - insetRight = inset !== undefined ? inset : key === "x" ? globalInsetRight : 0, // not fx - insetBottom = inset !== undefined ? inset : key === "y" ? globalInsetBottom : 0, // not fy - insetLeft = inset !== undefined ? inset : key === "x" ? globalInsetLeft : 0 // not fx - } = scaleOptions || {}; - if (transform == null) transform = undefined; - else if (typeof transform !== "function") throw new Error("invalid scale transform"); - scale.percent = !!percent; - scale.transform = transform; - if (key === "x" || key === "fx") { - scale.insetLeft = +insetLeft; - scale.insetRight = +insetRight; - } else if (key === "y" || key === "fy") { - scale.insetTop = +insetTop; - scale.insetBottom = +insetBottom; - } - scales[key] = scale; + const scale = Scale(key, channels, { + round: registry.get(key) === position ? round : undefined, // only for position + nice, + clamp, + align, + padding, + ...scaleOptions + }); + if (scale) { + // populate generic scale options (percent, transform, insets) + let { + percent, + transform, + inset, + insetTop = inset !== undefined ? inset : key === "y" ? globalInsetTop : 0, // not fy + insetRight = inset !== undefined ? inset : key === "x" ? globalInsetRight : 0, // not fx + insetBottom = inset !== undefined ? inset : key === "y" ? globalInsetBottom : 0, // not fy + insetLeft = inset !== undefined ? inset : key === "x" ? globalInsetLeft : 0 // not fx + } = scaleOptions || {}; + if (transform == null) transform = undefined; + else if (typeof transform !== "function") throw new Error("invalid scale transform"); + scale.percent = !!percent; + scale.transform = transform; + if (key === "x" || key === "fx") { + scale.insetLeft = +insetLeft; + scale.insetRight = +insetRight; + } else if (key === "y" || key === "fy") { + scale.insetTop = +insetTop; + scale.insetBottom = +insetBottom; } + scales[key] = scale; } } return scales; @@ -318,23 +316,6 @@ export function scaleOrder({range, domain = range}) { return Math.sign(order(domain)) * Math.sign(order(range)); } -// TODO use Float64Array.from for position and radius scales? -export function applyScales(channels, scales) { - const values = Object.create(null); - for (let [name, {value, scale}] of channels) { - if (name !== undefined) { - if (scale !== undefined) { - scale = scales[scale]; - if (scale !== undefined) { - value = Array.from(value, scale); - } - } - values[name] = value; - } - } - return values; -} - // Certain marks have special behavior if a scale is collapsed, i.e. if the // domain is degenerate and represents only a single value such as [3, 3]; for // example, a rect will span the full extent of the chart along a collapsed diff --git a/src/scales/ordinal.js b/src/scales/ordinal.js index 90ee306dec..44b9db655b 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"; @@ -105,7 +106,7 @@ function maybeRound(scale, channels, options) { function inferDomain(channels) { const values = new InternSet(); for (const {value, domain} of channels) { - if (domain !== undefined) return domain(); + if (domain !== undefined) return domain(); // see channelSort if (value === undefined) continue; for (const v of value) values.add(v); } diff --git a/src/scales/quantitative.js b/src/scales/quantitative.js index 39f6584a1c..bccef7f078 100644 --- a/src/scales/quantitative.js +++ b/src/scales/quantitative.js @@ -171,9 +171,11 @@ function inferZeroDomain(channels) { } // We don’t want the upper bound of the radial domain to be zero, as this would -// be degenerate, so we ignore nonpositive values. We also don’t want the maximum -// default radius to exceed 30px. +// be degenerate, so we ignore nonpositive values. We also don’t want the +// maximum default radius to exceed 30px. function inferRadialRange(channels, domain) { + const hint = channels.find(({radius}) => radius !== undefined); + if (hint !== undefined) return [0, hint.radius]; // a natural maximum radius, e.g. hexbins const h25 = quantile(channels, 0.5, ({value}) => value === undefined ? NaN : quantile(value, 0.25, positive)); const range = domain.map(d => 3 * Math.sqrt(d / h25)); const k = 30 / max(range); diff --git a/src/symbols.js b/src/symbols.js new file mode 100644 index 0000000000..bde756257d --- /dev/null +++ b/src/symbols.js @@ -0,0 +1,60 @@ +import {symbolAsterisk, symbolDiamond2, symbolPlus, symbolSquare2, symbolTriangle2, symbolX as symbolTimes} from "d3"; +import {symbolCircle, symbolCross, symbolDiamond, symbolSquare, symbolStar, symbolTriangle, symbolWye} from "d3"; + +const t = Math.sqrt(3) / 2; // TODO decide on radius definition + +const symbolHexagon = { + draw(context, size) { + 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(); + } +}; + +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]; +} diff --git a/test/output/hexbin.svg b/test/output/hexbin.svg new file mode 100644 index 0000000000..a36bafe5d9 --- /dev/null +++ b/test/output/hexbin.svg @@ -0,0 +1,214 @@ + + + + + + 34 + + + + 36 + + + + 38 + + + + 40 + + + + 42 + + + + 44 + + + + 46 + + + + 48 + + + + 50 + + + + 52 + + + + 54 + + + + 56 + + + + 58 + ↑ culmen_length_mm + + + + + 14 + + + + 15 + + + + 16 + + + + 17 + + + + 18 + + + + 19 + + + + 20 + + + + 21 + culmen_depth_mm → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/hexbin.js b/test/plots/hexbin.js new file mode 100644 index 0000000000..fcf256137d --- /dev/null +++ b/test/plots/hexbin.js @@ -0,0 +1,28 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; +import {hexbin as Hexbin} from "d3-hexbin"; + +export default async function() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + return Plot.plot({ + grid: true, + marks: [ + Plot.dot(penguins, { + x: "culmen_depth_mm", + y: "culmen_length_mm", + symbol: "hexagon", + initialize([index], {x: {value: X}, y: {value: Y}}, {x, y}) { + const bins = Hexbin().x(i => x(X[i])).y(i => y(Y[i])).radius(20)(index); + return { + facets: [d3.range(bins.length)], + channels: { + x: {value: bins.map(bin => bin.x)}, + y: {value: bins.map(bin => bin.y)}, + r: {value: bins.map(bin => bin.length), radius: 20, scale: "r"} + } + }; + } + }) + ] + }); +} diff --git a/test/plots/index.js b/test/plots/index.js index 8c147c5c04..ab58150106 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -62,6 +62,7 @@ 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 highCardinalityOrdinal} from "./high-cardinality-ordinal.js"; export {default as identityScale} from "./identity-scale.js"; export {default as industryUnemployment} from "./industry-unemployment.js"; diff --git a/test/transforms/normalize-test.js b/test/transforms/normalize-test.js index 92b5884954..98599c34f4 100644 --- a/test/transforms/normalize-test.js +++ b/test/transforms/normalize-test.js @@ -43,6 +43,6 @@ it("Plot.normalize deviation doesn’t crash on equal values", () => { function testNormalize(data, basis, r) { const mark = Plot.dot(data, Plot.normalizeY(basis, {y: data})); - const c = new Map(mark.initialize().channels); - assert.deepStrictEqual(c.get("y").value, r); + const {channels: {y: {value: Y}}} = mark.initialize(); + assert.deepStrictEqual(Y, r); } diff --git a/test/transforms/reduce-test.js b/test/transforms/reduce-test.js index c57c6af9df..b1480407c2 100644 --- a/test/transforms/reduce-test.js +++ b/test/transforms/reduce-test.js @@ -20,6 +20,6 @@ it("function reducers reduce as expected", () => { function testReducer(data, x, r) { const mark = Plot.dot(data, Plot.groupZ({x}, {x: d => d})); - const c = new Map(mark.initialize().channels); - assert.deepStrictEqual(c.get("x").value, [r]); + const {channels: {x: {value: X}}} = mark.initialize(); + assert.deepStrictEqual(X, [r]); } diff --git a/yarn.lock b/yarn.lock index 4f6392b915..17d49151bf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -536,6 +536,11 @@ d3-geo@3: dependencies: d3-array "2.5.0 - 3" +d3-hexbin@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/d3-hexbin/-/d3-hexbin-0.2.2.tgz#9c5837dacfd471ab05337a9e91ef10bfc4f98831" + integrity sha1-nFg32s/UcasFM3qeke8Qv8T5iDE= + d3-hierarchy@3: version "3.1.1" resolved "https://registry.yarnpkg.com/d3-hierarchy/-/d3-hierarchy-3.1.1.tgz#9cbb0ffd2375137a351e6cfeed344a06d4ff4597" From bdab31033cb8b064200b504f309d2267d4e7d376 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 11 Mar 2022 15:26:23 +0100 Subject: [PATCH 02/13] scale hex radius so that when hexagons touch, circles also touch without overlapping (#803) (supersedes #795) --- src/symbols.js | 2 +- test/output/hexbin.svg | 271 +++++++++++++++++++++++++---------------- test/plots/hexbin.js | 5 +- 3 files changed, 167 insertions(+), 111 deletions(-) diff --git a/src/symbols.js b/src/symbols.js index bde756257d..48e8d39abb 100644 --- a/src/symbols.js +++ b/src/symbols.js @@ -5,7 +5,7 @@ const t = Math.sqrt(3) / 2; // TODO decide on radius definition const symbolHexagon = { draw(context, size) { - const s = Math.sqrt(size / Math.PI), hs = s / 2, ts = s * t; + const s = Math.sqrt(size / Math.PI) * 2 / Math.sqrt(3), hs = s / 2, ts = s * t; context.moveTo(0, s); context.lineTo(ts, hs); context.lineTo(ts, -hs); diff --git a/test/output/hexbin.svg b/test/output/hexbin.svg index a36bafe5d9..cd23ce724f 100644 --- a/test/output/hexbin.svg +++ b/test/output/hexbin.svg @@ -102,113 +102,168 @@ culmen_depth_mm → - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/hexbin.js b/test/plots/hexbin.js index fcf256137d..08d95b7179 100644 --- a/test/plots/hexbin.js +++ b/test/plots/hexbin.js @@ -12,13 +12,14 @@ export default async function() { y: "culmen_length_mm", symbol: "hexagon", initialize([index], {x: {value: X}, y: {value: Y}}, {x, y}) { - const bins = Hexbin().x(i => x(X[i])).y(i => y(Y[i])).radius(20)(index); + const radius = 12; + const bins = Hexbin().x(i => x(X[i])).y(i => y(Y[i])).radius(radius * 2 / Math.sqrt(3))(index); return { facets: [d3.range(bins.length)], channels: { x: {value: bins.map(bin => bin.x)}, y: {value: bins.map(bin => bin.y)}, - r: {value: bins.map(bin => bin.length), radius: 20, scale: "r"} + r: {value: bins.map(bin => bin.length), radius, scale: "r"} } }; } From 5c073b1571a6414f558b4e23827ec26ea649bde5 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 11 Mar 2022 08:15:13 -0800 Subject: [PATCH 03/13] simpler hexagon --- src/symbols.js | 16 +- test/output/hexbin.svg | 326 ++++++++++++++++++++--------------------- 2 files changed, 171 insertions(+), 171 deletions(-) diff --git a/src/symbols.js b/src/symbols.js index 48e8d39abb..8879f6cec2 100644 --- a/src/symbols.js +++ b/src/symbols.js @@ -1,17 +1,17 @@ import {symbolAsterisk, symbolDiamond2, symbolPlus, symbolSquare2, symbolTriangle2, symbolX as symbolTimes} from "d3"; import {symbolCircle, symbolCross, symbolDiamond, symbolSquare, symbolStar, symbolTriangle, symbolWye} from "d3"; -const t = Math.sqrt(3) / 2; // TODO decide on radius definition +const w = 2 / Math.sqrt(3); const symbolHexagon = { draw(context, size) { - const s = Math.sqrt(size / Math.PI) * 2 / Math.sqrt(3), 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); + const s = Math.sqrt(size / Math.PI), t = s * w, h = t / 2; + context.moveTo(0, t); + context.lineTo(s, h); + context.lineTo(s, -h); + context.lineTo(0, -t); + context.lineTo(-s, -h); + context.lineTo(-s, h); context.closePath(); } }; diff --git a/test/output/hexbin.svg b/test/output/hexbin.svg index cd23ce724f..ec07786f60 100644 --- a/test/output/hexbin.svg +++ b/test/output/hexbin.svg @@ -102,168 +102,168 @@ culmen_depth_mm → - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From 4a1e110fd9adf06acb547097d5b7ff59b57deb8c Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 11 Mar 2022 12:57:54 -0800 Subject: [PATCH 04/13] hexgrid --- src/index.js | 1 + src/marks/hexgrid.js | 45 +++++++++++++++++++++++++++ src/symbols.js | 16 +++++----- test/output/hexbin.svg | 70 +++++++++++++++++------------------------- test/plots/hexbin.js | 5 +-- 5 files changed, 85 insertions(+), 52 deletions(-) create mode 100644 src/marks/hexgrid.js diff --git a/src/index.js b/src/index.js index f815c6ba22..30914d67b9 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"; diff --git a/src/marks/hexgrid.js b/src/marks/hexgrid.js new file mode 100644 index 0000000000..d65cd5db5c --- /dev/null +++ b/src/marks/hexgrid.js @@ -0,0 +1,45 @@ +import {create} from "d3"; +import {Mark} from "../plot.js"; +import {number} from "../options.js"; +import {applyDirectStyles, applyIndirectStyles, applyTransform, offset} from "../style.js"; +import {sqrt4_3} from "../symbols.js"; + +const defaults = { + ariaLabel: "hexgrid", + fill: "none", + stroke: "currentColor", + strokeOpacity: 0.1 +}; + +export function hexgrid(options) { + return new Hexgrid(options); +} + +export class Hexgrid extends Mark { + constructor({radius = 10, clip = true, ...options} = {}) { + super(undefined, undefined, {clip, ...options}, defaults); + this.radius = number(radius); + } + render(index, scales, channels, dimensions) { + const {dx, dy, radius: rx} = this; + const {marginTop, marginRight, marginBottom, marginLeft, width, height} = dimensions; + const x0 = marginLeft, x1 = width - marginRight, y0 = marginTop, y1 = height - marginBottom; + const ry = rx * sqrt4_3, hy = ry / 2, wx = rx * 2, wy = ry * 1.5; + const path = `m0,${-ry}l${rx},${hy}v${ry}l${-rx},${hy}`; + const i0 = Math.floor(x0 / wx), i1 = Math.ceil(x1 / wx); + const j0 = Math.floor((y0 + hy) / wy), j1 = Math.ceil((y1 - hy) / wy) + 1; + const m = []; + for (let j = j0; j < j1; ++j) { + for (let i = i0; i < i1; ++i) { + m.push(`M${i * wx + (j & 1) * rx},${j * wy}${path}`); + } + } + 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", m.join(""))) + .node(); + } +} diff --git a/src/symbols.js b/src/symbols.js index 8879f6cec2..f501631b4c 100644 --- a/src/symbols.js +++ b/src/symbols.js @@ -1,17 +1,17 @@ import {symbolAsterisk, symbolDiamond2, symbolPlus, symbolSquare2, symbolTriangle2, symbolX as symbolTimes} from "d3"; import {symbolCircle, symbolCross, symbolDiamond, symbolSquare, symbolStar, symbolTriangle, symbolWye} from "d3"; -const w = 2 / Math.sqrt(3); +export const sqrt4_3 = 2 / Math.sqrt(3); const symbolHexagon = { draw(context, size) { - const s = Math.sqrt(size / Math.PI), t = s * w, h = t / 2; - context.moveTo(0, t); - context.lineTo(s, h); - context.lineTo(s, -h); - context.lineTo(0, -t); - context.lineTo(-s, -h); - context.lineTo(-s, h); + const rx = Math.sqrt(size / Math.PI), ry = rx * sqrt4_3, hy = ry / 2; + context.moveTo(0, ry); + context.lineTo(rx, hy); + context.lineTo(rx, -hy); + context.lineTo(0, -ry); + context.lineTo(-rx, -hy); + context.lineTo(-rx, hy); context.closePath(); } }; diff --git a/test/output/hexbin.svg b/test/output/hexbin.svg index ec07786f60..8e881493f6 100644 --- a/test/output/hexbin.svg +++ b/test/output/hexbin.svg @@ -15,92 +15,78 @@ - - 34 + 34 - - 36 + 36 - - 38 + 38 - - 40 + 40 - - 42 + 42 - - 44 + 44 - - 46 + 46 - - 48 + 48 - - 50 + 50 - - 52 + 52 - - 54 + 54 - - 56 + 56 - - 58 + 58 ↑ culmen_length_mm - - 14 + 14 - - 15 + 15 - - 16 + 16 - - 17 + 17 - - 18 + 18 - - 19 + 19 - - 20 + 20 - - 21 + 21 culmen_depth_mm → + + + + + + + diff --git a/test/plots/hexbin.js b/test/plots/hexbin.js index 08d95b7179..8883913936 100644 --- a/test/plots/hexbin.js +++ b/test/plots/hexbin.js @@ -4,15 +4,16 @@ import {hexbin as Hexbin} from "d3-hexbin"; export default async function() { const penguins = await d3.csv("data/penguins.csv", d3.autoType); + const radius = 12; return Plot.plot({ - grid: true, marks: [ + Plot.frame(), + Plot.hexgrid({radius}), Plot.dot(penguins, { x: "culmen_depth_mm", y: "culmen_length_mm", symbol: "hexagon", initialize([index], {x: {value: X}, y: {value: Y}}, {x, y}) { - const radius = 12; const bins = Hexbin().x(i => x(X[i])).y(i => y(Y[i])).radius(radius * 2 / Math.sqrt(3))(index); return { facets: [d3.range(bins.length)], From 0239f16a5b880989e261d343187b6abbd7528434 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 11 Mar 2022 13:04:37 -0800 Subject: [PATCH 05/13] fix for unscaled channels --- src/plot.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plot.js b/src/plot.js index 84cc4ec3cd..21ed4f1dce 100644 --- a/src/plot.js +++ b/src/plot.js @@ -97,7 +97,7 @@ export function plot(options = {}) { if (facets !== undefined) state.facets = facets; if (channels !== undefined) { Object.assign(state.channels, applyScaleTransforms(channels, options)); - for (const name in channels) newByScale.add(channels[name].scale); + for (const {scale} of Object.values(channels)) if (scale != null) newByScale.add(scale); } } } From d238637d87f50e4ba2216da1c77a943898d797bd Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 11 Mar 2022 13:17:09 -0800 Subject: [PATCH 06/13] reorder --- src/channel.js | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/channel.js b/src/channel.js index edd281992e..b0e31486ba 100644 --- a/src/channel.js +++ b/src/channel.js @@ -3,6 +3,18 @@ import {first, labelof, maybeValue, range, valueof} from "./options.js"; import {registry} from "./scales/index.js"; import {maybeReduce} from "./transforms/group.js"; +// TODO Type coercion? +export function Channel(data, {scale, type, value, filter, hint}) { + return { + scale, + type, + value: valueof(data, value), + label: labelof(value), + filter, + hint + }; +} + export function channelObject(channelDescriptors, data) { const channels = {}; for (const channel of channelDescriptors) { @@ -11,7 +23,7 @@ export function channelObject(channelDescriptors, data) { return channels; } -// TODO use Float64Array.from for position and radius scales? +// TODO Use Float64Array for scales with numeric ranges, e.g. position? export function valueObject(channels, scales) { const values = {}; for (const channelName in channels) { @@ -22,18 +34,6 @@ export function valueObject(channels, scales) { return values; } -// TODO Type coercion? -export function Channel(data, {scale, type, value, filter, hint}) { - return { - scale, - type, - value: valueof(data, value), - label: labelof(value), - filter, - hint - }; -} - // Note: mutates channel.domain! This is set to a function so that it is lazily // computed; i.e., if the scale’s domain is set explicitly, that takes priority // over the sort option, and we don’t need to do additional work. From 0817303f98c1a1ed65311e2eb38642e94c4a8ac0 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 11 Mar 2022 14:18:06 -0800 Subject: [PATCH 07/13] hexbin --- package.json | 2 +- src/index.js | 3 +- src/marks/dot.js | 8 + src/transforms/hexbin.js | 65 +++++++ test/output/hexbin.svg | 357 +++++++++++++++++++++------------------ test/plots/hexbin.js | 21 +-- 6 files changed, 271 insertions(+), 185 deletions(-) create mode 100644 src/transforms/hexbin.js diff --git a/package.json b/package.json index d85612fa3d..8892247b1a 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,6 @@ "@rollup/plugin-json": "4", "@rollup/plugin-node-resolve": "13", "canvas": "2", - "d3-hexbin": "^0.2.2", "eslint": "8", "htl": "0.3", "js-beautify": "1", @@ -51,6 +50,7 @@ }, "dependencies": { "d3": "^7.3.0", + "d3-hexbin": "^0.2.2", "isoformat": "0.2" }, "engines": { diff --git a/src/index.js b/src/index.js index 30914d67b9..90e4bd4ff8 100644 --- a/src/index.js +++ b/src/index.js @@ -4,7 +4,7 @@ export {Arrow, arrow} from "./marks/arrow.js"; export {BarX, BarY, barX, barY} from "./marks/bar.js"; 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 {Dot, dot, dotX, dotY, circle, hexagon} 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"; @@ -19,6 +19,7 @@ export {valueof, channel} from "./options.js"; export {filter, reverse, sort, shuffle, basic as transform} from "./transforms/basic.js"; export {bin, binX, binY} from "./transforms/bin.js"; export {group, groupX, groupY, groupZ} from "./transforms/group.js"; +export {hexbin} from "./transforms/hexbin.js"; export {normalize, normalizeX, normalizeY} from "./transforms/normalize.js"; export {map, mapX, mapY} from "./transforms/map.js"; export {window, windowX, windowY} from "./transforms/window.js"; diff --git a/src/marks/dot.js b/src/marks/dot.js index 21341f2f64..aee2ec734f 100644 --- a/src/marks/dot.js +++ b/src/marks/dot.js @@ -101,3 +101,11 @@ export function dotX(data, {x = identity, ...options} = {}) { export function dotY(data, {y = identity, ...options} = {}) { return new Dot(data, {...options, y}); } + +export function circle(data, options) { + return dot(data, {...options, symbol: "circle"}); +} + +export function hexagon(data, options) { + return dot(data, {...options, symbol: "hexagon"}); +} diff --git a/src/transforms/hexbin.js b/src/transforms/hexbin.js new file mode 100644 index 0000000000..8dfc77eec4 --- /dev/null +++ b/src/transforms/hexbin.js @@ -0,0 +1,65 @@ +import {hexbin as Hexbin} from "d3-hexbin"; // TODO inline +import {sqrt4_3} from "../symbols.js"; +import {basic} from "./basic.js"; +import {hasOutput, maybeOutputs} from "./group.js"; + +export function hexbin(outputs = {fill: "count"}, options = {}) { + const {radius, ...rest} = outputs; + return hexbinn(rest, {radius, ...options}); +} + +// TODO group by (implicit) z +// TODO filter e.g. to show empty hexbins? +// TODO data output with sort and reverse? +// TODO disallow x, x1, x2, y, y1, y2 reducers? +function hexbinn(outputs, {radius = 10, ...options}) { + radius = +radius; + outputs = maybeOutputs(outputs, options); + return { + symbol: "hexagon", + ...!hasOutput(outputs, "r") && {r: radius}, + ...hasOutput(outputs, "fill") && {stroke: "none"}, + ...basic(options, (data, facets) => { + for (const o of outputs) o.initialize(data); + return {data, facets}; + }), + initialize(facets, {x: X, y: Y}, {x, y}) { + if (X === undefined) throw new Error("missing channel: x"); + if (Y === undefined) throw new Error("missing channel: y"); + ({value: X} = X); + ({value: Y} = Y); + const binsof = Hexbin().x(i => x(X[i])).y(i => y(Y[i])).radius(radius * sqrt4_3); + const binFacets = []; + const BX = []; + const BY = []; + let i = 0; + for (const facet of facets) { + const binFacet = []; + for (const o of outputs) o.scope("facet", facet); + for (const bin of binsof(facet)) { + binFacet.push(i++); + BX.push(bin.x); + BY.push(bin.y); + for (const o of outputs) o.reduce(bin); + } + binFacets.push(binFacet); + } + return { + facets: binFacets, + channels: { + x: {value: BX}, + y: {value: BY}, + ...Object.fromEntries(outputs.map(({name, output}) => [name, {scale: scaleof(name), radius: name === "r" ? radius : undefined, value: output.transform()}])) + } + }; + } + }; +} + +function scaleof(name) { + switch (name) { + case "fill": case "stroke": return "color"; + case "fillOpacity": case "strokeOpacity": case "opacity": return "opacity"; + case "r": case "length": case "symbol": return name; + } +} diff --git a/test/output/hexbin.svg b/test/output/hexbin.svg index 8e881493f6..3dd2cc8e45 100644 --- a/test/output/hexbin.svg +++ b/test/output/hexbin.svg @@ -85,171 +85,200 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/hexbin.js b/test/plots/hexbin.js index 8883913936..9b2e63872b 100644 --- a/test/plots/hexbin.js +++ b/test/plots/hexbin.js @@ -1,30 +1,13 @@ import * as Plot from "@observablehq/plot"; import * as d3 from "d3"; -import {hexbin as Hexbin} from "d3-hexbin"; export default async function() { const penguins = await d3.csv("data/penguins.csv", d3.autoType); - const radius = 12; return Plot.plot({ marks: [ Plot.frame(), - Plot.hexgrid({radius}), - Plot.dot(penguins, { - x: "culmen_depth_mm", - y: "culmen_length_mm", - symbol: "hexagon", - initialize([index], {x: {value: X}, y: {value: Y}}, {x, y}) { - const bins = Hexbin().x(i => x(X[i])).y(i => y(Y[i])).radius(radius * 2 / Math.sqrt(3))(index); - return { - facets: [d3.range(bins.length)], - channels: { - x: {value: bins.map(bin => bin.x)}, - y: {value: bins.map(bin => bin.y)}, - r: {value: bins.map(bin => bin.length), radius, scale: "r"} - } - }; - } - }) + Plot.hexgrid(), + Plot.dot(penguins, Plot.hexbin({r: "count"}, {x: "culmen_depth_mm", y: "culmen_length_mm"})) ] }); } From 1852e3048c48e706737966650f75e338d9289e05 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sat, 12 Mar 2022 08:07:18 -0800 Subject: [PATCH 08/13] fix #806; handle missing hint --- src/scales/ordinal.js | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/scales/ordinal.js b/src/scales/ordinal.js index 44b9db655b..cb661c204c 100644 --- a/src/scales/ordinal.js +++ b/src/scales/ordinal.js @@ -114,16 +114,22 @@ 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 key of ["fill", "stroke"]) { - const value = channelHint[key]; - if (!(key in hint)) hint[key] = value; - else if (hint[key] !== value) hint[key] = undefined; - } +function inferHint(channels, key) { + let value; + for (const {hint} of channels) { + const candidate = hint?.[key]; + if (candidate === undefined) continue; // no hint here + if (value === undefined) value = candidate; // first hint + else if (value !== candidate) return; // inconsistent hint } - return hint; + return value; +} + +function inferSymbolHint(channels) { + return { + fill: inferHint(channels, "fill"), + stroke: inferHint(channels, "stroke") + }; } function inferSymbolRange(hint) { From bca32ba652c8dfe4f46c50934658dbfc794bd37c Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sat, 12 Mar 2022 08:34:07 -0800 Subject: [PATCH 09/13] infer channel scales --- src/plot.js | 26 ++++++++++++++++++++++++-- src/transforms/hexbin.js | 10 +--------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/plot.js b/src/plot.js index 21ed4f1dce..770fba3d48 100644 --- a/src/plot.js +++ b/src/plot.js @@ -76,7 +76,8 @@ export function plot(options = {}) { : mark.facet === "exclude" ? facetsExclude || (facetsExclude = facetsIndex.map(f => Uint32Array.from(difference(facetIndex, f)))) : undefined; const {facets, channels} = mark.initialize(markFacets, facetChannels); - stateByMark.set(mark, {facets, channels: applyScaleTransforms(channels, options)}); + applyScaleTransforms(channels, options); + stateByMark.set(mark, {facets, channels}); } // Initalize the scales and axes. @@ -96,7 +97,9 @@ export function plot(options = {}) { const {facets, channels} = mark.reinitialize(state.facets, state.channels, scales); if (facets !== undefined) state.facets = facets; if (channels !== undefined) { - Object.assign(state.channels, applyScaleTransforms(channels, options)); + inferChannelScale(channels, mark); + applyScaleTransforms(channels, options); + Object.assign(state.channels, channels); for (const {scale} of Object.values(channels)) if (scale != null) newByScale.add(scale); } } @@ -324,6 +327,25 @@ function applyScaleTransforms(channels, options) { return channels; } +// An initializer may generate channels without knowing how the downstream mark +// will use them. Marks are typically responsible associated scales with +// channels, but here we assume common behavior across marks. +function inferChannelScale(channels) { + for (const name in channels) { + const channel = channels[name]; + let {scale} = channel; + if (scale === true) { + switch (name) { + case "fill": case "stroke": scale = "color"; break; + case "fillOpacity": case "strokeOpacity": case "opacity": scale = "opacity"; break; + case "r": case "length": case "symbol": scale = name; break; + default: scale = null; + } + channel.scale = scale; + } + } +} + function addScaleChannels(channelsByScale, stateByMark, filter = yes) { for (const {channels} of stateByMark.values()) { for (const name in channels) { diff --git a/src/transforms/hexbin.js b/src/transforms/hexbin.js index 8dfc77eec4..b8c235f367 100644 --- a/src/transforms/hexbin.js +++ b/src/transforms/hexbin.js @@ -49,17 +49,9 @@ function hexbinn(outputs, {radius = 10, ...options}) { channels: { x: {value: BX}, y: {value: BY}, - ...Object.fromEntries(outputs.map(({name, output}) => [name, {scale: scaleof(name), radius: name === "r" ? radius : undefined, value: output.transform()}])) + ...Object.fromEntries(outputs.map(({name, output}) => [name, {scale: true, radius: name === "r" ? radius : undefined, value: output.transform()}])) } }; } }; } - -function scaleof(name) { - switch (name) { - case "fill": case "stroke": return "color"; - case "fillOpacity": case "strokeOpacity": case "opacity": return "opacity"; - case "r": case "length": case "symbol": return name; - } -} From 42ac4f08093c5819e9fa2dcecf3e09e12785990e Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 23 Mar 2022 11:21:28 -0700 Subject: [PATCH 10/13] pass data to initializer --- src/plot.js | 8 ++++---- src/transforms/hexbin.js | 9 +++------ 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/plot.js b/src/plot.js index 770fba3d48..14dd4e0eab 100644 --- a/src/plot.js +++ b/src/plot.js @@ -75,9 +75,9 @@ export function plot(options = {}) { : mark.facet === "include" ? facetsIndex : mark.facet === "exclude" ? facetsExclude || (facetsExclude = facetsIndex.map(f => Uint32Array.from(difference(facetIndex, f)))) : undefined; - const {facets, channels} = mark.initialize(markFacets, facetChannels); + const {data, facets, channels} = mark.initialize(markFacets, facetChannels); applyScaleTransforms(channels, options); - stateByMark.set(mark, {facets, channels}); + stateByMark.set(mark, {data, facets, channels}); } // Initalize the scales and axes. @@ -94,7 +94,7 @@ export function plot(options = {}) { const newByScale = new Set(); for (const [mark, state] of stateByMark) { if (mark.reinitialize != null) { - const {facets, channels} = mark.reinitialize(state.facets, state.channels, scales); + const {facets, channels} = mark.reinitialize(state.data, state.facets, state.channels, scales); if (facets !== undefined) state.facets = facets; if (channels !== undefined) { inferChannelScale(channels, mark); @@ -278,7 +278,7 @@ export class Mark { if (this.transform != null) ({facets, data} = this.transform(data, facets)), data = arrayify(data); const channels = channelObject(this.channels, data); if (this.sort != null) channelSort(channels, facetChannels, data, this.sort); - return {facets, channels}; + return {data, facets, channels}; } filter(index, channels, values) { for (const name in channels) { diff --git a/src/transforms/hexbin.js b/src/transforms/hexbin.js index b8c235f367..0ea1744a25 100644 --- a/src/transforms/hexbin.js +++ b/src/transforms/hexbin.js @@ -1,6 +1,5 @@ import {hexbin as Hexbin} from "d3-hexbin"; // TODO inline import {sqrt4_3} from "../symbols.js"; -import {basic} from "./basic.js"; import {hasOutput, maybeOutputs} from "./group.js"; export function hexbin(outputs = {fill: "count"}, options = {}) { @@ -19,11 +18,8 @@ function hexbinn(outputs, {radius = 10, ...options}) { symbol: "hexagon", ...!hasOutput(outputs, "r") && {r: radius}, ...hasOutput(outputs, "fill") && {stroke: "none"}, - ...basic(options, (data, facets) => { - for (const o of outputs) o.initialize(data); - return {data, facets}; - }), - initialize(facets, {x: X, y: Y}, {x, y}) { + ...options, + initialize(data, facets, {x: X, y: Y}, {x, y}) { if (X === undefined) throw new Error("missing channel: x"); if (Y === undefined) throw new Error("missing channel: y"); ({value: X} = X); @@ -33,6 +29,7 @@ function hexbinn(outputs, {radius = 10, ...options}) { const BX = []; const BY = []; let i = 0; + for (const o of outputs) o.initialize(data); for (const facet of facets) { const binFacet = []; for (const o of outputs) o.scope("facet", facet); From 70d341a3e1146ee9852ec958e14780d68b3733e2 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 23 Mar 2022 12:51:28 -0700 Subject: [PATCH 11/13] offset hexagonal grid slightly --- src/marks/hexgrid.js | 5 +- src/transforms/hexbin.js | 13 +- test/output/hexbin.svg | 384 +++++++++++++++++++-------------------- 3 files changed, 204 insertions(+), 198 deletions(-) diff --git a/src/marks/hexgrid.js b/src/marks/hexgrid.js index d65cd5db5c..c18483f3a0 100644 --- a/src/marks/hexgrid.js +++ b/src/marks/hexgrid.js @@ -3,6 +3,7 @@ import {Mark} from "../plot.js"; import {number} from "../options.js"; import {applyDirectStyles, applyIndirectStyles, applyTransform, offset} from "../style.js"; import {sqrt4_3} from "../symbols.js"; +import {ox, oy} from "../transforms/hexbin.js"; const defaults = { ariaLabel: "hexgrid", @@ -23,7 +24,7 @@ export class Hexgrid extends Mark { render(index, scales, channels, dimensions) { const {dx, dy, radius: rx} = this; const {marginTop, marginRight, marginBottom, marginLeft, width, height} = dimensions; - const x0 = marginLeft, x1 = width - marginRight, y0 = marginTop, y1 = height - marginBottom; + const x0 = marginLeft - ox, x1 = width - marginRight - ox, y0 = marginTop - oy, y1 = height - marginBottom - oy; const ry = rx * sqrt4_3, hy = ry / 2, wx = rx * 2, wy = ry * 1.5; const path = `m0,${-ry}l${rx},${hy}v${ry}l${-rx},${hy}`; const i0 = Math.floor(x0 / wx), i1 = Math.ceil(x1 / wx); @@ -38,7 +39,7 @@ export class Hexgrid extends Mark { .call(applyIndirectStyles, this, dimensions) .call(g => g.append("path") .call(applyDirectStyles, this) - .call(applyTransform, null, null, offset + dx, offset + dy) + .call(applyTransform, null, null, offset + dx + ox, offset + dy + oy) .attr("d", m.join(""))) .node(); } diff --git a/src/transforms/hexbin.js b/src/transforms/hexbin.js index 0ea1744a25..028680eb81 100644 --- a/src/transforms/hexbin.js +++ b/src/transforms/hexbin.js @@ -2,6 +2,13 @@ import {hexbin as Hexbin} from "d3-hexbin"; // TODO inline import {sqrt4_3} from "../symbols.js"; import {hasOutput, maybeOutputs} from "./group.js"; +// We don’t want the hexagons to align with the edges of the plot frame, as that +// would cause extreme x-values (the upper bound of the default x-scale domain) +// to be rounded up into a floating bin to the right of the plot. Therefore, +// rather than centering the origin hexagon around ⟨0,0⟩ in screen coordinates, +// we offset slightly to ⟨0.5,0⟩. The hexgrid mark uses the same origin. +export const ox = 0.5, oy = 0; + export function hexbin(outputs = {fill: "count"}, options = {}) { const {radius, ...rest} = outputs; return hexbinn(rest, {radius, ...options}); @@ -24,7 +31,7 @@ function hexbinn(outputs, {radius = 10, ...options}) { if (Y === undefined) throw new Error("missing channel: y"); ({value: X} = X); ({value: Y} = Y); - const binsof = Hexbin().x(i => x(X[i])).y(i => y(Y[i])).radius(radius * sqrt4_3); + const binsof = Hexbin().x(i => x(X[i]) - ox).y(i => y(Y[i]) - oy).radius(radius * sqrt4_3); const binFacets = []; const BX = []; const BY = []; @@ -35,8 +42,8 @@ function hexbinn(outputs, {radius = 10, ...options}) { for (const o of outputs) o.scope("facet", facet); for (const bin of binsof(facet)) { binFacet.push(i++); - BX.push(bin.x); - BY.push(bin.y); + BX.push(bin.x + ox); + BY.push(bin.y + oy); for (const o of outputs) o.reduce(bin); } binFacets.push(binFacet); diff --git a/test/output/hexbin.svg b/test/output/hexbin.svg index 3dd2cc8e45..1165776539 100644 --- a/test/output/hexbin.svg +++ b/test/output/hexbin.svg @@ -85,200 +85,198 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From 6034a4f62b30494e47ab9b1d923c3ba4e21bf06c Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 23 Mar 2022 13:06:05 -0700 Subject: [PATCH 12/13] simpler without destructuring --- src/transforms/hexbin.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/transforms/hexbin.js b/src/transforms/hexbin.js index 028680eb81..7a6c4503bc 100644 --- a/src/transforms/hexbin.js +++ b/src/transforms/hexbin.js @@ -29,8 +29,8 @@ function hexbinn(outputs, {radius = 10, ...options}) { initialize(data, facets, {x: X, y: Y}, {x, y}) { if (X === undefined) throw new Error("missing channel: x"); if (Y === undefined) throw new Error("missing channel: y"); - ({value: X} = X); - ({value: Y} = Y); + X = X.value; + Y = Y.value; const binsof = Hexbin().x(i => x(X[i]) - ox).y(i => y(Y[i]) - oy).radius(radius * sqrt4_3); const binFacets = []; const BX = []; From 526375e7925d269b78dac07a34c2d63248f9cd92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 15 Mar 2022 14:50:30 +0100 Subject: [PATCH 13/13] sort hex bins by radius (descending) group by z inline hexbin binWidth is the distance between two centers (rebased on mbostock/reinitialize) --- package.json | 1 - src/marks/hexgrid.js | 8 +- src/symbols.js | 3 +- src/transforms/hexbin.js | 104 +++++-- test/output/hexbin.svg | 382 ++++++++++++------------- test/output/hexbinOranges.svg | 151 ++++++++++ test/output/hexbinR.html | 521 ++++++++++++++++++++++++++++++++++ test/output/hexbinSymbol.html | 236 +++++++++++++++ test/output/hexbinText.svg | 195 +++++++++++++ test/output/hexbinZ.svg | 310 ++++++++++++++++++++ test/plots/hexbin-oranges.js | 19 ++ test/plots/hexbin-r.js | 20 ++ test/plots/hexbin-symbol.js | 18 ++ test/plots/hexbin-text.js | 21 ++ test/plots/hexbin-z.js | 22 ++ test/plots/index.js | 5 + yarn.lock | 5 - 17 files changed, 1792 insertions(+), 229 deletions(-) create mode 100644 test/output/hexbinOranges.svg create mode 100644 test/output/hexbinR.html create mode 100644 test/output/hexbinSymbol.html create mode 100644 test/output/hexbinText.svg create mode 100644 test/output/hexbinZ.svg create mode 100644 test/plots/hexbin-oranges.js create mode 100644 test/plots/hexbin-r.js create mode 100644 test/plots/hexbin-symbol.js create mode 100644 test/plots/hexbin-text.js create mode 100644 test/plots/hexbin-z.js diff --git a/package.json b/package.json index 8892247b1a..b4ba6a0d3a 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,6 @@ }, "dependencies": { "d3": "^7.3.0", - "d3-hexbin": "^0.2.2", "isoformat": "0.2" }, "engines": { diff --git a/src/marks/hexgrid.js b/src/marks/hexgrid.js index c18483f3a0..4fbb9f2244 100644 --- a/src/marks/hexgrid.js +++ b/src/marks/hexgrid.js @@ -17,15 +17,15 @@ export function hexgrid(options) { } export class Hexgrid extends Mark { - constructor({radius = 10, clip = true, ...options} = {}) { + constructor({binWidth = 20, clip = true, ...options} = {}) { super(undefined, undefined, {clip, ...options}, defaults); - this.radius = number(radius); + this.binWidth = number(binWidth); } render(index, scales, channels, dimensions) { - const {dx, dy, radius: rx} = this; + const {dx, dy, binWidth} = this; const {marginTop, marginRight, marginBottom, marginLeft, width, height} = dimensions; const x0 = marginLeft - ox, x1 = width - marginRight - ox, y0 = marginTop - oy, y1 = height - marginBottom - oy; - const ry = rx * sqrt4_3, hy = ry / 2, wx = rx * 2, wy = ry * 1.5; + const rx = binWidth / 2, ry = rx * sqrt4_3, hy = ry / 2, wx = rx * 2, wy = ry * 1.5; const path = `m0,${-ry}l${rx},${hy}v${ry}l${-rx},${hy}`; const i0 = Math.floor(x0 / wx), i1 = Math.ceil(x1 / wx); const j0 = Math.floor((y0 + hy) / wy), j1 = Math.ceil((y1 - hy) / wy) + 1; diff --git a/src/symbols.js b/src/symbols.js index f501631b4c..e35aaec694 100644 --- a/src/symbols.js +++ b/src/symbols.js @@ -1,7 +1,8 @@ import {symbolAsterisk, symbolDiamond2, symbolPlus, symbolSquare2, symbolTriangle2, symbolX as symbolTimes} from "d3"; import {symbolCircle, symbolCross, symbolDiamond, symbolSquare, symbolStar, symbolTriangle, symbolWye} from "d3"; -export const sqrt4_3 = 2 / Math.sqrt(3); +export const sqrt3 = Math.sqrt(3); +export const sqrt4_3 = 2 / sqrt3; const symbolHexagon = { draw(context, size) { diff --git a/src/transforms/hexbin.js b/src/transforms/hexbin.js index 7a6c4503bc..f78e30229b 100644 --- a/src/transforms/hexbin.js +++ b/src/transforms/hexbin.js @@ -1,5 +1,6 @@ -import {hexbin as Hexbin} from "d3-hexbin"; // TODO inline -import {sqrt4_3} from "../symbols.js"; +import {group} from "d3"; +import {sqrt3} from "../symbols.js"; +import {maybeChannel, maybeColorChannel, valueof} from "../options.js"; import {hasOutput, maybeOutputs} from "./group.js"; // We don’t want the hexagons to align with the edges of the plot frame, as that @@ -10,52 +11,99 @@ import {hasOutput, maybeOutputs} from "./group.js"; export const ox = 0.5, oy = 0; export function hexbin(outputs = {fill: "count"}, options = {}) { - const {radius, ...rest} = outputs; - return hexbinn(rest, {radius, ...options}); + const {binWidth, ...rest} = outputs; + return hexbinn(rest, {binWidth, ...options}); } -// TODO group by (implicit) z // TODO filter e.g. to show empty hexbins? -// TODO data output with sort and reverse? // TODO disallow x, x1, x2, y, y1, y2 reducers? -function hexbinn(outputs, {radius = 10, ...options}) { - radius = +radius; - outputs = maybeOutputs(outputs, options); +function hexbinn(outputs, {binWidth = 20, fill, stroke, z, ...options}) { + binWidth = +binWidth; + const [GZ, setGZ] = maybeChannel(z); + const [vfill] = maybeColorChannel(fill); + const [vstroke] = maybeColorChannel(stroke); + const [GF = fill, setGF] = maybeChannel(vfill); + const [GS = stroke, setGS] = maybeChannel(vstroke); + outputs = maybeOutputs({ + ...setGF && {fill: "first"}, + ...setGS && {stroke: "first"}, + ...outputs + }, {fill, stroke, ...options}); return { symbol: "hexagon", - ...!hasOutput(outputs, "r") && {r: radius}, - ...hasOutput(outputs, "fill") && {stroke: "none"}, + ...!hasOutput(outputs, "r") && {r: binWidth / 2}, + ...!setGF && {fill}, + ...((hasOutput(outputs, "fill") || setGF) && stroke === undefined) ? {stroke: "none"} : {stroke}, ...options, initialize(data, facets, {x: X, y: Y}, {x, y}) { + if (setGF) setGF(valueof(data, vfill)); + if (setGS) setGS(valueof(data, vstroke)); + if (setGZ) setGZ(valueof(data, z)); + for (const o of outputs) o.initialize(data); if (X === undefined) throw new Error("missing channel: x"); if (Y === undefined) throw new Error("missing channel: y"); - X = X.value; - Y = Y.value; - const binsof = Hexbin().x(i => x(X[i]) - ox).y(i => y(Y[i]) - oy).radius(radius * sqrt4_3); + X = X.value.map(x); + Y = Y.value.map(y); + const F = setGF && GF.transform(); + const S = setGS && GS.transform(); + const Z = setGZ ? GZ.transform() : (F || S); const binFacets = []; const BX = []; const BY = []; - let i = 0; - for (const o of outputs) o.initialize(data); + let i = -1; for (const facet of facets) { const binFacet = []; for (const o of outputs) o.scope("facet", facet); - for (const bin of binsof(facet)) { - binFacet.push(i++); - BX.push(bin.x + ox); - BY.push(bin.y + oy); - for (const o of outputs) o.reduce(bin); + for (const index of Z ? group(facet, i => Z[i]).values() : [facet]) { + for (const bin of hbin(index, X, Y, binWidth)) { + binFacet.push(++i); + BX.push(bin.x); + BY.push(bin.y); + for (const o of outputs) o.reduce(bin); + } } binFacets.push(binFacet); } - return { - facets: binFacets, - channels: { - x: {value: BX}, - y: {value: BY}, - ...Object.fromEntries(outputs.map(({name, output}) => [name, {scale: true, radius: name === "r" ? radius : undefined, value: output.transform()}])) - } + const channels = { + x: {value: BX}, + y: {value: BY}, + ...Object.fromEntries(outputs.map(({name, output}) => [name, {scale: true, binWidth: name === "r" ? binWidth : undefined, value: output.transform()}])) }; + if ("r" in channels) { + const R = channels.r.value; + binFacets.forEach(index => index.sort((i, j) => R[j] - R[i])); + } + return {facets: binFacets, channels}; } }; } + +function hbin(I, X, Y, dx) { + const dy = dx * sqrt3 / 2; + const bins = new Map(); + for (const i of I) { + let px = X[i] / dx; + let py = Y[i] / dy; + if (isNaN(px) || isNaN(py)) continue; + let pj = Math.round(py), + pi = Math.round(px = px - (pj & 1) / 2), + py1 = py - pj; + if (Math.abs(py1) * 3 > 1) { + let px1 = px - pi, + pi2 = pi + (px < pi ? -1 : 1) / 2, + pj2 = pj + (py < pj ? -1 : 1), + px2 = px - pi2, + py2 = py - pj2; + if (px1 * px1 + py1 * py1 > px2 * px2 + py2 * py2) pi = pi2 + (pj & 1 ? 1 : -1) / 2, pj = pj2; + } + const key = `${pi},${pj}`; + let g = bins.get(key); + if (g === undefined) { + bins.set(key, g = []); + g.x = (pi + (pj & 1) / 2) * dx; + g.y = pj * dy; + } + g.push(i); + } + return bins.values(); +} diff --git a/test/output/hexbin.svg b/test/output/hexbin.svg index 1165776539..b600b89934 100644 --- a/test/output/hexbin.svg +++ b/test/output/hexbin.svg @@ -88,195 +88,197 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/hexbinOranges.svg b/test/output/hexbinOranges.svg new file mode 100644 index 0000000000..a855e5439e --- /dev/null +++ b/test/output/hexbinOranges.svg @@ -0,0 +1,151 @@ + + + + + 35 + + + 40 + + + 45 + + + 50 + + + 55 + ↑ culmen_length_mm + + + + 14 + + + 15 + + + 16 + + + 17 + + + 18 + + + 19 + + + 20 + + + 21 + culmen_depth_mm → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/hexbinR.html b/test/output/hexbinR.html new file mode 100644 index 0000000000..be11949cf5 --- /dev/null +++ b/test/output/hexbinR.html @@ -0,0 +1,521 @@ +
+ + + + + 0 + + + 5 + + + 10 + + + 15 + + + 20 + Proportion of each facet (%) + + + + + + 35 + + + 40 + + + 45 + + + 50 + + + 55 + ↑ culmen_length_mm + + + + FEMALE + + + MALE + + + + sex + + + + 14 + + + 16 + + + 18 + + + 20 + + + + + 14 + + + 16 + + + 18 + + + 20 + + + + + 14 + + + 16 + + + 18 + + + 20 + culmen_depth_mm → + + + + + + 0.067 + + + 0.055 + + + 0.048 + + + 0.048 + + + 0.042 + + + 0.042 + + + 0.042 + + + 0.036 + + + 0.036 + + + 0.03 + + + 0.03 + + + 0.03 + + + 0.03 + + + 0.024 + + + 0.024 + + + 0.024 + + + 0.018 + + + 0.018 + + + 0.018 + + + 0.018 + + + 0.018 + + + 0.018 + + + 0.018 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + + + + + + 0.083 + + + 0.065 + + + 0.048 + + + 0.042 + + + 0.042 + + + 0.03 + + + 0.03 + + + 0.03 + + + 0.03 + + + 0.024 + + + 0.024 + + + 0.024 + + + 0.024 + + + 0.024 + + + 0.018 + + + 0.018 + + + 0.018 + + + 0.018 + + + 0.018 + + + 0.018 + + + 0.018 + + + 0.018 + + + 0.018 + + + 0.018 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.012 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + 0.006 + + + + + + + + 0.182 + + + 0.091 + + + 0.091 + + + 0.091 + + + 0.091 + + + 0.091 + + + 0.091 + + + 0.091 + + + +
\ No newline at end of file diff --git a/test/output/hexbinSymbol.html b/test/output/hexbinSymbol.html new file mode 100644 index 0000000000..11c1d0bb82 --- /dev/null +++ b/test/output/hexbinSymbol.html @@ -0,0 +1,236 @@ +
+
+ + + FEMALE + + MALE +
+ + + + + 34 + + + + 36 + + + + 38 + + + + 40 + + + + 42 + + + + 44 + + + + 46 + + + + 48 + + + + 50 + + + + 52 + + + + 54 + + + + 56 + + + + 58 + ↑ culmen_length_mm + + + + + 14 + + + + 15 + + + + 16 + + + + 17 + + + + 18 + + + + 19 + + + + 20 + + + + 21 + culmen_depth_mm → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/output/hexbinText.svg b/test/output/hexbinText.svg new file mode 100644 index 0000000000..0e70e704da --- /dev/null +++ b/test/output/hexbinText.svg @@ -0,0 +1,195 @@ + + + + + 35 + + + 40 + + + 45 + + + 50 + + + 55 + ↑ culmen_length_mm + + + + FEMALE + + + MALE + + + + sex + + + + 15 + + + 20 + + + + + 15 + + + 20 + + + + + 15 + + + 20 + culmen_depth_mm → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 729103256610131211311412281224113111611124149142111 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 18212131514179311224111293442123113171034964311112221121 + + + + + + + + + + + + + + 11211111 + + \ No newline at end of file diff --git a/test/output/hexbinZ.svg b/test/output/hexbinZ.svg new file mode 100644 index 0000000000..d2de751545 --- /dev/null +++ b/test/output/hexbinZ.svg @@ -0,0 +1,310 @@ + + + + + 34 + + + 36 + + + 38 + + + 40 + + + 42 + + + 44 + + + 46 + + + 48 + + + 50 + + + 52 + + + 54 + + + 56 + + + 58 + ↑ culmen_length_mm + + + + 14 + + + 15 + + + 16 + + + 17 + + + 18 + + + 19 + + + 20 + + + 21 + culmen_depth_mm → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/hexbin-oranges.js b/test/plots/hexbin-oranges.js new file mode 100644 index 0000000000..29bb84bdeb --- /dev/null +++ b/test/plots/hexbin-oranges.js @@ -0,0 +1,19 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export default async function() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + return Plot.plot({ + color: {scheme: "oranges"}, + inset: 30, + marks: [ + Plot.frame(), + Plot.circle(penguins, Plot.hexbin({fill: "count"}, { + x: "culmen_depth_mm", + y: "culmen_length_mm", + binWidth: 35, + strokeWidth: 1 + })) + ] + }); +} diff --git a/test/plots/hexbin-r.js b/test/plots/hexbin-r.js new file mode 100644 index 0000000000..e63eb72ae5 --- /dev/null +++ b/test/plots/hexbin-r.js @@ -0,0 +1,20 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export default async function() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + return Plot.plot({ + width: 820, + height: 320, + color: {scheme: "reds", nice: true, tickFormat: d => 100 * d, label: "Proportion of each facet (%)", legend: true}, + facet: { + data: penguins, + x: "sex", + marginRight: 80 + }, + marks: [ + Plot.frame(), + Plot.dot(penguins, Plot.hexbin({title: "proportion-facet", r: "count", fill: "proportion-facet"}, {x: "culmen_depth_mm", y: "culmen_length_mm", strokeWidth: 1})) + ] + }); +} diff --git a/test/plots/hexbin-symbol.js b/test/plots/hexbin-symbol.js new file mode 100644 index 0000000000..d26da69415 --- /dev/null +++ b/test/plots/hexbin-symbol.js @@ -0,0 +1,18 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export default async function() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + return Plot.plot({ + grid: true, + marks: [ + Plot.dot(penguins, Plot.hexbin({r: "count", symbol: "mode"}, { + binWidth: 40, + symbol: "sex", + x: "culmen_depth_mm", + y: "culmen_length_mm" + })) + ], + symbol: {legend: true} + }); +} diff --git a/test/plots/hexbin-text.js b/test/plots/hexbin-text.js new file mode 100644 index 0000000000..8ab4086588 --- /dev/null +++ b/test/plots/hexbin-text.js @@ -0,0 +1,21 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export default async function() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + return Plot.plot({ + width: 820, + height: 320, + facet: { + data: penguins, + x: "sex", + marginRight: 80 + }, + inset: 14, + marks: [ + Plot.frame(), + Plot.dot(penguins, Plot.hexbin({fillOpacity: "count"}, {x: "culmen_depth_mm", y: "culmen_length_mm", fill: "brown", stroke: "black", strokeWidth: 0.5})), + Plot.text(penguins, Plot.hexbin({text: "count"}, {x: "culmen_depth_mm", y: "culmen_length_mm"})) + ] + }); +} diff --git a/test/plots/hexbin-z.js b/test/plots/hexbin-z.js new file mode 100644 index 0000000000..0ac30caff3 --- /dev/null +++ b/test/plots/hexbin-z.js @@ -0,0 +1,22 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export default async function() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + return Plot.plot({ + x: {inset: 10}, + y: {inset: 10}, + marks: [ + Plot.frame(), + Plot.hexgrid(), + Plot.dot(penguins, Plot.hexbin({r: "count"}, { + x: "culmen_depth_mm", + y: "culmen_length_mm", + strokeWidth: 2, + stroke: "sex", + fill: "sex", + fillOpacity: 0.5 + })) + ] + }); +} diff --git a/test/plots/index.js b/test/plots/index.js index 9fd8c0c3f4..6fb444376c 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -63,6 +63,11 @@ export {default as googleTrendsRidgeline} from "./google-trends-ridgeline.js"; export {default as gridChoropleth} from "./grid-choropleth.js"; export {default as hadcrutWarmingStripes} from "./hadcrut-warming-stripes.js"; export {default as hexbin} from "./hexbin.js"; +export {default as hexbinOranges} from "./hexbin-oranges.js"; +export {default as hexbinR} from "./hexbin-r.js"; +export {default as hexbinSymbol} from "./hexbin-symbol.js"; +export {default as hexbinText} from "./hexbin-text.js"; +export {default as hexbinZ} from "./hexbin-z.js"; export {default as highCardinalityOrdinal} from "./high-cardinality-ordinal.js"; export {default as identityScale} from "./identity-scale.js"; export {default as industryUnemployment} from "./industry-unemployment.js"; diff --git a/yarn.lock b/yarn.lock index 17d49151bf..4f6392b915 100644 --- a/yarn.lock +++ b/yarn.lock @@ -536,11 +536,6 @@ d3-geo@3: dependencies: d3-array "2.5.0 - 3" -d3-hexbin@^0.2.2: - version "0.2.2" - resolved "https://registry.yarnpkg.com/d3-hexbin/-/d3-hexbin-0.2.2.tgz#9c5837dacfd471ab05337a9e91ef10bfc4f98831" - integrity sha1-nFg32s/UcasFM3qeke8Qv8T5iDE= - d3-hierarchy@3: version "3.1.1" resolved "https://registry.yarnpkg.com/d3-hierarchy/-/d3-hierarchy-3.1.1.tgz#9cbb0ffd2375137a351e6cfeed344a06d4ff4597"