diff --git a/src/channel.js b/src/channel.js index 61c93061f3..b0e31486ba 100644 --- a/src/channel.js +++ b/src/channel.js @@ -15,6 +15,28 @@ export function Channel(data, {scale, type, value, filter, hint}) { }; } +export function channelObject(channelDescriptors, data) { + const channels = {}; + for (const channel of channelDescriptors) { + channels[channel.name] = Channel(data, channel); + } + return channels; +} + +// TODO Use Float64Array for scales with numeric ranges, e.g. position? +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; +} + +// 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/index.js b/src/index.js index f815c6ba22..90e4bd4ff8 100644 --- a/src/index.js +++ b/src/index.js @@ -4,8 +4,9 @@ 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"; export {Line, line, lineX, lineY} from "./marks/line.js"; export {Link, link} from "./marks/link.js"; @@ -18,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 b1b06e7321..aee2ec734f 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", @@ -100,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/marks/hexgrid.js b/src/marks/hexgrid.js new file mode 100644 index 0000000000..4fbb9f2244 --- /dev/null +++ b/src/marks/hexgrid.js @@ -0,0 +1,46 @@ +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"; +import {ox, oy} from "../transforms/hexbin.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({binWidth = 20, clip = true, ...options} = {}) { + super(undefined, undefined, {clip, ...options}, defaults); + this.binWidth = number(binWidth); + } + render(index, scales, channels, dimensions) { + 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 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; + 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 + ox, offset + dy + oy) + .attr("d", m.join(""))) + .node(); + } +} 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 bd2d391400..7e6eea516d 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,21 @@ 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; each mark must be unique"); - 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}); + const {data, facets, channels} = mark.initialize(markFacets, facetChannels); + applyScaleTransforms(channels, options); + stateByMark.set(mark, {data, facets, channels}); } - // 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 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 +90,32 @@ 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.data, state.facets, state.channels, scales); + if (facets !== undefined) state.facets = facets; + if (channels !== undefined) { + inferChannelScale(channels, mark); + applyScaleTransforms(channels, options); + Object.assign(state.channels, channels); + for (const {scale} of Object.values(channels)) if (scale != null) newByScale.add(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 +197,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 +249,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 +272,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 {data, 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 +314,53 @@ 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; +} + +// 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) { + 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 +379,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 +402,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 3267596822..6558870010 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; not a function`); - 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; not a function`); + 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..cb661c204c 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); } @@ -113,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) { diff --git a/src/scales/quantitative.js b/src/scales/quantitative.js index d16e036423..3480c0eb74 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..e35aaec694 --- /dev/null +++ b/src/symbols.js @@ -0,0 +1,61 @@ +import {symbolAsterisk, symbolDiamond2, symbolPlus, symbolSquare2, symbolTriangle2, symbolX as symbolTimes} from "d3"; +import {symbolCircle, symbolCross, symbolDiamond, symbolSquare, symbolStar, symbolTriangle, symbolWye} from "d3"; + +export const sqrt3 = Math.sqrt(3); +export const sqrt4_3 = 2 / sqrt3; + +const symbolHexagon = { + draw(context, size) { + 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(); + } +}; + +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/src/transforms/hexbin.js b/src/transforms/hexbin.js new file mode 100644 index 0000000000..f78e30229b --- /dev/null +++ b/src/transforms/hexbin.js @@ -0,0 +1,109 @@ +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 +// 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 {binWidth, ...rest} = outputs; + return hexbinn(rest, {binWidth, ...options}); +} + +// TODO filter e.g. to show empty hexbins? +// TODO disallow x, x1, x2, y, y1, y2 reducers? +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: 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.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 = -1; + for (const facet of facets) { + const binFacet = []; + for (const o of outputs) o.scope("facet", facet); + 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); + } + 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 new file mode 100644 index 0000000000..b600b89934 --- /dev/null +++ b/test/output/hexbin.svg @@ -0,0 +1,284 @@ + + + + + 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/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/hexbin.js b/test/plots/hexbin.js new file mode 100644 index 0000000000..9b2e63872b --- /dev/null +++ b/test/plots/hexbin.js @@ -0,0 +1,13 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export default async function() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + return Plot.plot({ + marks: [ + Plot.frame(), + Plot.hexgrid(), + Plot.dot(penguins, Plot.hexbin({r: "count"}, {x: "culmen_depth_mm", y: "culmen_length_mm"})) + ] + }); +} diff --git a/test/plots/index.js b/test/plots/index.js index 4c12184922..6fb444376c 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -62,6 +62,12 @@ 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 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/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]); }