From f36055779f37feb07437c7b794314b1633628e4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Wed, 16 Aug 2023 20:40:14 +0200 Subject: [PATCH] expose instantiated scales descriptors in the render API (#1810) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * expose instantiated scales descriptors in the render API * more uniform handling of identity scales * don’t expose domain and range for identity (yet) --------- Co-authored-by: Mike Bostock --- src/plot.js | 7 +++-- src/scales.d.ts | 4 +-- src/scales.js | 30 ++++++++++--------- src/scales/index.js | 4 +++ src/scales/quantitative.js | 10 +++++-- test/scales/scales-test.js | 60 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 93 insertions(+), 22 deletions(-) diff --git a/src/plot.js b/src/plot.js index 1c6f60712b..11d83282f9 100644 --- a/src/plot.js +++ b/src/plot.js @@ -141,11 +141,11 @@ export function plot(options = {}) { // Initalize the scales and dimensions. const scaleDescriptors = createScales(addScaleChannels(channelsByScale, stateByMark, options), options); - const scales = createScaleFunctions(scaleDescriptors); const dimensions = createDimensions(scaleDescriptors, marks, options); autoScaleRange(scaleDescriptors, dimensions); + const scales = createScaleFunctions(scaleDescriptors); const {fx, fy} = scales; const subdimensions = fx || fy ? innerDimensions(scaleDescriptors, dimensions) : dimensions; const superdimensions = fx || fy ? actualDimensions(scales, dimensions) : dimensions; @@ -221,9 +221,10 @@ export function plot(options = {}) { addScaleChannels(newChannelsByScale, stateByMark, options, (key) => newByScale.has(key)); addScaleChannels(channelsByScale, stateByMark, options, (key) => newByScale.has(key)); const newScaleDescriptors = inheritScaleLabels(createScales(newChannelsByScale, options), scaleDescriptors); - const newScales = createScaleFunctions(newScaleDescriptors); + const {scales: newExposedScales, ...newScales} = createScaleFunctions(newScaleDescriptors); Object.assign(scaleDescriptors, newScaleDescriptors); Object.assign(scales, newScales); + Object.assign(scales.scales, newExposedScales); } // Sort and filter the facets to match the fx and fy domains; this is needed @@ -333,7 +334,7 @@ export function plot(options = {}) { if (caption != null) figure.append(createFigcaption(document, caption)); } - figure.scale = exposeScales(scaleDescriptors); + figure.scale = exposeScales(scales.scales); figure.legend = exposeLegends(scaleDescriptors, context, options); const w = consumeWarnings(); diff --git a/src/scales.d.ts b/src/scales.d.ts index 3ed4d489c8..3d548745f1 100644 --- a/src/scales.d.ts +++ b/src/scales.d.ts @@ -161,9 +161,9 @@ export type ScaleName = "x" | "y" | "fx" | "fy" | "r" | "color" | "opacity" | "s /** * The instantiated scales’ apply functions; passed to marks and initializers - * for rendering. + * for rendering. The scales property exposes all the scale definitions. */ -export type ScaleFunctions = {[key in ScaleName]?: (value: any) => any}; +export type ScaleFunctions = {[key in ScaleName]?: (value: any) => any} & {scales: {[key in ScaleName]?: Scale}}; /** * The supported scale types. For quantitative data, one of: diff --git a/src/scales.js b/src/scales.js index 5b993c18e7..5bc4865f04 100644 --- a/src/scales.js +++ b/src/scales.js @@ -97,17 +97,19 @@ export function createScales( return scales; } -export function createScaleFunctions(scales) { - return Object.fromEntries( - Object.entries(scales) - .filter(([, {scale}]) => scale) // drop identity scales - .map(([name, {scale, type, interval, label}]) => { - scale.type = type; // for axis - if (interval != null) scale.interval = interval; // for axis - if (label != null) scale.label = label; // for axis - return [name, scale]; - }) - ); +export function createScaleFunctions(descriptors) { + const scales = {}; + const scaleFunctions = {scales}; + for (const [key, descriptor] of Object.entries(descriptors)) { + const {scale, type, interval, label} = descriptor; + scales[key] = exposeScale(descriptor); + scaleFunctions[key] = scale; + // TODO: pass these properties, which are needed for axes, in the descriptor. + scale.type = type; + if (interval != null) scale.interval = interval; + if (label != null) scale.label = label; + } + return scaleFunctions; } // Mutates scale.range! @@ -362,7 +364,7 @@ function createScale(key, channels = [], options = {}) { case "band": return createScaleBand(key, channels, options); case "identity": - return registry.get(key) === position ? createScaleIdentity() : {type: "identity"}; + return createScaleIdentity(key); case undefined: return; default: @@ -513,10 +515,10 @@ export function scale(options = {}) { return scale; } -export function exposeScales(scaleDescriptors) { +export function exposeScales(scales) { return (key) => { if (!registry.has((key = `${key}`))) throw new Error(`unknown scale: ${key}`); - return key in scaleDescriptors ? exposeScale(scaleDescriptors[key]) : undefined; + return scales[key]; }; } diff --git a/src/scales/index.js b/src/scales/index.js index 2e624a709a..ee2f2b2688 100644 --- a/src/scales/index.js +++ b/src/scales/index.js @@ -45,3 +45,7 @@ export const registry = new Map([ export function isPosition(kind) { return kind === position || kind === projection; } + +export function hasNumericRange(kind) { + return kind === position || kind === radius || kind === length || kind === opacity; +} diff --git a/src/scales/quantitative.js b/src/scales/quantitative.js index 71e031387f..0625ffcf01 100644 --- a/src/scales/quantitative.js +++ b/src/scales/quantitative.js @@ -25,7 +25,7 @@ import { } from "d3"; import {finite, negative, positive} from "../defined.js"; import {arrayify, constant, maybeNiceInterval, maybeRangeInterval, orderof, slice} from "../options.js"; -import {color, length, opacity, radius, registry} from "./index.js"; +import {color, length, opacity, radius, registry, hasNumericRange} from "./index.js"; import {ordinalRange, quantitativeScheme} from "./schemes.js"; export const flip = (i) => (t) => i(1 - t); @@ -257,8 +257,12 @@ function isOrdered(domain, sign) { return true; } -export function createScaleIdentity() { - return {type: "identity", scale: scaleIdentity()}; +// For non-numeric identity scales such as color and symbol, we can’t use D3’s +// identity scale because it coerces to number; and we can’t compute the domain +// (and equivalently range) since we can’t know whether the values are +// continuous or discrete. +export function createScaleIdentity(key) { + return {type: "identity", scale: hasNumericRange(registry.get(key)) ? scaleIdentity() : (d) => d}; } export function inferDomain(channels, f = finite) { diff --git a/test/scales/scales-test.js b/test/scales/scales-test.js index 146e0e1a92..5135cec632 100644 --- a/test/scales/scales-test.js +++ b/test/scales/scales-test.js @@ -2102,6 +2102,66 @@ it("plot(…).scale(name).apply and invert return the expected functions", () => ]); }); +it("Plot.plot passes render functions scale descriptors", async () => { + const seed = d3.randomLcg(42); + const x = d3.randomNormal.source(seed)(); + Plot.plot({ + marks: [ + Plot.dotX({length: 10001}, {x, fill: seed}), + (index, {x, color, scales}) => { + assert.deepStrictEqual(Object.keys(scales), ["color", "x"]); + assert.strictEqual(x(0), 314.6324357568407); + assert.strictEqual(x(1), 400.26512486789505); + assert.strictEqual(color(0), "rgb(35, 23, 27)"); + assert.strictEqual(color(1), "rgb(144, 12, 0)"); + scaleEqual(scales.color, { + type: "linear", + domain: [0.0003394410014152527, 0.999856373295188], + range: [0, 1], + clamp: false, + interpolate: d3.interpolateTurbo + }); + scaleEqual(scales.x, { + type: "linear", + domain: [-3.440653783215207, 3.5660162890264693], + range: [20, 620], + clamp: false, + interpolate: d3.interpolateNumber + }); + return null; + } + ] + }); +}); + +it("Plot.plot passes render functions re-initialized scale descriptors and functions", async () => { + const seed = d3.randomLcg(42); + const x = d3.randomNormal.source(seed)(); + const y = d3.randomNormal.source(seed)(); + Plot.plot({ + marks: [ + Plot.dot({length: 10001}, Plot.hexbin({fill: "count"}, {x, y})), + (index, {x, y, color, scales}) => { + assert.deepStrictEqual(Object.keys(scales), ["x", "y", "color"]); + assert.ok(Math.abs(x(0) - 351) < 1); + assert.ok(Math.abs(x(1) - 426) < 1); + assert.ok(Math.abs(y(0) - 196) < 1); + assert.ok(Math.abs(y(1) - 148) < 1); + assert.strictEqual(color(1), "rgb(35, 23, 27)"); + assert.strictEqual(color(10), "rgb(72, 58, 164)"); + scaleEqual(scales.color, { + type: "linear", + domain: [1, 161], + range: [0, 1], + clamp: false, + interpolate: d3.interpolateTurbo + }); + return null; + } + ] + }); +}); + it("plot(…).scale(name) returns a deduplicated ordinal domain", () => { const letters = "abbbcaabbcc"; const plot = Plot.dotX(letters).plot({x: {domain: letters}});