From 7a4735168f746d769a55d691f43f4b746d4626a8 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 10 Mar 2022 15:31:36 -0800 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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 18d5ec1cd5e26caac0d8e1b90fd23aebf6240590 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 22 Mar 2022 11:23:15 +0100 Subject: [PATCH 10/12] document layouts (as "scale-aware transforms") --- README.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f4bdbad1a6..284b3579b5 100644 --- a/README.md +++ b/README.md @@ -950,6 +950,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 defaults to true, clipping the mark to the frame’s dimensions. + ### Image [a scatterplot of Presidential portraits](https://observablehq.com/@observablehq/plot-image) @@ -1437,10 +1445,10 @@ 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*) +* *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 @@ -1939,6 +1947,69 @@ 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. +## Scale-aware transforms + +Some transforms need to operate in representation space (such as pixels and colors, *i.e.* after scales have been applied) rather than data space. Such a transform might, for example, modify the marks’ positions in screen space to avoid occlusion. These scale-aware transforms are applied *after* the initial setting of the scales, and can modify the channels or derive new channels—which can in turn be passed to scales. + +### Dodge + +The dodge transform 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 transform 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 transforms accept the following options: +* **padding** — a number of pixels added to the radius of the mark to estimate its size +* **anchor** - the frame anchor: one of *middle*, *right*, and *left* (default) for dodgeX, and one of *middle*, *top*, and *bottom* (default) for dodgeY. With the *middle* anchor the piles will grow from the center in both directions; with the other anchors, the piles will grow from the specified anchor towards the opposite direction. + +### Hexbin + +The hexbin transform 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/transforms/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 optionally indicate the *radius* in pixels of the hexagonal lattice (defaults to 10). If any of **z**, **fill**, or **stroke** is a channel, the first of these channels will be used to subdivide bins. 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 + +When the hexbin transform has an *r* output, the bins are returned in decreasing size order. + +See also the [hexgrid](#hexgrid) mark. + +### Custom scale-aware transsforms + +When its *options* have an *initialize* property, the initialize 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). From 0d770be9a41531ae3888428b38923ee709cc2d3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 22 Mar 2022 12:19:06 +0100 Subject: [PATCH 11/12] document binWidth --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 284b3579b5..1fbcc293f7 100644 --- a/README.md +++ b/README.md @@ -956,7 +956,7 @@ 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 defaults to true, clipping the mark to the frame’s dimensions. +The *binWidth* option specifies the distance between the centers of neighboring hexagons, in pixels (defaults to 20). The *clip* option defaults to true, clipping the mark to the frame’s dimensions. ### Image @@ -1979,7 +1979,7 @@ The hexbin transform can be applied to any mark that consumes *x* and *y*, such #### Plot.hexbin(*outputs*, *options*) -[Source](./src/transforms/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 optionally indicate the *radius* in pixels of the hexagonal lattice (defaults to 10). If any of **z**, **fill**, or **stroke** is a channel, the first of these channels will be used to subdivide bins. 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. +[Source](./src/transforms/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 optionally indicate the *binWidth* in pixels (defaults to 20), defined as the distance between the centers of two neighboring hexagons. If any of **z**, **fill**, or **stroke** is a channel, the first of these channels will be used to subdivide bins. 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: From f6421fd8cd6383644747639080216a8f1e9d1fc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 24 Mar 2022 14:09:30 +0100 Subject: [PATCH 12/12] document the initialize option after 42ac4f08093c5819e9fa2dcecf3e09e12785990e --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1fbcc293f7..c3707517cd 100644 --- a/README.md +++ b/README.md @@ -2006,9 +2006,9 @@ When the hexbin transform has an *r* output, the bins are returned in decreasing See also the [hexgrid](#hexgrid) mark. -### Custom scale-aware transsforms +### Custom scale-aware transforms -When its *options* have an *initialize* property, the initialize 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. +When its *options* have an *initialize* property, the initialize function is called after the scales have been computed. It receives as inputs the (possibly transformed) data array, the index of elements of this array that belong to each facet, the input channels (as a key: array object), the scales, and the dimensions, with the mark as this. It must return the data, index, and the channels that need to be scaled in a second pass. ## Curves