From 04b4de3b69e3b7eae0a71fbd0f1c6b97606b0c12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 24 Feb 2026 13:58:38 +0100 Subject: [PATCH] pixelRound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Given a continuous scale, returns a rounding function that snaps values to the coarsest precision that still distinguishes neighboring pixels. For temporal scales, this means utc or time intervals; for numeric scales, “nice” values = 10^x * {1, 2, 5}. For non-linear scales (log, pow, symlog, etc.) we compute precision locally. This is a separate branch needed by both the dataless brush and the dataless crosshair --- src/precision.d.ts | 19 +++++ src/precision.js | 36 ++++++++++ test/precision-test.ts | 157 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 212 insertions(+) create mode 100644 src/precision.d.ts create mode 100644 src/precision.js create mode 100644 test/precision-test.ts diff --git a/src/precision.d.ts b/src/precision.d.ts new file mode 100644 index 0000000000..c9bd99fad6 --- /dev/null +++ b/src/precision.d.ts @@ -0,0 +1,19 @@ +import type {ScaleType} from "./scales.js"; + +/** Internal d3 scale with type, as produced by createScaleFunctions. */ +export interface MaterializedScale { + (value: any): number; + type: ScaleType; + domain(): any[]; + range(): number[]; + invert(value: number): any; +} + +/** + * Returns a function that rounds values in data space to the coarsest + * precision that distinguishes neighboring pixels. For temporal scales, finds + * the coarsest calendar interval that spans at most 1px; for linear scales, + * uses a uniform step; for non-linear scales (where the data density varies), + * computes the step locally. + */ +export function pixelRound(scale: MaterializedScale): (value: any) => any; diff --git a/src/precision.js b/src/precision.js new file mode 100644 index 0000000000..647f53550e --- /dev/null +++ b/src/precision.js @@ -0,0 +1,36 @@ +import {tickStep, timeTickInterval, utcTickInterval} from "d3"; +import {numberInterval} from "./options.js"; + +export function pixelRound(scale) { + if (scale.type === "identity") return Math.round; + if (!scale.invert) throw new Error(`Unsupported scale ${scale.type}`); + const [d0, d1] = scale.domain(); + const r = scale.range(); + const span = Math.abs(r[1] - r[0]); + return !span + ? (v) => v + : scale.type === "linear" + ? niceRound(tickStep(0, Math.abs(d1 - d0) / span, 2)) + : scale.type === "utc" || scale.type === "time" + ? temporalPrecision(scale, d0, d1, span) + : (v) => niceRound(tickStep(0, Math.abs(scale.invert(scale(v) + 0.5) - v), 2))(v); +} + +// Find the coarsest calendar interval whose offset spans at most 1px; +// fall back to identity for sub-millisecond domains. The multipliers +// 1, 1.5, 2, 2.5 cover the possible ratios between adjacent intervals. +function temporalPrecision(scale, d0, d1, span) { + const tickInterval = scale.type === "utc" ? utcTickInterval : timeTickInterval; + const p0 = scale(d0); + for (let k = 1; k <= 2.5; k += 0.5) { + const interval = tickInterval(d0, d1, k * span); + if (!interval) break; + if (Math.abs(scale(interval.offset(d0)) - p0) <= 1) return interval.round; + } + return (v) => v; +} + +function niceRound(step) { + const {floor} = numberInterval(step); + return (v) => floor(+v + step / 2); +} diff --git a/test/precision-test.ts b/test/precision-test.ts new file mode 100644 index 0000000000..c4d8e1ada7 --- /dev/null +++ b/test/precision-test.ts @@ -0,0 +1,157 @@ +import assert from "assert"; +import {scale as createScale} from "../src/index.js"; +import {pixelRound} from "../src/precision.js"; +import type {MaterializedScale} from "../src/precision.js"; + +// pixelRound expects a d3-like scale; Plot.scale() returns plain arrays +// and a separate apply function. +function scale(options: any): MaterializedScale { + const {type, domain, range, apply, invert} = createScale({x: {range: [0, 600], ...options}}) as any; + return Object.assign(apply, {type, domain: () => domain, range: () => range, invert}); +} + +function assertDistinct(s: MaterializedScale, label = "") { + const round = pixelRound(s); + const [r0, r1] = s.range(); + const lo = Math.min(r0, r1); + const hi = Math.max(r0, r1); + let prev = +round(s.invert(lo)); + for (let p = lo + 1; p < hi; ++p) { + const v = +round(s.invert(p)); + assert.notStrictEqual(prev, v, `${label}pixels ${p - 1} and ${p} should map to distinct values`); + prev = v; + } +} + +describe("pixelRound", () => { + it("rounds to integer for identity scales", () => { + const round = pixelRound({type: "identity"} as any); + assert.strictEqual(round(42.7), 43); + assert.strictEqual(round(42.3), 42); + }); + + it("returns identity for a zero-pixel range", () => { + const round = pixelRound(scale({type: "linear", domain: [0, 100], range: [0, 0]})); + assert.strictEqual(round(42), 42); + }); + + it("always returns a round function", () => { + for (const s of [ + scale({type: "linear", domain: [0, 100]}), + scale({type: "utc", domain: [new Date("2020-01-01"), new Date("2025-01-01")]}), + scale({type: "log", domain: [1, 1000], range: [0, 300]}), + scale({type: "symlog", domain: [0, 1000], range: [0, 500]}) + ]) { + const floor = pixelRound(s); + assert.strictEqual(typeof floor, "function", `expected function for ${s.type}`); + } + }); + + describe("linear scales", () => { + it("rounds to a nice step", () => { + const round = pixelRound(scale({type: "linear", domain: [0, 100], range: [0, 500]})); + assert.strictEqual(round(38.87), 38.9); + }); + it("produces clean floating point values", () => { + const round = pixelRound(scale({type: "linear", domain: [0, 100], range: [0, 500]})); + assert.strictEqual(round(38.8), 38.8); + assert.strictEqual(round(0.3), 0.3); + }); + it("handles reversed domains", () => { + const floor = pixelRound(scale({type: "linear", domain: [100, 0], range: [0, 500]})); + assert.strictEqual(typeof floor, "function"); + }); + it("guarantees distinct values for neighboring pixels", () => { + assertDistinct(scale({type: "linear", domain: [0, 100], range: [0, 500]})); + }); + }); + + describe("temporal scales", () => { + it("5 years / 600px rounds to midnight", () => { + const round = pixelRound(scale({type: "utc", domain: [new Date("2020-01-01"), new Date("2025-01-01")]})); + const d = round(new Date("2023-06-15T14:30:00Z")); + assert.strictEqual(d.getUTCHours(), 0); + assert.strictEqual(d.getUTCMinutes(), 0); + }); + it("1 month / 600px rounds to whole minutes", () => { + const round = pixelRound(scale({type: "utc", domain: [new Date("2020-01-01"), new Date("2020-02-01")]})); + const d = round(new Date("2020-01-15T14:30:00Z")); + assert.strictEqual(d.getUTCSeconds(), 0); + }); + it("1 hour / 600px rounds to whole seconds", () => { + const round = pixelRound( + scale({type: "utc", domain: [new Date("2020-01-01T00:00Z"), new Date("2020-01-01T01:00Z")]}) + ); + const d = round(new Date("2020-01-01T00:30:15.789Z")); + assert.strictEqual(d.getUTCMilliseconds(), 0); + }); + it("precision gets finer as the domain shrinks", () => { + const wide = pixelRound(scale({type: "utc", domain: [new Date("2000-01-01"), new Date("2025-01-01")]})); + const narrow = pixelRound(scale({type: "utc", domain: [new Date("2020-01-01"), new Date("2020-02-01")]})); + const d = new Date("2020-01-15T14:30:45Z"); + assert.ok(Math.abs(+d - +wide(d)) >= Math.abs(+d - +narrow(d))); + }); + it("guarantees distinct values for neighboring pixels", () => { + const cases: [Date, Date, number][] = [ + [new Date("2020-01-01"), new Date("2025-01-01"), 600], // 5 years / 600px + [new Date("2020-01-01"), new Date("2020-02-01"), 600], // 1 month / 600px + [new Date("2020-01-01T00:00Z"), new Date("2020-01-01T01:00Z"), 600], // 1 hour / 600px + [new Date("2020-02-01"), new Date("2020-03-01"), 29], // leap February / 29px + [new Date("2021-02-01"), new Date("2021-03-01"), 29], // non-leap February / 29px + [new Date("2025-01-01"), new Date("2020-01-01"), 600], // inverted domain + [new Date("2020-01-01"), new Date("2025-01-01"), -600], // inverted range + [new Date("2025-01-01"), new Date("2020-01-01"), -600] // inverted domain and range + ]; + for (const [d0, d1, r1] of cases) { + assertDistinct(scale({type: "utc", domain: [d0, d1], range: [0, r1]}), `utc ${d0}–${d1}@${r1}px: `); + } + }); + it("guarantees distinct values for neighboring pixels (local time)", () => { + // US DST spring-forward: March 8, 2020 is a 23h day in America/Los_Angeles + const d0 = new Date("2020-03-08T00:00:00-08:00"); // midnight PST + const d1 = new Date("2020-03-09T00:00:00-07:00"); // midnight PDT + assertDistinct(scale({type: "time", domain: [d0, d1], range: [0, 720]}), "DST spring-forward@720px: "); + }); + }); + + describe("log scales", () => { + it("precision gets coarser toward the sparse end", () => { + const s = scale({type: "log", domain: [1, 1000], range: [0, 300]}); + const floor = pixelRound(s); + const v0 = floor(1.5); + const v299 = floor(950.5); + assert.ok(v0 === 1.5 || Math.abs(v0 - 1.5) < 0.1, `near start: ${v0}`); + assert.ok(Math.abs(v299 - 950.5) >= 0.1, `near end should be coarser: ${v299}`); + }); + it("guarantees distinct values for neighboring pixels", () => { + assertDistinct(scale({type: "log", domain: [1, 1000], range: [0, 300]})); + }); + it("works across a wide domain", () => { + assertDistinct(scale({type: "log", domain: [0.000001, 10000]})); + }); + }); + + describe("pow scales", () => { + it("guarantees distinct values for neighboring pixels", () => { + assertDistinct(scale({type: "pow", exponent: 2, domain: [0, 100], range: [0, 500]})); + }); + it("handles steep exponent", () => { + assertDistinct(scale({type: "pow", exponent: 4, domain: [0, 10]})); + }); + }); + + describe("sqrt scales", () => { + it("guarantees distinct values for neighboring pixels", () => { + assertDistinct(scale({type: "sqrt", domain: [0, 10000], range: [0, 400]})); + }); + }); + + describe("symlog scales", () => { + it("guarantees distinct values for neighboring pixels", () => { + assertDistinct(scale({type: "symlog", domain: [-100000, 100000], range: [0, 580]})); + }); + it("handles narrow range near zero", () => { + assertDistinct(scale({type: "symlog", domain: [-10, 10], range: [0, 200]})); + }); + }); +});