Skip to content

Commit bb29be9

Browse files
committed
pixelRound
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
1 parent 530d063 commit bb29be9

File tree

3 files changed

+211
-0
lines changed

3 files changed

+211
-0
lines changed

src/precision.d.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type {ScaleType} from "./scales.js";
2+
3+
/** Internal d3 scale with type, as produced by createScaleFunctions. */
4+
export interface MaterializedScale {
5+
(value: any): number;
6+
type: ScaleType;
7+
domain(): any[];
8+
range(): number[];
9+
invert(value: number): any;
10+
}
11+
12+
/**
13+
* Returns a function that rounds values in data space to the coarsest
14+
* precision that distinguishes neighboring pixels. For temporal scales, finds
15+
* the coarsest calendar interval that spans at most 1px; for linear scales,
16+
* uses a uniform step; for non-linear scales (where the data density varies),
17+
* computes the step locally.
18+
*/
19+
export function pixelRound(scale: MaterializedScale): (value: any) => any;

src/precision.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import {tickStep, timeTickInterval, utcTickInterval} from "d3";
2+
import {numberInterval} from "./options.js";
3+
4+
export function pixelRound(scale) {
5+
if (scale.type === "identity") return Math.round;
6+
if (!scale.invert) throw new Error(`Unsupported scale ${scale.type}`);
7+
const [d0, d1] = scale.domain();
8+
const r = scale.range();
9+
const span = Math.abs(r[1] - r[0]);
10+
return !span
11+
? (v) => v
12+
: scale.type === "linear"
13+
? niceRound(tickStep(0, Math.abs(d1 - d0) / span, 2))
14+
: scale.type === "utc" || scale.type === "time"
15+
? temporalPrecision(scale, d0, d1, span)
16+
: (v) => niceRound(tickStep(0, Math.abs(scale.invert(scale(v) + 0.5) - v), 2))(v);
17+
}
18+
19+
// Find the coarsest calendar interval whose offset spans at most 1px;
20+
// fall back to identity for sub-millisecond domains. The multipliers
21+
// 1, 1.5, 2, 2.5 cover the possible ratios between adjacent intervals.
22+
function temporalPrecision(scale, d0, d1, span) {
23+
const tickInterval = scale.type === "utc" ? utcTickInterval : timeTickInterval;
24+
const p0 = scale(d0);
25+
for (let k = 1; k <= 2.5; k += 0.5) {
26+
const interval = tickInterval(d0, d1, k * span);
27+
if (!interval) break;
28+
if (Math.abs(scale(interval.offset(d0)) - p0) <= 1) return interval.round;
29+
}
30+
return (v) => v;
31+
}
32+
33+
function niceRound(step) {
34+
const {floor} = numberInterval(step);
35+
return (v) => floor(+v + step / 2);
36+
}

test/precision-test.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import assert from "assert";
2+
import {scale as createScale} from "../src/index.js";
3+
import {pixelRound} from "../src/precision.js";
4+
import type {MaterializedScale} from "../src/precision.js";
5+
6+
// pixelRound expects a d3-like scale; Plot.scale() returns plain arrays
7+
// and a separate apply function.
8+
function scale(options: any): MaterializedScale {
9+
const {type, domain, range, apply, invert} = createScale({x: {range: [0, 600], ...options}}) as any;
10+
return Object.assign(apply, {type, domain: () => domain, range: () => range, invert});
11+
}
12+
13+
function assertDistinct(s: MaterializedScale, label = "") {
14+
const round = pixelRound(s);
15+
const [r0, r1] = s.range();
16+
const lo = Math.min(r0, r1);
17+
const hi = Math.max(r0, r1);
18+
let prev = +round(s.invert(lo));
19+
for (let p = lo + 1; p < hi; ++p) {
20+
const v = +round(s.invert(p));
21+
assert.notStrictEqual(prev, v, `${label}pixels ${p - 1} and ${p} should map to distinct values`);
22+
prev = v;
23+
}
24+
}
25+
26+
describe("pixelRound", () => {
27+
it("rounds to integer for identity scales", () => {
28+
const round = pixelRound({type: "identity"} as any);
29+
assert.strictEqual(round(42.7), 43);
30+
assert.strictEqual(round(42.3), 42);
31+
});
32+
33+
it("returns identity for a zero-pixel range", () => {
34+
const round = pixelRound(scale({type: "linear", domain: [0, 100], range: [0, 0]}));
35+
assert.strictEqual(round(42), 42);
36+
});
37+
38+
it("always returns a round function", () => {
39+
for (const s of [
40+
scale({type: "linear", domain: [0, 100]}),
41+
scale({type: "utc", domain: [new Date("2020-01-01"), new Date("2025-01-01")]}),
42+
scale({type: "log", domain: [1, 1000], range: [0, 300]}),
43+
scale({type: "symlog", domain: [0, 1000], range: [0, 500]})
44+
]) {
45+
const floor = pixelRound(s);
46+
assert.strictEqual(typeof floor, "function", `expected function for ${s.type}`);
47+
}
48+
});
49+
50+
describe("linear scales", () => {
51+
it("rounds to a nice step", () => {
52+
const round = pixelRound(scale({type: "linear", domain: [0, 100], range: [0, 500]}));
53+
assert.strictEqual(round(38.87), 38.9);
54+
});
55+
it("produces clean floating point values", () => {
56+
const round = pixelRound(scale({type: "linear", domain: [0, 100], range: [0, 500]}));
57+
assert.strictEqual(round(38.8), 38.8);
58+
assert.strictEqual(round(0.3), 0.3);
59+
});
60+
it("handles reversed domains", () => {
61+
const floor = pixelRound(scale({type: "linear", domain: [100, 0], range: [0, 500]}));
62+
assert.strictEqual(typeof floor, "function");
63+
});
64+
it("guarantees distinct values for neighboring pixels", () => {
65+
assertDistinct(scale({type: "linear", domain: [0, 100], range: [0, 500]}));
66+
});
67+
});
68+
69+
describe("temporal scales", () => {
70+
it("5 years / 600px rounds to midnight", () => {
71+
const round = pixelRound(scale({type: "utc", domain: [new Date("2020-01-01"), new Date("2025-01-01")]}));
72+
const d = round(new Date("2023-06-15T14:30:00Z"));
73+
assert.strictEqual(d.getUTCHours(), 0);
74+
assert.strictEqual(d.getUTCMinutes(), 0);
75+
});
76+
it("1 month / 600px rounds to whole minutes", () => {
77+
const round = pixelRound(scale({type: "utc", domain: [new Date("2020-01-01"), new Date("2020-02-01")]}));
78+
const d = round(new Date("2020-01-15T14:30:00Z"));
79+
assert.strictEqual(d.getUTCSeconds(), 0);
80+
});
81+
it("1 hour / 600px rounds to whole seconds", () => {
82+
const round = pixelRound(scale({type: "utc", domain: [new Date("2020-01-01T00:00Z"), new Date("2020-01-01T01:00Z")]}));
83+
const d = round(new Date("2020-01-01T00:30:15.789Z"));
84+
assert.strictEqual(d.getUTCMilliseconds(), 0);
85+
});
86+
it("precision gets finer as the domain shrinks", () => {
87+
const wide = pixelRound(scale({type: "utc", domain: [new Date("2000-01-01"), new Date("2025-01-01")]}));
88+
const narrow = pixelRound(scale({type: "utc", domain: [new Date("2020-01-01"), new Date("2020-02-01")]}));
89+
const d = new Date("2020-01-15T14:30:45Z");
90+
assert.ok(Math.abs(+d - +wide(d)) >= Math.abs(+d - +narrow(d)));
91+
});
92+
it("guarantees distinct values for neighboring pixels", () => {
93+
const cases: [Date, Date, number][] = [
94+
[new Date("2020-01-01"), new Date("2025-01-01"), 600], // 5 years / 600px
95+
[new Date("2020-01-01"), new Date("2020-02-01"), 600], // 1 month / 600px
96+
[new Date("2020-01-01T00:00Z"), new Date("2020-01-01T01:00Z"), 600], // 1 hour / 600px
97+
[new Date("2020-02-01"), new Date("2020-03-01"), 29], // leap February / 29px
98+
[new Date("2021-02-01"), new Date("2021-03-01"), 29], // non-leap February / 29px
99+
[new Date("2025-01-01"), new Date("2020-01-01"), 600], // inverted domain
100+
[new Date("2020-01-01"), new Date("2025-01-01"), -600], // inverted range
101+
[new Date("2025-01-01"), new Date("2020-01-01"), -600] // inverted domain and range
102+
];
103+
for (const [d0, d1, r1] of cases) {
104+
assertDistinct(scale({type: "utc", domain: [d0, d1], range: [0, r1]}), `utc ${d0}${d1}@${r1}px: `);
105+
}
106+
});
107+
it("guarantees distinct values for neighboring pixels (local time)", () => {
108+
// US DST spring-forward: March 8, 2020 is a 23h day in America/Los_Angeles
109+
const d0 = new Date("2020-03-08T00:00:00-08:00"); // midnight PST
110+
const d1 = new Date("2020-03-09T00:00:00-07:00"); // midnight PDT
111+
assertDistinct(scale({type: "time", domain: [d0, d1], range: [0, 720]}), "DST spring-forward@720px: ");
112+
});
113+
});
114+
115+
describe("log scales", () => {
116+
it("precision gets coarser toward the sparse end", () => {
117+
const s = scale({type: "log", domain: [1, 1000], range: [0, 300]});
118+
const floor = pixelRound(s);
119+
const v0 = floor(1.5);
120+
const v299 = floor(950.5);
121+
assert.ok(v0 === 1.5 || Math.abs(v0 - 1.5) < 0.1, `near start: ${v0}`);
122+
assert.ok(Math.abs(v299 - 950.5) >= 0.1, `near end should be coarser: ${v299}`);
123+
});
124+
it("guarantees distinct values for neighboring pixels", () => {
125+
assertDistinct(scale({type: "log", domain: [1, 1000], range: [0, 300]}));
126+
});
127+
it("works across a wide domain", () => {
128+
assertDistinct(scale({type: "log", domain: [0.000001, 10000]}));
129+
});
130+
});
131+
132+
describe("pow scales", () => {
133+
it("guarantees distinct values for neighboring pixels", () => {
134+
assertDistinct(scale({type: "pow", exponent: 2, domain: [0, 100], range: [0, 500]}));
135+
});
136+
it("handles steep exponent", () => {
137+
assertDistinct(scale({type: "pow", exponent: 4, domain: [0, 10]}));
138+
});
139+
});
140+
141+
describe("sqrt scales", () => {
142+
it("guarantees distinct values for neighboring pixels", () => {
143+
assertDistinct(scale({type: "sqrt", domain: [0, 10000], range: [0, 400]}));
144+
});
145+
});
146+
147+
describe("symlog scales", () => {
148+
it("guarantees distinct values for neighboring pixels", () => {
149+
assertDistinct(scale({type: "symlog", domain: [-100000, 100000], range: [0, 580]}));
150+
});
151+
it("handles narrow range near zero", () => {
152+
assertDistinct(scale({type: "symlog", domain: [-10, 10], range: [0, 200]}));
153+
});
154+
});
155+
156+
});

0 commit comments

Comments
 (0)