From e23f6c5801c2aa318cab9cf94c5feb9415fb87bb Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sat, 15 Jan 2022 12:33:41 -1000 Subject: [PATCH 1/7] detangle basic transforms --- src/axis.js | 3 +- src/index.js | 7 ++--- src/legends.js | 2 +- src/mark.js | 38 +---------------------- src/options.js | 42 +++++++++++++++++++++++++ src/scales/quantitative.js | 6 ++-- src/transforms/basic.js | 63 +++++++++++++++++++++++++++++++++++--- src/transforms/bin.js | 3 +- src/transforms/compose.js | 10 ------ src/transforms/filter.js | 13 -------- src/transforms/group.js | 3 +- src/transforms/interval.js | 3 +- src/transforms/map.js | 3 +- src/transforms/reverse.js | 9 ------ src/transforms/select.js | 3 +- src/transforms/sort.js | 31 ------------------- src/transforms/stack.js | 3 +- 17 files changed, 122 insertions(+), 120 deletions(-) create mode 100644 src/options.js delete mode 100644 src/transforms/compose.js delete mode 100644 src/transforms/filter.js delete mode 100644 src/transforms/reverse.js delete mode 100644 src/transforms/sort.js diff --git a/src/axis.js b/src/axis.js index ddf5ffc8ca..63981ee262 100644 --- a/src/axis.js +++ b/src/axis.js @@ -1,7 +1,8 @@ import {axisTop, axisBottom, axisRight, axisLeft, create, format, utcFormat} from "d3"; import {formatIsoDate} from "./format.js"; -import {boolean, take, number, string, keyword, maybeKeyword, constant, isTemporal} from "./mark.js"; +import {boolean, take, number, string, keyword, maybeKeyword, isTemporal} from "./mark.js"; import {radians} from "./math.js"; +import {constant} from "./options.js"; import {impliedString} from "./style.js"; export class AxisX { diff --git a/src/index.js b/src/index.js index 4ee74bbb00..36acdbe60e 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,5 @@ export {plot} from "./plot.js"; -export {Mark, marks, valueof} from "./mark.js"; +export {Mark, marks} from "./mark.js"; export {Area, area, areaX, areaY} from "./marks/area.js"; export {BarX, BarY, barX, barY} from "./marks/bar.js"; export {Cell, cell, cellX, cellY} from "./marks/cell.js"; @@ -13,9 +13,8 @@ export {RuleX, RuleY, ruleX, ruleY} from "./marks/rule.js"; export {Text, text, textX, textY} from "./marks/text.js"; export {TickX, TickY, tickX, tickY} from "./marks/tick.js"; export {Vector, vector} from "./marks/vector.js"; -export {filter} from "./transforms/filter.js"; -export {reverse} from "./transforms/reverse.js"; -export {sort, shuffle} from "./transforms/sort.js"; +export {valueof} from "./options.js"; +export {filter, reverse, sort, shuffle} from "./transforms/basic.js"; export {bin, binX, binY} from "./transforms/bin.js"; export {group, groupX, groupY, groupZ} from "./transforms/group.js"; export {normalize, normalizeX, normalizeY} from "./transforms/normalize.js"; diff --git a/src/legends.js b/src/legends.js index c6eb18cf08..90b054d65e 100644 --- a/src/legends.js +++ b/src/legends.js @@ -1,8 +1,8 @@ import {rgb} from "d3"; +import {isObject} from "./options.js"; import {normalizeScale} from "./scales.js"; import {legendRamp} from "./legends/ramp.js"; import {legendSwatches, legendSymbols} from "./legends/swatches.js"; -import {isObject} from "./mark.js"; const legendRegistry = new Map([ ["color", legendColor], diff --git a/src/mark.js b/src/mark.js index 7996b1a420..ce5007abed 100644 --- a/src/mark.js +++ b/src/mark.js @@ -1,14 +1,11 @@ import {ascending, color, descending, rollup, sort} from "d3"; +import {arrayify, isOptions, valueof} from "./options.js"; import {plot} from "./plot.js"; import {registry} from "./scales/index.js"; import {styles} from "./style.js"; import {basic} from "./transforms/basic.js"; import {maybeReduce} from "./transforms/group.js"; -// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray -const TypedArray = Object.getPrototypeOf(Uint8Array); -const objectToString = Object.prototype.toString; - export class Mark { constructor(data, channels = [], options = {}, defaults) { const {facet = "auto", sort, dx, dy} = options; @@ -105,17 +102,6 @@ function channelSort(channels, facetChannels, data, options) { } } -// This allows transforms to behave equivalently to channels. -export function valueof(data, value, type) { - const array = type === undefined ? Array : type; - return typeof value === "string" ? array.from(data, field(value)) - : typeof value === "function" ? array.from(data, value) - : typeof value === "number" || value instanceof Date ? array.from(data, constant(value)) - : value && typeof value.transform === "function" ? arrayify(value.transform(data), type) - : arrayify(value, type); // preserve undefined type -} - -export const field = name => d => d[name]; export const indexOf = (d, i) => i; export const identity = {transform: d => d}; export const zero = () => 0; @@ -124,7 +110,6 @@ export const number = x => x == null ? x : +x; export const boolean = x => x == null ? x : !!x; export const first = d => d[0]; export const second = d => d[1]; -export const constant = x => () => x; // A few extra color keywords not known to d3-color. const colors = new Set(["currentColor", "none"]); @@ -162,27 +147,6 @@ export function keyword(input, name, allowed) { return i; } -// Promotes the specified data to an array or typed array as needed. If an array -// type is provided (e.g., Array), then the returned array will strictly be of -// the specified type; otherwise, any array or typed array may be returned. If -// the specified data is null or undefined, returns the value as-is. -export function arrayify(data, type) { - return data == null ? data : (type === undefined - ? (data instanceof Array || data instanceof TypedArray) ? data : Array.from(data) - : (data instanceof type ? data : type.from(data))); -} - -// Disambiguates an options object (e.g., {y: "x2"}) from a primitive value. -export function isObject(option) { - return option && option.toString === objectToString; -} - -// Disambiguates an options object (e.g., {y: "x2"}) from a channel value -// definition expressed as a channel transform (e.g., {transform: …}). -export function isOptions(option) { - return isObject(option) && typeof option.transform !== "function"; -} - // For marks specified either as [0, x] or [x1, x2], such as areas and bars. export function maybeZero(x, x1, x2, x3 = identity) { if (x1 === undefined && x2 === undefined) { // {x} or {} diff --git a/src/options.js b/src/options.js new file mode 100644 index 0000000000..6451c89bb8 --- /dev/null +++ b/src/options.js @@ -0,0 +1,42 @@ +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray +const TypedArray = Object.getPrototypeOf(Uint8Array); +const objectToString = Object.prototype.toString; + +export function field(name) { + return d => d[name]; +} + +export function constant(value) { + return () => value; +} + +// This allows transforms to behave equivalently to channels. +export function valueof(data, value, type) { + const array = type === undefined ? Array : type; + return typeof value === "string" ? array.from(data, field(value)) + : typeof value === "function" ? array.from(data, value) + : typeof value === "number" || value instanceof Date ? array.from(data, constant(value)) + : value && typeof value.transform === "function" ? arrayify(value.transform(data), type) + : arrayify(value, type); // preserve undefined type +} + +// Promotes the specified data to an array or typed array as needed. If an array +// type is provided (e.g., Array), then the returned array will strictly be of +// the specified type; otherwise, any array or typed array may be returned. If +// the specified data is null or undefined, returns the value as-is. +export function arrayify(data, type) { + return data == null ? data : (type === undefined + ? (data instanceof Array || data instanceof TypedArray) ? data : Array.from(data) + : (data instanceof type ? data : type.from(data))); +} + +// Disambiguates an options object (e.g., {y: "x2"}) from a primitive value. +export function isObject(option) { + return option && option.toString === objectToString; +} + +// Disambiguates an options object (e.g., {y: "x2"}) from a channel value +// definition expressed as a channel transform (e.g., {transform: …}). +export function isOptions(option) { + return isObject(option) && typeof option.transform !== "function"; +} diff --git a/src/scales/quantitative.js b/src/scales/quantitative.js index 0acadcda0c..a06588efee 100644 --- a/src/scales/quantitative.js +++ b/src/scales/quantitative.js @@ -22,11 +22,11 @@ import { scaleThreshold, scaleIdentity } from "d3"; -import {ordinalRange, quantitativeScheme} from "./schemes.js"; -import {registry, radius, opacity, color, length} from "./index.js"; import {positive, negative, finite} from "../defined.js"; -import {constant} from "../mark.js"; +import {constant} from "../options.js"; import {order} from "../scales.js"; +import {ordinalRange, quantitativeScheme} from "./schemes.js"; +import {registry, radius, opacity, color, length} from "./index.js"; export const flip = i => t => i(1 - t); const unit = [0, 1]; diff --git a/src/transforms/basic.js b/src/transforms/basic.js index 0f555744b1..2f99f3b2e3 100644 --- a/src/transforms/basic.js +++ b/src/transforms/basic.js @@ -1,8 +1,6 @@ -import {isOptions} from "../mark.js"; -import {composeTransform} from "./compose.js"; -import {filterTransform} from "./filter.js"; -import {reverseTransform} from "./reverse.js"; -import {sortTransform} from "./sort.js"; +import {randomLcg} from "d3"; +import {ascendingDefined} from "../defined.js"; +import {arrayify, isOptions, valueof} from "../options.js"; // If both t1 and t2 are defined, returns a composite transform that first // applies t1 and then applies t2. @@ -24,3 +22,58 @@ export function basic({ transform: composeTransform(t1, t2) }; } + +function composeTransform(t1, t2) { + if (t1 == null) return t2 === null ? undefined : t2; + if (t2 == null) return t1 === null ? undefined : t1; + return (data, facets) => { + ({data, facets} = t1(data, facets)); + return t2(arrayify(data), facets); + }; +} + +export function filter(value, options) { + return basic(options, filterTransform(value)); +} + +function filterTransform(value) { + return (data, facets) => { + const V = valueof(data, value); + return {data, facets: facets.map(I => I.filter(i => V[i]))}; + }; +} + +export function reverse(options) { + return basic(options, reverseTransform); +} + +function reverseTransform(data, facets) { + return {data, facets: facets.map(I => I.slice().reverse())}; +} + +export function shuffle({seed, ...options} = {}) { + return basic(options, sortValue(seed == null ? Math.random : randomLcg(seed))); +} + +export function sort(value, options) { + return basic(options, sortTransform(value)); +} + +function sortTransform(value) { + return (typeof value === "function" && value.length !== 1 ? sortCompare : sortValue)(value); +} + +function sortCompare(compare) { + return (data, facets) => { + const compareData = (i, j) => compare(data[i], data[j]); + return {data, facets: facets.map(I => I.slice().sort(compareData))}; + }; +} + +function sortValue(value) { + return (data, facets) => { + const V = valueof(data, value); + const compareValue = (i, j) => ascendingDefined(V[i], V[j]); + return {data, facets: facets.map(I => I.slice().sort(compareValue))}; + }; +} diff --git a/src/transforms/bin.js b/src/transforms/bin.js index e080e0d793..2d5b78619a 100644 --- a/src/transforms/bin.js +++ b/src/transforms/bin.js @@ -1,5 +1,6 @@ import {bin as binner, extent, thresholdFreedmanDiaconis, thresholdScott, thresholdSturges, utcTickInterval} from "d3"; -import {valueof, range, identity, maybeLazyChannel, maybeTuple, maybeColorChannel, maybeValue, mid, labelof, isTemporal} from "../mark.js"; +import {range, identity, maybeLazyChannel, maybeTuple, maybeColorChannel, maybeValue, mid, labelof, isTemporal} from "../mark.js"; +import {valueof} from "../options.js"; import {coerceDate} from "../scales.js"; import {basic} from "./basic.js"; import {hasOutput, maybeEvaluator, maybeGroup, maybeOutput, maybeOutputs, maybeReduce, maybeSort, maybeSubgroup, reduceCount, reduceIdentity} from "./group.js"; diff --git a/src/transforms/compose.js b/src/transforms/compose.js deleted file mode 100644 index 7691d5f4f0..0000000000 --- a/src/transforms/compose.js +++ /dev/null @@ -1,10 +0,0 @@ -import {arrayify} from "../mark.js"; - -export function composeTransform(t1, t2) { - if (t1 == null) return t2 === null ? undefined : t2; - if (t2 == null) return t1 === null ? undefined : t1; - return (data, facets) => { - ({data, facets} = t1(data, facets)); - return t2(arrayify(data), facets); - }; -} diff --git a/src/transforms/filter.js b/src/transforms/filter.js deleted file mode 100644 index ec4f9cf5ee..0000000000 --- a/src/transforms/filter.js +++ /dev/null @@ -1,13 +0,0 @@ -import {valueof} from "../mark.js"; -import {basic} from "./basic.js"; - -export function filter(value, options) { - return basic(options, filterTransform(value)); -} - -export function filterTransform(value) { - return (data, facets) => { - const V = valueof(data, value); - return {data, facets: facets.map(I => I.filter(i => V[i]))}; - }; -} diff --git a/src/transforms/group.js b/src/transforms/group.js index fbc3a00648..8b81cb6cbf 100644 --- a/src/transforms/group.js +++ b/src/transforms/group.js @@ -1,6 +1,7 @@ import {group as grouper, sort, sum, deviation, min, max, mean, median, mode, variance, InternSet, minIndex, maxIndex} from "d3"; import {ascendingDefined, firstof} from "../defined.js"; -import {valueof, maybeColorChannel, maybeInput, maybeTuple, maybeLazyChannel, lazyChannel, first, identity, take, labelof, range} from "../mark.js"; +import {maybeColorChannel, maybeInput, maybeTuple, maybeLazyChannel, lazyChannel, first, identity, take, labelof, range} from "../mark.js"; +import {valueof} from "../options.js"; import {basic} from "./basic.js"; // Group on {z, fill, stroke}. diff --git a/src/transforms/interval.js b/src/transforms/interval.js index 8b101ab8d3..b8df93d77e 100644 --- a/src/transforms/interval.js +++ b/src/transforms/interval.js @@ -1,4 +1,5 @@ -import {labelof, maybeValue, valueof} from "../mark.js"; +import {labelof, maybeValue} from "../mark.js"; +import {valueof} from "../options.js"; import {maybeInsetX, maybeInsetY} from "./inset.js"; // TODO Allow the interval to be specified as a string, e.g. “day” or “hour”? diff --git a/src/transforms/map.js b/src/transforms/map.js index c9481a6125..b18b9185f5 100644 --- a/src/transforms/map.js +++ b/src/transforms/map.js @@ -1,5 +1,6 @@ import {count, group, rank} from "d3"; -import {maybeZ, take, valueof, maybeInput, lazyChannel} from "../mark.js"; +import {maybeZ, take, maybeInput, lazyChannel} from "../mark.js"; +import {valueof} from "../options.js"; import {basic} from "./basic.js"; export function mapX(m, options = {}) { diff --git a/src/transforms/reverse.js b/src/transforms/reverse.js deleted file mode 100644 index adbc9701e7..0000000000 --- a/src/transforms/reverse.js +++ /dev/null @@ -1,9 +0,0 @@ -import {basic} from "./basic.js"; - -export function reverse(options) { - return basic(options, reverseTransform); -} - -export function reverseTransform(data, facets) { - return {data, facets: facets.map(I => I.slice().reverse())}; -} diff --git a/src/transforms/select.js b/src/transforms/select.js index f70f3729ea..f8ee6a42f6 100644 --- a/src/transforms/select.js +++ b/src/transforms/select.js @@ -1,5 +1,6 @@ import {greatest, group, least} from "d3"; -import {maybeZ, valueof} from "../mark.js"; +import {maybeZ} from "../mark.js"; +import {valueof} from "../options.js"; import {basic} from "./basic.js"; export function selectFirst(options) { diff --git a/src/transforms/sort.js b/src/transforms/sort.js deleted file mode 100644 index b34f945b59..0000000000 --- a/src/transforms/sort.js +++ /dev/null @@ -1,31 +0,0 @@ -import {randomLcg} from "d3"; -import {ascendingDefined} from "../defined.js"; -import {valueof} from "../mark.js"; -import {basic} from "./basic.js"; - -export function shuffle({seed, ...options} = {}) { - return basic(options, sortValue(seed == null ? Math.random : randomLcg(seed))); -} - -export function sort(value, options) { - return basic(options, sortTransform(value)); -} - -export function sortTransform(value) { - return (typeof value === "function" && value.length !== 1 ? sortCompare : sortValue)(value); -} - -function sortCompare(compare) { - return (data, facets) => { - const compareData = (i, j) => compare(data[i], data[j]); - return {data, facets: facets.map(I => I.slice().sort(compareData))}; - }; -} - -function sortValue(value) { - return (data, facets) => { - const V = valueof(data, value); - const compareValue = (i, j) => ascendingDefined(V[i], V[j]); - return {data, facets: facets.map(I => I.slice().sort(compareValue))}; - }; -} diff --git a/src/transforms/stack.js b/src/transforms/stack.js index 5275763080..692812dae0 100644 --- a/src/transforms/stack.js +++ b/src/transforms/stack.js @@ -1,6 +1,7 @@ import {InternMap, cumsum, group, groupSort, greatest, max, min, rollup, sum} from "d3"; import {ascendingDefined} from "../defined.js"; -import {field, lazyChannel, maybeLazyChannel, maybeZ, mid, range, valueof, maybeZero, isOptions, maybeValue} from "../mark.js"; +import {lazyChannel, maybeLazyChannel, maybeZ, mid, range, maybeZero, maybeValue} from "../mark.js"; +import {field, isOptions, valueof} from "../options.js"; import {basic} from "./basic.js"; export function stackX(stackOptions = {}, options = {}) { From 6a30dc8696bd595d78292a0c307ccf35a2c57eed Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sat, 15 Jan 2022 12:43:22 -1000 Subject: [PATCH 2/7] detangle options --- src/axis.js | 2 +- src/facet.js | 3 +- src/legends/swatches.js | 2 +- src/mark.js | 174 +----------------------------------- src/marks/area.js | 3 +- src/marks/bar.js | 3 +- src/marks/cell.js | 2 +- src/marks/dot.js | 3 +- src/marks/frame.js | 3 +- src/marks/image.js | 3 +- src/marks/line.js | 3 +- src/marks/rect.js | 3 +- src/marks/rule.js | 3 +- src/marks/text.js | 3 +- src/marks/tick.js | 3 +- src/marks/vector.js | 3 +- src/options.js | 174 +++++++++++++++++++++++++++++++++++- src/scales.js | 2 +- src/style.js | 2 +- src/transforms/bin.js | 3 +- src/transforms/group.js | 3 +- src/transforms/identity.js | 2 +- src/transforms/interval.js | 3 +- src/transforms/map.js | 3 +- src/transforms/normalize.js | 2 +- src/transforms/select.js | 3 +- src/transforms/stack.js | 3 +- 27 files changed, 209 insertions(+), 207 deletions(-) diff --git a/src/axis.js b/src/axis.js index 63981ee262..59cee73578 100644 --- a/src/axis.js +++ b/src/axis.js @@ -1,6 +1,6 @@ import {axisTop, axisBottom, axisRight, axisLeft, create, format, utcFormat} from "d3"; +import {boolean, take, number, string, keyword, maybeKeyword, isTemporal} from "./options.js"; import {formatIsoDate} from "./format.js"; -import {boolean, take, number, string, keyword, maybeKeyword, isTemporal} from "./mark.js"; import {radians} from "./math.js"; import {constant} from "./options.js"; import {impliedString} from "./style.js"; diff --git a/src/facet.js b/src/facet.js index 62b198f2b4..13a4983578 100644 --- a/src/facet.js +++ b/src/facet.js @@ -1,6 +1,7 @@ import {cross, difference, groups, InternMap} from "d3"; import {create} from "d3"; -import {Mark, first, second, markify, where} from "./mark.js"; +import {Mark, markify} from "./mark.js"; +import {first, second, where} from "./options.js"; import {applyScales} from "./scales.js"; import {filterStyles} from "./style.js"; diff --git a/src/legends/swatches.js b/src/legends/swatches.js index 98185579e0..9266df201e 100644 --- a/src/legends/swatches.js +++ b/src/legends/swatches.js @@ -1,7 +1,7 @@ import {create, path} from "d3"; import {inferFontVariant} from "../axes.js"; import {maybeTickFormat} from "../axis.js"; -import {maybeColorChannel, maybeNumberChannel} from "../mark.js"; +import {maybeColorChannel, maybeNumberChannel} from "../options.js"; import {applyInlineStyles, impliedString, maybeClassName, none} from "../style.js"; function maybeScale(scale, key) { diff --git a/src/mark.js b/src/mark.js index ce5007abed..e6bb2f7c6b 100644 --- a/src/mark.js +++ b/src/mark.js @@ -1,5 +1,5 @@ -import {ascending, color, descending, rollup, sort} from "d3"; -import {arrayify, isOptions, valueof} from "./options.js"; +import {ascending, descending, rollup, sort} from "d3"; +import {arrayify, first, isOptions, keyword, labelof, maybeValue, range, valueof} from "./options.js"; import {plot} from "./plot.js"; import {registry} from "./scales/index.js"; import {styles} from "./style.js"; @@ -102,176 +102,6 @@ function channelSort(channels, facetChannels, data, options) { } } -export const indexOf = (d, i) => i; -export const identity = {transform: d => d}; -export const zero = () => 0; -export const string = x => x == null ? x : `${x}`; -export const number = x => x == null ? x : +x; -export const boolean = x => x == null ? x : !!x; -export const first = d => d[0]; -export const second = d => d[1]; - -// A few extra color keywords not known to d3-color. -const colors = new Set(["currentColor", "none"]); - -// Some channels may allow a string constant to be specified; to differentiate -// string constants (e.g., "red") from named fields (e.g., "date"), this -// function tests whether the given value is a CSS color string and returns a -// tuple [channel, constant] where one of the two is undefined, and the other is -// the given value. If you wish to reference a named field that is also a valid -// CSS color, use an accessor (d => d.red) instead. -export function maybeColorChannel(value, defaultValue) { - if (value === undefined) value = defaultValue; - return value === null ? [undefined, "none"] - : typeof value === "string" && (colors.has(value) || color(value)) ? [undefined, value] - : [value, undefined]; -} - -// Similar to maybeColorChannel, this tests whether the given value is a number -// indicating a constant, and otherwise assumes that it’s a channel value. -export function maybeNumberChannel(value, defaultValue) { - if (value === undefined) value = defaultValue; - return value === null || typeof value === "number" ? [undefined, value] - : [value, undefined]; -} - -// Validates the specified optional string against the allowed list of keywords. -export function maybeKeyword(input, name, allowed) { - if (input != null) return keyword(input, name, allowed); -} - -// Validates the specified required string against the allowed list of keywords. -export function keyword(input, name, allowed) { - const i = `${input}`.toLowerCase(); - if (!allowed.includes(i)) throw new Error(`invalid ${name}: ${input}`); - return i; -} - -// For marks specified either as [0, x] or [x1, x2], such as areas and bars. -export function maybeZero(x, x1, x2, x3 = identity) { - if (x1 === undefined && x2 === undefined) { // {x} or {} - x1 = 0, x2 = x === undefined ? x3 : x; - } else if (x1 === undefined) { // {x, x2} or {x2} - x1 = x === undefined ? 0 : x; - } else if (x2 === undefined) { // {x, x1} or {x1} - x2 = x === undefined ? 0 : x; - } - return [x1, x2]; -} - -// For marks that have x and y channels (e.g., cell, dot, line, text). -export function maybeTuple(x, y) { - return x === undefined && y === undefined ? [first, second] : [x, y]; -} - -// A helper for extracting the z channel, if it is variable. Used by transforms -// that require series, such as moving average and normalize. -export function maybeZ({z, fill, stroke} = {}) { - if (z === undefined) ([z] = maybeColorChannel(fill)); - if (z === undefined) ([z] = maybeColorChannel(stroke)); - return z; -} - -// Returns a Uint32Array with elements [0, 1, 2, … data.length - 1]. -export function range(data) { - return Uint32Array.from(data, indexOf); -} - -// Returns a filtered range of data given the test function. -export function where(data, test) { - return range(data).filter(i => test(data[i], i, data)); -} - -// Returns an array [values[index[0]], values[index[1]], …]. -export function take(values, index) { - return Array.from(index, i => values[i]); -} - -export function maybeInput(key, options) { - if (options[key] !== undefined) return options[key]; - switch (key) { - case "x1": case "x2": key = "x"; break; - case "y1": case "y2": key = "y"; break; - } - return options[key]; -} - -// Defines a channel whose values are lazily populated by calling the returned -// setter. If the given source is labeled, the label is propagated to the -// returned channel definition. -export function lazyChannel(source) { - let value; - return [ - { - transform: () => value, - label: labelof(source) - }, - v => value = v - ]; -} - -export function labelof(value, defaultValue) { - return typeof value === "string" ? value - : value && value.label !== undefined ? value.label - : defaultValue; -} - -// Like lazyChannel, but allows the source to be null. -export function maybeLazyChannel(source) { - return source == null ? [source] : lazyChannel(source); -} - -// Assuming that both x1 and x2 and lazy channels (per above), this derives a -// new a channel that’s the average of the two, and which inherits the channel -// label (if any). Both input channels are assumed to be quantitative. If either -// channel is temporal, the returned channel is also temporal. -export function mid(x1, x2) { - return { - transform(data) { - const X1 = x1.transform(data); - const X2 = x2.transform(data); - return isTemporal(X1) || isTemporal(X2) - ? Array.from(X1, (_, i) => new Date((+X1[i] + +X2[i]) / 2)) - : Float64Array.from(X1, (_, i) => (+X1[i] + +X2[i]) / 2); - }, - label: x1.label - }; -} - -// This distinguishes between per-dimension options and a standalone value. -export function maybeValue(value) { - return value === undefined || isOptions(value) ? value : {value}; -} - -export function numberChannel(source) { - return { - transform: data => valueof(data, source, Float64Array), - label: labelof(source) - }; -} - -export function isOrdinal(values) { - for (const value of values) { - if (value == null) continue; - const type = typeof value; - return type === "string" || type === "boolean"; - } -} - -export function isTemporal(values) { - for (const value of values) { - if (value == null) continue; - return value instanceof Date; - } -} - -export function isNumeric(values) { - for (const value of values) { - if (value == null) continue; - return typeof value === "number"; - } -} - export function markify(mark) { return mark instanceof Mark ? mark : new Render(mark); } diff --git a/src/marks/area.js b/src/marks/area.js index dca3302aff..67d1490404 100644 --- a/src/marks/area.js +++ b/src/marks/area.js @@ -1,7 +1,8 @@ import {area as shapeArea, create, group} from "d3"; import {Curve} from "../curve.js"; import {defined} from "../defined.js"; -import {Mark, indexOf, maybeZ} from "../mark.js"; +import {Mark} from "../mark.js"; +import {indexOf, maybeZ} from "../options.js"; import {applyDirectStyles, applyIndirectStyles, applyTransform, applyGroupedChannelStyles} from "../style.js"; import {maybeIdentityX, maybeIdentityY} from "../transforms/identity.js"; import {maybeStackX, maybeStackY} from "../transforms/stack.js"; diff --git a/src/marks/bar.js b/src/marks/bar.js index 7ac336f5f1..5b10d45c9e 100644 --- a/src/marks/bar.js +++ b/src/marks/bar.js @@ -1,6 +1,7 @@ import {create} from "d3"; import {filter} from "../defined.js"; -import {Mark, number} from "../mark.js"; +import {Mark} from "../mark.js"; +import {number} from "../options.js"; import {isCollapsed} from "../scales.js"; import {applyDirectStyles, applyIndirectStyles, applyTransform, impliedString, applyAttr, applyChannelStyles} from "../style.js"; import {maybeIdentityX, maybeIdentityY} from "../transforms/identity.js"; diff --git a/src/marks/cell.js b/src/marks/cell.js index 04acb9cdd2..2f097a992f 100644 --- a/src/marks/cell.js +++ b/src/marks/cell.js @@ -1,4 +1,4 @@ -import {identity, indexOf, maybeColorChannel, maybeTuple} from "../mark.js"; +import {identity, indexOf, maybeColorChannel, maybeTuple} from "../options.js"; import {AbstractBar} from "./bar.js"; export class Cell extends AbstractBar { diff --git a/src/marks/dot.js b/src/marks/dot.js index 3953406401..bf7179c789 100644 --- a/src/marks/dot.js +++ b/src/marks/dot.js @@ -1,6 +1,7 @@ import {create, path, symbolCircle} from "d3"; import {filter, positive} from "../defined.js"; -import {Mark, identity, maybeNumberChannel, maybeTuple} from "../mark.js"; +import {Mark} from "../mark.js"; +import {identity, maybeNumberChannel, maybeTuple} from "../options.js"; import {maybeSymbolChannel} from "../scales/symbol.js"; import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform, offset} from "../style.js"; diff --git a/src/marks/frame.js b/src/marks/frame.js index 8da9eba0c9..f142f6abc8 100644 --- a/src/marks/frame.js +++ b/src/marks/frame.js @@ -1,5 +1,6 @@ import {create} from "d3"; -import {Mark, number} from "../mark.js"; +import {Mark} from "../mark.js"; +import {number} from "../options.js"; import {applyDirectStyles, applyIndirectStyles, applyTransform, offset} from "../style.js"; const defaults = { diff --git a/src/marks/image.js b/src/marks/image.js index dd037de450..75fd619d9b 100644 --- a/src/marks/image.js +++ b/src/marks/image.js @@ -1,6 +1,7 @@ import {create} from "d3"; import {filter, positive} from "../defined.js"; -import {Mark, maybeNumberChannel, maybeTuple, string} from "../mark.js"; +import {Mark} from "../mark.js"; +import {maybeNumberChannel, maybeTuple, string} from "../options.js"; import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform, applyAttr, offset, impliedString} from "../style.js"; const defaults = { diff --git a/src/marks/line.js b/src/marks/line.js index f8be691af3..ecf899a971 100644 --- a/src/marks/line.js +++ b/src/marks/line.js @@ -1,7 +1,8 @@ import {create, group, line as shapeLine} from "d3"; import {Curve} from "../curve.js"; import {defined} from "../defined.js"; -import {Mark, indexOf, identity, maybeTuple, maybeZ} from "../mark.js"; +import {Mark} from "../mark.js"; +import {indexOf, identity, maybeTuple, maybeZ} from "../options.js"; import {applyDirectStyles, applyIndirectStyles, applyTransform, applyGroupedChannelStyles, offset} from "../style.js"; const defaults = { diff --git a/src/marks/rect.js b/src/marks/rect.js index 849b6c1dcd..ef29ce48f1 100644 --- a/src/marks/rect.js +++ b/src/marks/rect.js @@ -1,6 +1,7 @@ import {create} from "d3"; import {filter} from "../defined.js"; -import {Mark, number} from "../mark.js"; +import {Mark} from "../mark.js"; +import {number} from "../options.js"; import {isCollapsed} from "../scales.js"; import {applyDirectStyles, applyIndirectStyles, applyTransform, impliedString, applyAttr, applyChannelStyles} from "../style.js"; import {maybeIdentityX, maybeIdentityY} from "../transforms/identity.js"; diff --git a/src/marks/rule.js b/src/marks/rule.js index 2ca13bbcff..008df5081c 100644 --- a/src/marks/rule.js +++ b/src/marks/rule.js @@ -1,6 +1,7 @@ import {create} from "d3"; import {filter} from "../defined.js"; -import {Mark, identity, number} from "../mark.js"; +import {Mark} from "../mark.js"; +import {identity, number} from "../options.js"; import {isCollapsed} from "../scales.js"; import {applyDirectStyles, applyIndirectStyles, applyTransform, applyChannelStyles, offset} from "../style.js"; import {maybeIntervalX, maybeIntervalY} from "../transforms/interval.js"; diff --git a/src/marks/text.js b/src/marks/text.js index d344329e25..49e74c05d5 100644 --- a/src/marks/text.js +++ b/src/marks/text.js @@ -1,6 +1,7 @@ import {create} from "d3"; import {filter, nonempty} from "../defined.js"; -import {Mark, indexOf, identity, string, maybeNumberChannel, maybeTuple, numberChannel, isNumeric, isTemporal} from "../mark.js"; +import {Mark} from "../mark.js"; +import {indexOf, identity, string, maybeNumberChannel, maybeTuple, numberChannel, isNumeric, isTemporal} from "../options.js"; import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyAttr, applyText, applyTransform, offset} from "../style.js"; const defaults = { diff --git a/src/marks/tick.js b/src/marks/tick.js index 09740e7282..e326155a43 100644 --- a/src/marks/tick.js +++ b/src/marks/tick.js @@ -1,6 +1,7 @@ import {create} from "d3"; import {filter} from "../defined.js"; -import {Mark, identity, number} from "../mark.js"; +import {Mark} from "../mark.js"; +import {identity, number} from "../options.js"; import {applyDirectStyles, applyIndirectStyles, applyTransform, applyChannelStyles, offset} from "../style.js"; const defaults = { diff --git a/src/marks/vector.js b/src/marks/vector.js index 03e39c86e2..8bac6b0fbe 100644 --- a/src/marks/vector.js +++ b/src/marks/vector.js @@ -1,6 +1,7 @@ import {create} from "d3"; import {filter} from "../defined.js"; -import {Mark, maybeNumberChannel, maybeTuple, keyword} from "../mark.js"; +import {Mark} from "../mark.js"; +import {maybeNumberChannel, maybeTuple, keyword} from "../options.js"; import {radians} from "../math.js"; import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform, offset} from "../style.js"; diff --git a/src/options.js b/src/options.js index 6451c89bb8..729c8b3880 100644 --- a/src/options.js +++ b/src/options.js @@ -1,13 +1,179 @@ +import {color} from "d3"; + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray const TypedArray = Object.getPrototypeOf(Uint8Array); const objectToString = Object.prototype.toString; -export function field(name) { - return d => d[name]; +export const field = name => d => d[name]; +export const constant = x => () => x; +export const indexOf = (d, i) => i; +export const identity = {transform: d => d}; +export const zero = () => 0; +export const string = x => x == null ? x : `${x}`; +export const number = x => x == null ? x : +x; +export const boolean = x => x == null ? x : !!x; +export const first = d => d[0]; +export const second = d => d[1]; + +// A few extra color keywords not known to d3-color. +const colors = new Set(["currentColor", "none"]); + +// Some channels may allow a string constant to be specified; to differentiate +// string constants (e.g., "red") from named fields (e.g., "date"), this +// function tests whether the given value is a CSS color string and returns a +// tuple [channel, constant] where one of the two is undefined, and the other is +// the given value. If you wish to reference a named field that is also a valid +// CSS color, use an accessor (d => d.red) instead. +export function maybeColorChannel(value, defaultValue) { + if (value === undefined) value = defaultValue; + return value === null ? [undefined, "none"] + : typeof value === "string" && (colors.has(value) || color(value)) ? [undefined, value] + : [value, undefined]; +} + +// Similar to maybeColorChannel, this tests whether the given value is a number +// indicating a constant, and otherwise assumes that it’s a channel value. +export function maybeNumberChannel(value, defaultValue) { + if (value === undefined) value = defaultValue; + return value === null || typeof value === "number" ? [undefined, value] + : [value, undefined]; +} + +// Validates the specified optional string against the allowed list of keywords. +export function maybeKeyword(input, name, allowed) { + if (input != null) return keyword(input, name, allowed); +} + +// Validates the specified required string against the allowed list of keywords. +export function keyword(input, name, allowed) { + const i = `${input}`.toLowerCase(); + if (!allowed.includes(i)) throw new Error(`invalid ${name}: ${input}`); + return i; +} + +// For marks specified either as [0, x] or [x1, x2], such as areas and bars. +export function maybeZero(x, x1, x2, x3 = identity) { + if (x1 === undefined && x2 === undefined) { // {x} or {} + x1 = 0, x2 = x === undefined ? x3 : x; + } else if (x1 === undefined) { // {x, x2} or {x2} + x1 = x === undefined ? 0 : x; + } else if (x2 === undefined) { // {x, x1} or {x1} + x2 = x === undefined ? 0 : x; + } + return [x1, x2]; +} + +// For marks that have x and y channels (e.g., cell, dot, line, text). +export function maybeTuple(x, y) { + return x === undefined && y === undefined ? [first, second] : [x, y]; +} + +// A helper for extracting the z channel, if it is variable. Used by transforms +// that require series, such as moving average and normalize. +export function maybeZ({z, fill, stroke} = {}) { + if (z === undefined) ([z] = maybeColorChannel(fill)); + if (z === undefined) ([z] = maybeColorChannel(stroke)); + return z; +} + +// Returns a Uint32Array with elements [0, 1, 2, … data.length - 1]. +export function range(data) { + return Uint32Array.from(data, indexOf); +} + +// Returns a filtered range of data given the test function. +export function where(data, test) { + return range(data).filter(i => test(data[i], i, data)); +} + +// Returns an array [values[index[0]], values[index[1]], …]. +export function take(values, index) { + return Array.from(index, i => values[i]); +} + +export function maybeInput(key, options) { + if (options[key] !== undefined) return options[key]; + switch (key) { + case "x1": case "x2": key = "x"; break; + case "y1": case "y2": key = "y"; break; + } + return options[key]; +} + +// Defines a channel whose values are lazily populated by calling the returned +// setter. If the given source is labeled, the label is propagated to the +// returned channel definition. +export function lazyChannel(source) { + let value; + return [ + { + transform: () => value, + label: labelof(source) + }, + v => value = v + ]; +} + +export function labelof(value, defaultValue) { + return typeof value === "string" ? value + : value && value.label !== undefined ? value.label + : defaultValue; +} + +// Like lazyChannel, but allows the source to be null. +export function maybeLazyChannel(source) { + return source == null ? [source] : lazyChannel(source); +} + +// Assuming that both x1 and x2 and lazy channels (per above), this derives a +// new a channel that’s the average of the two, and which inherits the channel +// label (if any). Both input channels are assumed to be quantitative. If either +// channel is temporal, the returned channel is also temporal. +export function mid(x1, x2) { + return { + transform(data) { + const X1 = x1.transform(data); + const X2 = x2.transform(data); + return isTemporal(X1) || isTemporal(X2) + ? Array.from(X1, (_, i) => new Date((+X1[i] + +X2[i]) / 2)) + : Float64Array.from(X1, (_, i) => (+X1[i] + +X2[i]) / 2); + }, + label: x1.label + }; +} + +// This distinguishes between per-dimension options and a standalone value. +export function maybeValue(value) { + return value === undefined || isOptions(value) ? value : {value}; +} + +export function numberChannel(source) { + return { + transform: data => valueof(data, source, Float64Array), + label: labelof(source) + }; +} + +export function isOrdinal(values) { + for (const value of values) { + if (value == null) continue; + const type = typeof value; + return type === "string" || type === "boolean"; + } +} + +export function isTemporal(values) { + for (const value of values) { + if (value == null) continue; + return value instanceof Date; + } } -export function constant(value) { - return () => value; +export function isNumeric(values) { + for (const value of values) { + if (value == null) continue; + return typeof value === "number"; + } } // This allows transforms to behave equivalently to channels. diff --git a/src/scales.js b/src/scales.js index 811f5fe1b6..8fe7726ad8 100644 --- a/src/scales.js +++ b/src/scales.js @@ -1,11 +1,11 @@ import {descending} from "d3"; import {parse as isoParse} from "isoformat"; +import {isOrdinal, isTemporal} 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} from "./scales/ordinal.js"; -import {isOrdinal, isTemporal} from "./mark.js"; export function Scales(channels, { inset: globalInset = 0, diff --git a/src/style.js b/src/style.js index d8a3b310f3..1ef197d99d 100644 --- a/src/style.js +++ b/src/style.js @@ -1,5 +1,5 @@ import {isoFormat, namespaces} from "d3"; -import {string, number, maybeColorChannel, maybeNumberChannel, isTemporal, isNumeric} from "./mark.js"; +import {string, number, maybeColorChannel, maybeNumberChannel, isTemporal, isNumeric} from "./options.js"; import {filter, nonempty} from "./defined.js"; import {formatNumber} from "./format.js"; diff --git a/src/transforms/bin.js b/src/transforms/bin.js index 2d5b78619a..96c6f5d75f 100644 --- a/src/transforms/bin.js +++ b/src/transforms/bin.js @@ -1,6 +1,5 @@ import {bin as binner, extent, thresholdFreedmanDiaconis, thresholdScott, thresholdSturges, utcTickInterval} from "d3"; -import {range, identity, maybeLazyChannel, maybeTuple, maybeColorChannel, maybeValue, mid, labelof, isTemporal} from "../mark.js"; -import {valueof} from "../options.js"; +import {range, identity, maybeLazyChannel, maybeTuple, maybeColorChannel, maybeValue, mid, labelof, isTemporal, valueof} from "../options.js"; import {coerceDate} from "../scales.js"; import {basic} from "./basic.js"; import {hasOutput, maybeEvaluator, maybeGroup, maybeOutput, maybeOutputs, maybeReduce, maybeSort, maybeSubgroup, reduceCount, reduceIdentity} from "./group.js"; diff --git a/src/transforms/group.js b/src/transforms/group.js index 8b81cb6cbf..55cc57b437 100644 --- a/src/transforms/group.js +++ b/src/transforms/group.js @@ -1,7 +1,6 @@ import {group as grouper, sort, sum, deviation, min, max, mean, median, mode, variance, InternSet, minIndex, maxIndex} from "d3"; import {ascendingDefined, firstof} from "../defined.js"; -import {maybeColorChannel, maybeInput, maybeTuple, maybeLazyChannel, lazyChannel, first, identity, take, labelof, range} from "../mark.js"; -import {valueof} from "../options.js"; +import {maybeColorChannel, maybeInput, maybeTuple, maybeLazyChannel, lazyChannel, first, identity, take, labelof, range, valueof} from "../options.js"; import {basic} from "./basic.js"; // Group on {z, fill, stroke}. diff --git a/src/transforms/identity.js b/src/transforms/identity.js index f7bf429fda..c32145f8a1 100644 --- a/src/transforms/identity.js +++ b/src/transforms/identity.js @@ -1,4 +1,4 @@ -import {identity} from "../mark.js"; +import {identity} from "../options.js"; export function maybeIdentityX(options = {}) { const {x, x1, x2} = options; diff --git a/src/transforms/interval.js b/src/transforms/interval.js index b8df93d77e..7171130983 100644 --- a/src/transforms/interval.js +++ b/src/transforms/interval.js @@ -1,5 +1,4 @@ -import {labelof, maybeValue} from "../mark.js"; -import {valueof} from "../options.js"; +import {labelof, maybeValue, valueof} from "../options.js"; import {maybeInsetX, maybeInsetY} from "./inset.js"; // TODO Allow the interval to be specified as a string, e.g. “day” or “hour”? diff --git a/src/transforms/map.js b/src/transforms/map.js index b18b9185f5..d850608883 100644 --- a/src/transforms/map.js +++ b/src/transforms/map.js @@ -1,6 +1,5 @@ import {count, group, rank} from "d3"; -import {maybeZ, take, maybeInput, lazyChannel} from "../mark.js"; -import {valueof} from "../options.js"; +import {maybeZ, take, maybeInput, lazyChannel, valueof} from "../options.js"; import {basic} from "./basic.js"; export function mapX(m, options = {}) { diff --git a/src/transforms/normalize.js b/src/transforms/normalize.js index 5ef62addc3..ee302c77d5 100644 --- a/src/transforms/normalize.js +++ b/src/transforms/normalize.js @@ -1,6 +1,6 @@ import {extent, deviation, max, mean, median, min, sum} from "d3"; import {defined} from "../defined.js"; -import {take} from "../mark.js"; +import {take} from "../options.js"; import {mapX, mapY} from "./map.js"; export function normalizeX(basis, options) { diff --git a/src/transforms/select.js b/src/transforms/select.js index f8ee6a42f6..9afd6921f5 100644 --- a/src/transforms/select.js +++ b/src/transforms/select.js @@ -1,6 +1,5 @@ import {greatest, group, least} from "d3"; -import {maybeZ} from "../mark.js"; -import {valueof} from "../options.js"; +import {maybeZ, valueof} from "../options.js"; import {basic} from "./basic.js"; export function selectFirst(options) { diff --git a/src/transforms/stack.js b/src/transforms/stack.js index 692812dae0..5d7ec92116 100644 --- a/src/transforms/stack.js +++ b/src/transforms/stack.js @@ -1,7 +1,6 @@ import {InternMap, cumsum, group, groupSort, greatest, max, min, rollup, sum} from "d3"; import {ascendingDefined} from "../defined.js"; -import {lazyChannel, maybeLazyChannel, maybeZ, mid, range, maybeZero, maybeValue} from "../mark.js"; -import {field, isOptions, valueof} from "../options.js"; +import {field, isOptions, valueof, lazyChannel, maybeLazyChannel, maybeZ, mid, range, maybeZero, maybeValue} from "../options.js"; import {basic} from "./basic.js"; export function stackX(stackOptions = {}, options = {}) { From 6e9aba8dca901ec976f6fedf9d101eb514878264 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sat, 15 Jan 2022 12:45:56 -1000 Subject: [PATCH 3/7] detangle scales --- src/options.js | 9 ++++++++- src/scales.js | 10 +--------- src/scales/quantitative.js | 3 +-- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/options.js b/src/options.js index 729c8b3880..e8f012d4d1 100644 --- a/src/options.js +++ b/src/options.js @@ -1,4 +1,4 @@ -import {color} from "d3"; +import {color, descending} from "d3"; // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray const TypedArray = Object.getPrototypeOf(Uint8Array); @@ -206,3 +206,10 @@ export function isObject(option) { export function isOptions(option) { return isObject(option) && typeof option.transform !== "function"; } + +export function order(values) { + if (values == null) return; + const first = values[0]; + const last = values[values.length - 1]; + return descending(first, last); +} diff --git a/src/scales.js b/src/scales.js index 8fe7726ad8..d4c1f682a8 100644 --- a/src/scales.js +++ b/src/scales.js @@ -1,6 +1,5 @@ -import {descending} from "d3"; import {parse as isoParse} from "isoformat"; -import {isOrdinal, isTemporal} from "./options.js"; +import {isOrdinal, isTemporal, order} 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"; @@ -245,13 +244,6 @@ export function scaleOrder({range, domain = range}) { return Math.sign(order(domain)) * Math.sign(order(range)); } -export function order(values) { - if (values == null) return; - const first = values[0]; - const last = values[values.length - 1]; - return descending(first, last); -} - // TODO use Float64Array.from for position and radius scales? export function applyScales(channels = [], scales) { const values = Object.create(null); diff --git a/src/scales/quantitative.js b/src/scales/quantitative.js index a06588efee..39f6584a1c 100644 --- a/src/scales/quantitative.js +++ b/src/scales/quantitative.js @@ -23,8 +23,7 @@ import { scaleIdentity } from "d3"; import {positive, negative, finite} from "../defined.js"; -import {constant} from "../options.js"; -import {order} from "../scales.js"; +import {constant, order} from "../options.js"; import {ordinalRange, quantitativeScheme} from "./schemes.js"; import {registry, radius, opacity, color, length} from "./index.js"; From a91064777487311c9cfdfb4a9bd0cdbd08eb1aba Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sat, 15 Jan 2022 12:56:07 -1000 Subject: [PATCH 4/7] detangle dimensions --- src/dimensions.js | 47 +++++++++++++++++++++++++++++++++++++++++ src/plot.js | 54 +++-------------------------------------------- src/scales.js | 4 ++++ 3 files changed, 54 insertions(+), 51 deletions(-) create mode 100644 src/dimensions.js diff --git a/src/dimensions.js b/src/dimensions.js new file mode 100644 index 0000000000..7718421884 --- /dev/null +++ b/src/dimensions.js @@ -0,0 +1,47 @@ +import {isOrdinalScale} from "./scales.js"; +import {offset} from "./style.js"; + +export function Dimensions( + scales, + { + x: {axis: xAxis} = {}, + y: {axis: yAxis} = {}, + fx: {axis: fxAxis} = {}, + fy: {axis: fyAxis} = {} + }, + { + width = 640, + height = autoHeight(scales), + facet: { + margin: facetMargin, + marginTop: facetMarginTop = facetMargin !== undefined ? facetMargin : fxAxis === "top" ? 30 : 0, + marginRight: facetMarginRight = facetMargin !== undefined ? facetMargin : fyAxis === "right" ? 40 : 0, + marginBottom: facetMarginBottom = facetMargin !== undefined ? facetMargin : fxAxis === "bottom" ? 30 : 0, + marginLeft: facetMarginLeft = facetMargin !== undefined ? facetMargin : fyAxis === "left" ? 40 : 0 + } = {}, + margin, + marginTop = margin !== undefined ? margin : Math.max((xAxis === "top" ? 30 : 0) + facetMarginTop, yAxis || fyAxis ? 20 : 0.5 - offset), + marginRight = margin !== undefined ? margin : Math.max((yAxis === "right" ? 40 : 0) + facetMarginRight, xAxis || fxAxis ? 20 : 0.5 + offset), + marginBottom = margin !== undefined ? margin : Math.max((xAxis === "bottom" ? 30 : 0) + facetMarginBottom, yAxis || fyAxis ? 20 : 0.5 + offset), + marginLeft = margin !== undefined ? margin : Math.max((yAxis === "left" ? 40 : 0) + facetMarginLeft, xAxis || fxAxis ? 20 : 0.5 - offset) + } = {} +) { + return { + width, + height, + marginTop, + marginRight, + marginBottom, + marginLeft, + facetMarginTop, + facetMarginRight, + facetMarginBottom, + facetMarginLeft + }; +} + +function autoHeight({y, fy, fx}) { + const nfy = fy ? fy.scale.domain().length : 1; + const ny = y ? (isOrdinalScale(y) ? y.scale.domain().length : Math.max(7, 17 / nfy)) : 1; + return !!(y || fy) * Math.max(1, Math.min(60, ny * nfy)) * 20 + !!fx * 30 + 60; +} diff --git a/src/plot.js b/src/plot.js index d83e5715e7..d7cac8a417 100644 --- a/src/plot.js +++ b/src/plot.js @@ -1,10 +1,11 @@ import {create} from "d3"; import {Axes, autoAxisTicks, autoScaleLabels} from "./axes.js"; +import {Dimensions} from "./dimensions.js"; import {facets} from "./facet.js"; import {Legends, exposeLegends} from "./legends.js"; import {markify} from "./mark.js"; -import {Scales, autoScaleRange, applyScales, exposeScales, isOrdinalScale} from "./scales.js"; -import {applyInlineStyles, filterStyles, maybeClassName, offset} from "./style.js"; +import {Scales, ScaleFunctions, autoScaleRange, applyScales, exposeScales} from "./scales.js"; +import {applyInlineStyles, filterStyles, maybeClassName} from "./style.js"; export function plot(options = {}) { const {facet, style, caption} = options; @@ -115,52 +116,3 @@ export function plot(options = {}) { figure.legend = exposeLegends(scaleDescriptors, options); return figure; } - -function Dimensions( - scales, - { - x: {axis: xAxis} = {}, - y: {axis: yAxis} = {}, - fx: {axis: fxAxis} = {}, - fy: {axis: fyAxis} = {} - }, - { - width = 640, - height = autoHeight(scales), - facet: { - margin: facetMargin, - marginTop: facetMarginTop = facetMargin !== undefined ? facetMargin : fxAxis === "top" ? 30 : 0, - marginRight: facetMarginRight = facetMargin !== undefined ? facetMargin : fyAxis === "right" ? 40 : 0, - marginBottom: facetMarginBottom = facetMargin !== undefined ? facetMargin : fxAxis === "bottom" ? 30 : 0, - marginLeft: facetMarginLeft = facetMargin !== undefined ? facetMargin : fyAxis === "left" ? 40 : 0 - } = {}, - margin, - marginTop = margin !== undefined ? margin : Math.max((xAxis === "top" ? 30 : 0) + facetMarginTop, yAxis || fyAxis ? 20 : 0.5 - offset), - marginRight = margin !== undefined ? margin : Math.max((yAxis === "right" ? 40 : 0) + facetMarginRight, xAxis || fxAxis ? 20 : 0.5 + offset), - marginBottom = margin !== undefined ? margin : Math.max((xAxis === "bottom" ? 30 : 0) + facetMarginBottom, yAxis || fyAxis ? 20 : 0.5 + offset), - marginLeft = margin !== undefined ? margin : Math.max((yAxis === "left" ? 40 : 0) + facetMarginLeft, xAxis || fxAxis ? 20 : 0.5 - offset) - } = {} -) { - return { - width, - height, - marginTop, - marginRight, - marginBottom, - marginLeft, - facetMarginTop, - facetMarginRight, - facetMarginBottom, - facetMarginLeft - }; -} - -function ScaleFunctions(scales) { - return Object.fromEntries(Object.entries(scales).map(([name, {scale}]) => [name, scale])); -} - -function autoHeight({y, fy, fx}) { - const nfy = fy ? fy.scale.domain().length : 1; - const ny = y ? (isOrdinalScale(y) ? y.scale.domain().length : Math.max(7, 17 / nfy)) : 1; - return !!(y || fy) * Math.max(1, Math.min(60, ny * nfy)) * 20 + !!fx * 30 + 60; -} diff --git a/src/scales.js b/src/scales.js index d4c1f682a8..84592ba274 100644 --- a/src/scales.js +++ b/src/scales.js @@ -61,6 +61,10 @@ export function Scales(channels, { return scales; } +export function ScaleFunctions(scales) { + return Object.fromEntries(Object.entries(scales).map(([name, {scale}]) => [name, scale])); +} + // Mutates scale.range! export function autoScaleRange({x, y, fx, fy}, dimensions) { if (fx) autoScaleRangeX(fx, dimensions); From f3c93d11b02820c75a07203d4e630bc952666bf0 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sat, 15 Jan 2022 13:05:28 -1000 Subject: [PATCH 5/7] detangle plot, marks, facets --- src/channel.js | 60 ++++++++++ src/facet.js | 211 --------------------------------- src/index.js | 3 +- src/mark.js | 130 -------------------- src/marks/area.js | 2 +- src/marks/bar.js | 2 +- src/marks/dot.js | 2 +- src/marks/frame.js | 2 +- src/marks/image.js | 2 +- src/marks/line.js | 2 +- src/marks/link.js | 2 +- src/marks/rect.js | 2 +- src/marks/rule.js | 2 +- src/marks/text.js | 2 +- src/marks/tick.js | 2 +- src/marks/vector.js | 2 +- src/plot.js | 281 +++++++++++++++++++++++++++++++++++++++++++- 17 files changed, 350 insertions(+), 359 deletions(-) create mode 100644 src/channel.js delete mode 100644 src/facet.js delete mode 100644 src/mark.js diff --git a/src/channel.js b/src/channel.js new file mode 100644 index 0000000000..3e6c4793ba --- /dev/null +++ b/src/channel.js @@ -0,0 +1,60 @@ +import {ascending, descending, rollup, sort} from "d3"; +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, hint}) { + return { + scale, + type, + value: valueof(data, value), + label: labelof(value), + hint + }; +} + +export function channelSort(channels, facetChannels, data, options) { + const {reverse: defaultReverse, reduce: defaultReduce = true, limit: defaultLimit} = options; + for (const x in options) { + if (!registry.has(x)) continue; // ignore unknown scale keys + const {value: y, reverse = defaultReverse, reduce = defaultReduce, limit = defaultLimit} = maybeValue(options[x]); + if (reduce == null || reduce === false) continue; // disabled reducer + const X = channels.find(([, {scale}]) => scale === x) || facetChannels && facetChannels.find(([, {scale}]) => scale === x); + if (!X) throw new Error(`missing channel for scale: ${x}`); + const XV = X[1].value; + const [lo = 0, hi = Infinity] = limit && typeof limit[Symbol.iterator] === "function" ? limit : limit < 0 ? [limit] : [0, limit]; + if (y == null) { + X[1].domain = () => { + let domain = XV; + if (reverse) domain = domain.slice().reverse(); + if (lo !== 0 || hi !== Infinity) domain = domain.slice(lo, hi); + return domain; + }; + } else { + let YV; + if (y === "data") { + YV = data; + } else { + const Y = channels.find(([name]) => name === y); + if (!Y) throw new Error(`missing channel: ${y}`); + YV = Y[1].value; + } + const reducer = maybeReduce(reduce === true ? "max" : reduce, YV); + X[1].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); + return domain.map(first); + }; + } + } +} + +function ascendingGroup([ak, av], [bk, bv]) { + return ascending(av, bv) || ascending(ak, bk); +} + +function descendingGroup([ak, av], [bk, bv]) { + return descending(av, bv) || ascending(ak, bk); +} diff --git a/src/facet.js b/src/facet.js deleted file mode 100644 index 13a4983578..0000000000 --- a/src/facet.js +++ /dev/null @@ -1,211 +0,0 @@ -import {cross, difference, groups, InternMap} from "d3"; -import {create} from "d3"; -import {Mark, markify} from "./mark.js"; -import {first, second, where} from "./options.js"; -import {applyScales} from "./scales.js"; -import {filterStyles} from "./style.js"; - -export function facets(data, {x, y, ...options}, marks) { - return x === undefined && y === undefined - ? marks // if no facets are specified, ignore! - : [new Facet(data, {x, y, ...options}, marks)]; -} - -class Facet extends Mark { - constructor(data, {x, y, ...options} = {}, marks = []) { - if (data == null) throw new Error("missing facet data"); - super( - data, - [ - {name: "fx", value: x, scale: "fx", optional: true}, - {name: "fy", value: y, scale: "fy", optional: true} - ], - options - ); - this.marks = marks.flat(Infinity).map(markify); - // The following fields are set by initialize: - this.marksChannels = undefined; // array of mark channels - this.marksIndexByFacet = undefined; // map from facet key to array of mark indexes - } - initialize() { - const {index, channels} = super.initialize(); - const facets = index === undefined ? [] : facetGroups(index, channels); - const facetsKeys = Array.from(facets, first); - const facetsIndex = Array.from(facets, second); - const subchannels = []; - const marksChannels = this.marksChannels = []; - const marksIndexByFacet = this.marksIndexByFacet = facetMap(channels); - for (const facetKey of facetsKeys) { - marksIndexByFacet.set(facetKey, new Array(this.marks.length)); - } - let facetsExclude; - for (let i = 0; i < this.marks.length; ++i) { - const mark = this.marks[i]; - const {facet} = mark; - const markFacets = facet === "auto" ? mark.data === this.data ? facetsIndex : undefined - : facet === "include" ? facetsIndex - : facet === "exclude" ? facetsExclude || (facetsExclude = facetsIndex.map(f => Uint32Array.from(difference(index, f)))) - : undefined; - const {index: I, channels: markChannels} = mark.initialize(markFacets, channels); - // If an index is returned by mark.initialize, its structure depends on - // whether or not faceting has been applied: it is a flat index ([0, 1, 2, - // …]) when not faceted, and a nested index ([[0, 1, …], [2, 3, …], …]) - // when faceted. - if (I !== undefined) { - if (markFacets) { - for (let j = 0; j < facetsKeys.length; ++j) { - marksIndexByFacet.get(facetsKeys[j])[i] = I[j]; - } - } else { - for (let j = 0; j < facetsKeys.length; ++j) { - marksIndexByFacet.get(facetsKeys[j])[i] = I; - } - } - } - for (const [, channel] of markChannels) { - subchannels.push([, channel]); - } - marksChannels.push(markChannels); - } - return {index, channels: [...channels, ...subchannels]}; - } - render(I, scales, channels, dimensions, axes) { - const {marks, marksChannels, marksIndexByFacet} = this; - const {fx, fy} = scales; - const fyDomain = fy && fy.domain(); - const fxDomain = fx && fx.domain(); - const fyMargins = fy && {marginTop: 0, marginBottom: 0, height: fy.bandwidth()}; - const fxMargins = fx && {marginRight: 0, marginLeft: 0, width: fx.bandwidth()}; - const subdimensions = {...dimensions, ...fxMargins, ...fyMargins}; - const marksValues = marksChannels.map(channels => applyScales(channels, scales)); - return create("svg:g") - .call(g => { - if (fy && axes.y) { - const axis1 = axes.y, axis2 = nolabel(axis1); - const j = axis1.labelAnchor === "bottom" ? fyDomain.length - 1 : axis1.labelAnchor === "center" ? fyDomain.length >> 1 : 0; - const fyDimensions = {...dimensions, ...fyMargins}; - g.selectAll() - .data(fyDomain) - .join("g") - .attr("transform", ky => `translate(0,${fy(ky)})`) - .append((ky, i) => (i === j ? axis1 : axis2).render( - fx && where(fxDomain, kx => marksIndexByFacet.has([kx, ky])), - scales, - null, - fyDimensions - )); - } - if (fx && axes.x) { - const axis1 = axes.x, axis2 = nolabel(axis1); - const j = axis1.labelAnchor === "right" ? fxDomain.length - 1 : axis1.labelAnchor === "center" ? fxDomain.length >> 1 : 0; - const {marginLeft, marginRight} = dimensions; - const fxDimensions = {...dimensions, ...fxMargins, labelMarginLeft: marginLeft, labelMarginRight: marginRight}; - g.selectAll() - .data(fxDomain) - .join("g") - .attr("transform", kx => `translate(${fx(kx)},0)`) - .append((kx, i) => (i === j ? axis1 : axis2).render( - fy && where(fyDomain, ky => marksIndexByFacet.has([kx, ky])), - scales, - null, - fxDimensions - )); - } - }) - .call(g => g.selectAll() - .data(facetKeys(scales).filter(marksIndexByFacet.has, marksIndexByFacet)) - .join("g") - .attr("transform", facetTranslate(fx, fy)) - .each(function(key) { - const marksFacetIndex = marksIndexByFacet.get(key); - for (let i = 0; i < marks.length; ++i) { - const values = marksValues[i]; - const index = filterStyles(marksFacetIndex[i], values); - const node = marks[i].render( - index, - scales, - values, - subdimensions - ); - if (node != null) this.appendChild(node); - } - })) - .node(); - } -} - -// Derives a copy of the specified axis with the label disabled. -function nolabel(axis) { - return axis === undefined || axis.label === undefined - ? axis // use the existing axis if unlabeled - : Object.assign(Object.create(axis), {label: undefined}); -} - -// Unlike facetGroups, which returns groups in order of input data, this returns -// keys in order of the associated scale’s domains. -function facetKeys({fx, fy}) { - return fx && fy ? cross(fx.domain(), fy.domain()) - : fx ? fx.domain() - : fy.domain(); -} - -// 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 facetGroup1(index, [, {value: F}]) { - return groups(index, i => F[i]); -} - -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])); -} - -// This must match the key structure returned by facetGroups. -function facetTranslate(fx, fy) { - return fx && fy ? ([kx, ky]) => `translate(${fx(kx)},${fy(ky)})` - : fx ? kx => `translate(${fx(kx)},0)` - : ky => `translate(0,${fy(ky)})`; -} - -function facetMap(channels) { - return new (channels.length > 1 ? FacetMap2 : FacetMap); -} - -class FacetMap { - constructor() { - this._ = new InternMap(); - } - has(key) { - return this._.has(key); - } - get(key) { - return this._.get(key); - } - set(key, value) { - return this._.set(key, value), this; - } -} - -// A Map-like interface that supports paired keys. -class FacetMap2 extends FacetMap { - has([key1, key2]) { - const map = super.get(key1); - return map ? map.has(key2) : false; - } - get([key1, key2]) { - const map = super.get(key1); - return map && map.get(key2); - } - set([key1, key2], value) { - const map = super.get(key1); - if (map) map.set(key2, value); - else super.set(key1, new InternMap([[key2, value]])); - return this; - } -} diff --git a/src/index.js b/src/index.js index 36acdbe60e..008315bbdf 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,4 @@ -export {plot} from "./plot.js"; -export {Mark, marks} from "./mark.js"; +export {plot, Mark, marks} from "./plot.js"; export {Area, area, areaX, areaY} from "./marks/area.js"; export {BarX, BarY, barX, barY} from "./marks/bar.js"; export {Cell, cell, cellX, cellY} from "./marks/cell.js"; diff --git a/src/mark.js b/src/mark.js deleted file mode 100644 index e6bb2f7c6b..0000000000 --- a/src/mark.js +++ /dev/null @@ -1,130 +0,0 @@ -import {ascending, descending, rollup, sort} from "d3"; -import {arrayify, first, isOptions, keyword, labelof, maybeValue, range, valueof} from "./options.js"; -import {plot} from "./plot.js"; -import {registry} from "./scales/index.js"; -import {styles} from "./style.js"; -import {basic} from "./transforms/basic.js"; -import {maybeReduce} from "./transforms/group.js"; - -export class Mark { - constructor(data, channels = [], options = {}, defaults) { - const {facet = "auto", sort, dx, dy} = options; - const names = new Set(); - this.data = data; - 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); - this.transform = transform; - if (defaults !== undefined) channels = styles(this, options, channels, defaults); - this.channels = channels.filter(channel => { - const {name, value, optional} = channel; - if (value == null) { - if (optional) return false; - throw new Error(`missing channel value: ${name}`); - } - if (name !== undefined) { - const key = `${name}`; - if (key === "__proto__") throw new Error("illegal channel name"); - if (names.has(key)) throw new Error(`duplicate channel: ${key}`); - names.add(key); - } - return true; - }); - this.dx = +dx || 0; - this.dy = +dy || 0; - } - initialize(facets, facetChannels) { - let data = arrayify(this.data); - let index = facets === undefined && data != null ? range(data) : facets; - if (data !== undefined && this.transform !== undefined) { - if (facets === undefined) index = index.length ? [index] : []; - ({facets: index, data} = this.transform(data, index)); - data = arrayify(data); - if (facets === undefined && index.length) ([index] = index); - } - const channels = this.channels.map(channel => { - const {name} = channel; - return [name == null ? undefined : `${name}`, Channel(data, channel)]; - }); - if (this.sort != null) channelSort(channels, facetChannels, data, this.sort); - return {index, channels}; - } - plot({marks = [], ...options} = {}) { - return plot({...options, marks: [...marks, this]}); - } -} - -// TODO Type coercion? -function Channel(data, {scale, type, value, hint}) { - return { - scale, - type, - value: valueof(data, value), - label: labelof(value), - hint - }; -} - -function channelSort(channels, facetChannels, data, options) { - const {reverse: defaultReverse, reduce: defaultReduce = true, limit: defaultLimit} = options; - for (const x in options) { - if (!registry.has(x)) continue; // ignore unknown scale keys - const {value: y, reverse = defaultReverse, reduce = defaultReduce, limit = defaultLimit} = maybeValue(options[x]); - if (reduce == null || reduce === false) continue; // disabled reducer - const X = channels.find(([, {scale}]) => scale === x) || facetChannels && facetChannels.find(([, {scale}]) => scale === x); - if (!X) throw new Error(`missing channel for scale: ${x}`); - const XV = X[1].value; - const [lo = 0, hi = Infinity] = limit && typeof limit[Symbol.iterator] === "function" ? limit : limit < 0 ? [limit] : [0, limit]; - if (y == null) { - X[1].domain = () => { - let domain = XV; - if (reverse) domain = domain.slice().reverse(); - if (lo !== 0 || hi !== Infinity) domain = domain.slice(lo, hi); - return domain; - }; - } else { - let YV; - if (y === "data") { - YV = data; - } else { - const Y = channels.find(([name]) => name === y); - if (!Y) throw new Error(`missing channel: ${y}`); - YV = Y[1].value; - } - const reducer = maybeReduce(reduce === true ? "max" : reduce, YV); - X[1].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); - return domain.map(first); - }; - } - } -} - -export function markify(mark) { - return mark instanceof Mark ? mark : new Render(mark); -} - -class Render extends Mark { - constructor(render) { - super(); - if (render == null) return; - if (typeof render !== "function") throw new TypeError("invalid mark"); - this.render = render; - } - render() {} -} - -export function marks(...marks) { - marks.plot = Mark.prototype.plot; - return marks; -} - -function ascendingGroup([ak, av], [bk, bv]) { - return ascending(av, bv) || ascending(ak, bk); -} - -function descendingGroup([ak, av], [bk, bv]) { - return descending(av, bv) || ascending(ak, bk); -} diff --git a/src/marks/area.js b/src/marks/area.js index 67d1490404..0d5620637e 100644 --- a/src/marks/area.js +++ b/src/marks/area.js @@ -1,7 +1,7 @@ import {area as shapeArea, create, group} from "d3"; import {Curve} from "../curve.js"; import {defined} from "../defined.js"; -import {Mark} from "../mark.js"; +import {Mark} from "../plot.js"; import {indexOf, maybeZ} from "../options.js"; import {applyDirectStyles, applyIndirectStyles, applyTransform, applyGroupedChannelStyles} from "../style.js"; import {maybeIdentityX, maybeIdentityY} from "../transforms/identity.js"; diff --git a/src/marks/bar.js b/src/marks/bar.js index 5b10d45c9e..0f0d44030c 100644 --- a/src/marks/bar.js +++ b/src/marks/bar.js @@ -1,6 +1,6 @@ import {create} from "d3"; import {filter} from "../defined.js"; -import {Mark} from "../mark.js"; +import {Mark} from "../plot.js"; import {number} from "../options.js"; import {isCollapsed} from "../scales.js"; import {applyDirectStyles, applyIndirectStyles, applyTransform, impliedString, applyAttr, applyChannelStyles} from "../style.js"; diff --git a/src/marks/dot.js b/src/marks/dot.js index bf7179c789..d5ca83171a 100644 --- a/src/marks/dot.js +++ b/src/marks/dot.js @@ -1,6 +1,6 @@ import {create, path, symbolCircle} from "d3"; import {filter, positive} from "../defined.js"; -import {Mark} from "../mark.js"; +import {Mark} from "../plot.js"; import {identity, maybeNumberChannel, maybeTuple} from "../options.js"; import {maybeSymbolChannel} from "../scales/symbol.js"; import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform, offset} from "../style.js"; diff --git a/src/marks/frame.js b/src/marks/frame.js index f142f6abc8..eac4b8beaf 100644 --- a/src/marks/frame.js +++ b/src/marks/frame.js @@ -1,5 +1,5 @@ import {create} from "d3"; -import {Mark} from "../mark.js"; +import {Mark} from "../plot.js"; import {number} from "../options.js"; import {applyDirectStyles, applyIndirectStyles, applyTransform, offset} from "../style.js"; diff --git a/src/marks/image.js b/src/marks/image.js index 75fd619d9b..b20ad9d707 100644 --- a/src/marks/image.js +++ b/src/marks/image.js @@ -1,6 +1,6 @@ import {create} from "d3"; import {filter, positive} from "../defined.js"; -import {Mark} from "../mark.js"; +import {Mark} from "../plot.js"; import {maybeNumberChannel, maybeTuple, string} from "../options.js"; import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform, applyAttr, offset, impliedString} from "../style.js"; diff --git a/src/marks/line.js b/src/marks/line.js index ecf899a971..6fce9f39c0 100644 --- a/src/marks/line.js +++ b/src/marks/line.js @@ -1,7 +1,7 @@ import {create, group, line as shapeLine} from "d3"; import {Curve} from "../curve.js"; import {defined} from "../defined.js"; -import {Mark} from "../mark.js"; +import {Mark} from "../plot.js"; import {indexOf, identity, maybeTuple, maybeZ} from "../options.js"; import {applyDirectStyles, applyIndirectStyles, applyTransform, applyGroupedChannelStyles, offset} from "../style.js"; diff --git a/src/marks/link.js b/src/marks/link.js index 86acb22dc0..8eef19690c 100644 --- a/src/marks/link.js +++ b/src/marks/link.js @@ -1,6 +1,6 @@ import {create, path} from "d3"; import {filter} from "../defined.js"; -import {Mark} from "../mark.js"; +import {Mark} from "../plot.js"; import {Curve} from "../curve.js"; import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform, offset} from "../style.js"; diff --git a/src/marks/rect.js b/src/marks/rect.js index ef29ce48f1..d69a4dfdcf 100644 --- a/src/marks/rect.js +++ b/src/marks/rect.js @@ -1,6 +1,6 @@ import {create} from "d3"; import {filter} from "../defined.js"; -import {Mark} from "../mark.js"; +import {Mark} from "../plot.js"; import {number} from "../options.js"; import {isCollapsed} from "../scales.js"; import {applyDirectStyles, applyIndirectStyles, applyTransform, impliedString, applyAttr, applyChannelStyles} from "../style.js"; diff --git a/src/marks/rule.js b/src/marks/rule.js index 008df5081c..0ed542bcb1 100644 --- a/src/marks/rule.js +++ b/src/marks/rule.js @@ -1,6 +1,6 @@ import {create} from "d3"; import {filter} from "../defined.js"; -import {Mark} from "../mark.js"; +import {Mark} from "../plot.js"; import {identity, number} from "../options.js"; import {isCollapsed} from "../scales.js"; import {applyDirectStyles, applyIndirectStyles, applyTransform, applyChannelStyles, offset} from "../style.js"; diff --git a/src/marks/text.js b/src/marks/text.js index 49e74c05d5..3007e50ec8 100644 --- a/src/marks/text.js +++ b/src/marks/text.js @@ -1,6 +1,6 @@ import {create} from "d3"; import {filter, nonempty} from "../defined.js"; -import {Mark} from "../mark.js"; +import {Mark} from "../plot.js"; import {indexOf, identity, string, maybeNumberChannel, maybeTuple, numberChannel, isNumeric, isTemporal} from "../options.js"; import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyAttr, applyText, applyTransform, offset} from "../style.js"; diff --git a/src/marks/tick.js b/src/marks/tick.js index e326155a43..fbd433ce65 100644 --- a/src/marks/tick.js +++ b/src/marks/tick.js @@ -1,6 +1,6 @@ import {create} from "d3"; import {filter} from "../defined.js"; -import {Mark} from "../mark.js"; +import {Mark} from "../plot.js"; import {identity, number} from "../options.js"; import {applyDirectStyles, applyIndirectStyles, applyTransform, applyChannelStyles, offset} from "../style.js"; diff --git a/src/marks/vector.js b/src/marks/vector.js index 8bac6b0fbe..51902bff44 100644 --- a/src/marks/vector.js +++ b/src/marks/vector.js @@ -1,6 +1,6 @@ import {create} from "d3"; import {filter} from "../defined.js"; -import {Mark} from "../mark.js"; +import {Mark} from "../plot.js"; import {maybeNumberChannel, maybeTuple, keyword} from "../options.js"; import {radians} from "../math.js"; import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform, offset} from "../style.js"; diff --git a/src/plot.js b/src/plot.js index d7cac8a417..8648b3d918 100644 --- a/src/plot.js +++ b/src/plot.js @@ -1,11 +1,12 @@ -import {create} from "d3"; +import {create, cross, difference, groups, InternMap} from "d3"; import {Axes, autoAxisTicks, autoScaleLabels} from "./axes.js"; +import {Channel, channelSort} from "./channel.js"; import {Dimensions} from "./dimensions.js"; -import {facets} from "./facet.js"; import {Legends, exposeLegends} from "./legends.js"; -import {markify} from "./mark.js"; +import {arrayify, isOptions, keyword, range, first, second, where} from "./options.js"; import {Scales, ScaleFunctions, autoScaleRange, applyScales, exposeScales} from "./scales.js"; -import {applyInlineStyles, filterStyles, maybeClassName} from "./style.js"; +import {applyInlineStyles, filterStyles, maybeClassName, styles} from "./style.js"; +import {basic} from "./transforms/basic.js"; export function plot(options = {}) { const {facet, style, caption} = options; @@ -116,3 +117,275 @@ export function plot(options = {}) { figure.legend = exposeLegends(scaleDescriptors, options); return figure; } + +export class Mark { + constructor(data, channels = [], options = {}, defaults) { + const {facet = "auto", sort, dx, dy} = options; + const names = new Set(); + this.data = data; + 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); + this.transform = transform; + if (defaults !== undefined) channels = styles(this, options, channels, defaults); + this.channels = channels.filter(channel => { + const {name, value, optional} = channel; + if (value == null) { + if (optional) return false; + throw new Error(`missing channel value: ${name}`); + } + if (name !== undefined) { + const key = `${name}`; + if (key === "__proto__") throw new Error("illegal channel name"); + if (names.has(key)) throw new Error(`duplicate channel: ${key}`); + names.add(key); + } + return true; + }); + this.dx = +dx || 0; + this.dy = +dy || 0; + } + initialize(facets, facetChannels) { + let data = arrayify(this.data); + let index = facets === undefined && data != null ? range(data) : facets; + if (data !== undefined && this.transform !== undefined) { + if (facets === undefined) index = index.length ? [index] : []; + ({facets: index, data} = this.transform(data, index)); + data = arrayify(data); + if (facets === undefined && index.length) ([index] = index); + } + const channels = this.channels.map(channel => { + const {name} = channel; + return [name == null ? undefined : `${name}`, Channel(data, channel)]; + }); + if (this.sort != null) channelSort(channels, facetChannels, data, this.sort); + return {index, channels}; + } + plot({marks = [], ...options} = {}) { + return plot({...options, marks: [...marks, this]}); + } +} + +export function marks(...marks) { + marks.plot = Mark.prototype.plot; + return marks; +} + +function markify(mark) { + return mark instanceof Mark ? mark : new Render(mark); +} + +class Render extends Mark { + constructor(render) { + super(); + if (render == null) return; + if (typeof render !== "function") throw new TypeError("invalid mark"); + this.render = render; + } + render() {} +} + +function facets(data, {x, y, ...options}, marks) { + return x === undefined && y === undefined + ? marks // if no facets are specified, ignore! + : [new Facet(data, {x, y, ...options}, marks)]; +} + +class Facet extends Mark { + constructor(data, {x, y, ...options} = {}, marks = []) { + if (data == null) throw new Error("missing facet data"); + super( + data, + [ + {name: "fx", value: x, scale: "fx", optional: true}, + {name: "fy", value: y, scale: "fy", optional: true} + ], + options + ); + this.marks = marks.flat(Infinity).map(markify); + // The following fields are set by initialize: + this.marksChannels = undefined; // array of mark channels + this.marksIndexByFacet = undefined; // map from facet key to array of mark indexes + } + initialize() { + const {index, channels} = super.initialize(); + const facets = index === undefined ? [] : facetGroups(index, channels); + const facetsKeys = Array.from(facets, first); + const facetsIndex = Array.from(facets, second); + const subchannels = []; + const marksChannels = this.marksChannels = []; + const marksIndexByFacet = this.marksIndexByFacet = facetMap(channels); + for (const facetKey of facetsKeys) { + marksIndexByFacet.set(facetKey, new Array(this.marks.length)); + } + let facetsExclude; + for (let i = 0; i < this.marks.length; ++i) { + const mark = this.marks[i]; + const {facet} = mark; + const markFacets = facet === "auto" ? mark.data === this.data ? facetsIndex : undefined + : facet === "include" ? facetsIndex + : facet === "exclude" ? facetsExclude || (facetsExclude = facetsIndex.map(f => Uint32Array.from(difference(index, f)))) + : undefined; + const {index: I, channels: markChannels} = mark.initialize(markFacets, channels); + // If an index is returned by mark.initialize, its structure depends on + // whether or not faceting has been applied: it is a flat index ([0, 1, 2, + // …]) when not faceted, and a nested index ([[0, 1, …], [2, 3, …], …]) + // when faceted. + if (I !== undefined) { + if (markFacets) { + for (let j = 0; j < facetsKeys.length; ++j) { + marksIndexByFacet.get(facetsKeys[j])[i] = I[j]; + } + } else { + for (let j = 0; j < facetsKeys.length; ++j) { + marksIndexByFacet.get(facetsKeys[j])[i] = I; + } + } + } + for (const [, channel] of markChannels) { + subchannels.push([, channel]); + } + marksChannels.push(markChannels); + } + return {index, channels: [...channels, ...subchannels]}; + } + render(I, scales, channels, dimensions, axes) { + const {marks, marksChannels, marksIndexByFacet} = this; + const {fx, fy} = scales; + const fyDomain = fy && fy.domain(); + const fxDomain = fx && fx.domain(); + const fyMargins = fy && {marginTop: 0, marginBottom: 0, height: fy.bandwidth()}; + const fxMargins = fx && {marginRight: 0, marginLeft: 0, width: fx.bandwidth()}; + const subdimensions = {...dimensions, ...fxMargins, ...fyMargins}; + const marksValues = marksChannels.map(channels => applyScales(channels, scales)); + return create("svg:g") + .call(g => { + if (fy && axes.y) { + const axis1 = axes.y, axis2 = nolabel(axis1); + const j = axis1.labelAnchor === "bottom" ? fyDomain.length - 1 : axis1.labelAnchor === "center" ? fyDomain.length >> 1 : 0; + const fyDimensions = {...dimensions, ...fyMargins}; + g.selectAll() + .data(fyDomain) + .join("g") + .attr("transform", ky => `translate(0,${fy(ky)})`) + .append((ky, i) => (i === j ? axis1 : axis2).render( + fx && where(fxDomain, kx => marksIndexByFacet.has([kx, ky])), + scales, + null, + fyDimensions + )); + } + if (fx && axes.x) { + const axis1 = axes.x, axis2 = nolabel(axis1); + const j = axis1.labelAnchor === "right" ? fxDomain.length - 1 : axis1.labelAnchor === "center" ? fxDomain.length >> 1 : 0; + const {marginLeft, marginRight} = dimensions; + const fxDimensions = {...dimensions, ...fxMargins, labelMarginLeft: marginLeft, labelMarginRight: marginRight}; + g.selectAll() + .data(fxDomain) + .join("g") + .attr("transform", kx => `translate(${fx(kx)},0)`) + .append((kx, i) => (i === j ? axis1 : axis2).render( + fy && where(fyDomain, ky => marksIndexByFacet.has([kx, ky])), + scales, + null, + fxDimensions + )); + } + }) + .call(g => g.selectAll() + .data(facetKeys(scales).filter(marksIndexByFacet.has, marksIndexByFacet)) + .join("g") + .attr("transform", facetTranslate(fx, fy)) + .each(function(key) { + const marksFacetIndex = marksIndexByFacet.get(key); + for (let i = 0; i < marks.length; ++i) { + const values = marksValues[i]; + const index = filterStyles(marksFacetIndex[i], values); + const node = marks[i].render( + index, + scales, + values, + subdimensions + ); + if (node != null) this.appendChild(node); + } + })) + .node(); + } +} + +// Derives a copy of the specified axis with the label disabled. +function nolabel(axis) { + return axis === undefined || axis.label === undefined + ? axis // use the existing axis if unlabeled + : Object.assign(Object.create(axis), {label: undefined}); +} + +// Unlike facetGroups, which returns groups in order of input data, this returns +// keys in order of the associated scale’s domains. +function facetKeys({fx, fy}) { + return fx && fy ? cross(fx.domain(), fy.domain()) + : fx ? fx.domain() + : fy.domain(); +} + +// 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 facetGroup1(index, [, {value: F}]) { + return groups(index, i => F[i]); +} + +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])); +} + +// This must match the key structure returned by facetGroups. +function facetTranslate(fx, fy) { + return fx && fy ? ([kx, ky]) => `translate(${fx(kx)},${fy(ky)})` + : fx ? kx => `translate(${fx(kx)},0)` + : ky => `translate(0,${fy(ky)})`; +} + +function facetMap(channels) { + return new (channels.length > 1 ? FacetMap2 : FacetMap); +} + +class FacetMap { + constructor() { + this._ = new InternMap(); + } + has(key) { + return this._.has(key); + } + get(key) { + return this._.get(key); + } + set(key, value) { + return this._.set(key, value), this; + } +} + +// A Map-like interface that supports paired keys. +class FacetMap2 extends FacetMap { + has([key1, key2]) { + const map = super.get(key1); + return map ? map.has(key2) : false; + } + get([key1, key2]) { + const map = super.get(key1); + return map && map.get(key2); + } + set([key1, key2], value) { + const map = super.get(key1); + if (map) map.set(key2, value); + else super.set(key1, new InternMap([[key2, value]])); + return this; + } +} From 9ceb4796eeaa1d85fba833d11f43dad61b8fc6fd Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sun, 16 Jan 2022 09:11:23 -0800 Subject: [PATCH 6/7] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Philippe Rivière --- src/axis.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/axis.js b/src/axis.js index 59cee73578..8a00001b51 100644 --- a/src/axis.js +++ b/src/axis.js @@ -1,8 +1,7 @@ import {axisTop, axisBottom, axisRight, axisLeft, create, format, utcFormat} from "d3"; -import {boolean, take, number, string, keyword, maybeKeyword, isTemporal} from "./options.js"; +import {boolean, take, number, string, keyword, maybeKeyword, constant, isTemporal} from "./options.js"; import {formatIsoDate} from "./format.js"; import {radians} from "./math.js"; -import {constant} from "./options.js"; import {impliedString} from "./style.js"; export class AxisX { From 61c76847ca343bbd40547c6a7f16ea48b4ac16e5 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sun, 16 Jan 2022 09:16:07 -0800 Subject: [PATCH 7/7] minimize diff --- src/options.js | 64 ++++++++++++++++++++--------------------- src/transforms/bin.js | 2 +- src/transforms/group.js | 2 +- src/transforms/map.js | 2 +- src/transforms/stack.js | 2 +- 5 files changed, 36 insertions(+), 36 deletions(-) diff --git a/src/options.js b/src/options.js index e8f012d4d1..4095f06969 100644 --- a/src/options.js +++ b/src/options.js @@ -4,8 +4,17 @@ import {color, descending} from "d3"; const TypedArray = Object.getPrototypeOf(Uint8Array); const objectToString = Object.prototype.toString; +// This allows transforms to behave equivalently to channels. +export function valueof(data, value, type) { + const array = type === undefined ? Array : type; + return typeof value === "string" ? array.from(data, field(value)) + : typeof value === "function" ? array.from(data, value) + : typeof value === "number" || value instanceof Date ? array.from(data, constant(value)) + : value && typeof value.transform === "function" ? arrayify(value.transform(data), type) + : arrayify(value, type); // preserve undefined type +} + export const field = name => d => d[name]; -export const constant = x => () => x; export const indexOf = (d, i) => i; export const identity = {transform: d => d}; export const zero = () => 0; @@ -14,6 +23,7 @@ export const number = x => x == null ? x : +x; export const boolean = x => x == null ? x : !!x; export const first = d => d[0]; export const second = d => d[1]; +export const constant = x => () => x; // A few extra color keywords not known to d3-color. const colors = new Set(["currentColor", "none"]); @@ -51,6 +61,27 @@ export function keyword(input, name, allowed) { return i; } +// Promotes the specified data to an array or typed array as needed. If an array +// type is provided (e.g., Array), then the returned array will strictly be of +// the specified type; otherwise, any array or typed array may be returned. If +// the specified data is null or undefined, returns the value as-is. +export function arrayify(data, type) { + return data == null ? data : (type === undefined + ? (data instanceof Array || data instanceof TypedArray) ? data : Array.from(data) + : (data instanceof type ? data : type.from(data))); +} + +// Disambiguates an options object (e.g., {y: "x2"}) from a primitive value. +export function isObject(option) { + return option && option.toString === objectToString; +} + +// Disambiguates an options object (e.g., {y: "x2"}) from a channel value +// definition expressed as a channel transform (e.g., {transform: …}). +export function isOptions(option) { + return isObject(option) && typeof option.transform !== "function"; +} + // For marks specified either as [0, x] or [x1, x2], such as areas and bars. export function maybeZero(x, x1, x2, x3 = identity) { if (x1 === undefined && x2 === undefined) { // {x} or {} @@ -176,37 +207,6 @@ export function isNumeric(values) { } } -// This allows transforms to behave equivalently to channels. -export function valueof(data, value, type) { - const array = type === undefined ? Array : type; - return typeof value === "string" ? array.from(data, field(value)) - : typeof value === "function" ? array.from(data, value) - : typeof value === "number" || value instanceof Date ? array.from(data, constant(value)) - : value && typeof value.transform === "function" ? arrayify(value.transform(data), type) - : arrayify(value, type); // preserve undefined type -} - -// Promotes the specified data to an array or typed array as needed. If an array -// type is provided (e.g., Array), then the returned array will strictly be of -// the specified type; otherwise, any array or typed array may be returned. If -// the specified data is null or undefined, returns the value as-is. -export function arrayify(data, type) { - return data == null ? data : (type === undefined - ? (data instanceof Array || data instanceof TypedArray) ? data : Array.from(data) - : (data instanceof type ? data : type.from(data))); -} - -// Disambiguates an options object (e.g., {y: "x2"}) from a primitive value. -export function isObject(option) { - return option && option.toString === objectToString; -} - -// Disambiguates an options object (e.g., {y: "x2"}) from a channel value -// definition expressed as a channel transform (e.g., {transform: …}). -export function isOptions(option) { - return isObject(option) && typeof option.transform !== "function"; -} - export function order(values) { if (values == null) return; const first = values[0]; diff --git a/src/transforms/bin.js b/src/transforms/bin.js index 96c6f5d75f..77e7eda1d8 100644 --- a/src/transforms/bin.js +++ b/src/transforms/bin.js @@ -1,5 +1,5 @@ import {bin as binner, extent, thresholdFreedmanDiaconis, thresholdScott, thresholdSturges, utcTickInterval} from "d3"; -import {range, identity, maybeLazyChannel, maybeTuple, maybeColorChannel, maybeValue, mid, labelof, isTemporal, valueof} from "../options.js"; +import {valueof, range, identity, maybeLazyChannel, maybeTuple, maybeColorChannel, maybeValue, mid, labelof, isTemporal} from "../options.js"; import {coerceDate} from "../scales.js"; import {basic} from "./basic.js"; import {hasOutput, maybeEvaluator, maybeGroup, maybeOutput, maybeOutputs, maybeReduce, maybeSort, maybeSubgroup, reduceCount, reduceIdentity} from "./group.js"; diff --git a/src/transforms/group.js b/src/transforms/group.js index 55cc57b437..c2da19acc6 100644 --- a/src/transforms/group.js +++ b/src/transforms/group.js @@ -1,6 +1,6 @@ import {group as grouper, sort, sum, deviation, min, max, mean, median, mode, variance, InternSet, minIndex, maxIndex} from "d3"; import {ascendingDefined, firstof} from "../defined.js"; -import {maybeColorChannel, maybeInput, maybeTuple, maybeLazyChannel, lazyChannel, first, identity, take, labelof, range, valueof} from "../options.js"; +import {valueof, maybeColorChannel, maybeInput, maybeTuple, maybeLazyChannel, lazyChannel, first, identity, take, labelof, range} from "../options.js"; import {basic} from "./basic.js"; // Group on {z, fill, stroke}. diff --git a/src/transforms/map.js b/src/transforms/map.js index d850608883..5ecc89ed2b 100644 --- a/src/transforms/map.js +++ b/src/transforms/map.js @@ -1,5 +1,5 @@ import {count, group, rank} from "d3"; -import {maybeZ, take, maybeInput, lazyChannel, valueof} from "../options.js"; +import {maybeZ, take, valueof, maybeInput, lazyChannel} from "../options.js"; import {basic} from "./basic.js"; export function mapX(m, options = {}) { diff --git a/src/transforms/stack.js b/src/transforms/stack.js index 5d7ec92116..f0386558bf 100644 --- a/src/transforms/stack.js +++ b/src/transforms/stack.js @@ -1,6 +1,6 @@ import {InternMap, cumsum, group, groupSort, greatest, max, min, rollup, sum} from "d3"; import {ascendingDefined} from "../defined.js"; -import {field, isOptions, valueof, lazyChannel, maybeLazyChannel, maybeZ, mid, range, maybeZero, maybeValue} from "../options.js"; +import {field, lazyChannel, maybeLazyChannel, maybeZ, mid, range, valueof, maybeZero, isOptions, maybeValue} from "../options.js"; import {basic} from "./basic.js"; export function stackX(stackOptions = {}, options = {}) {