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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
+
+
\ 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 @@
+
\ 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 @@
+
\ 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]);
}