-
Notifications
You must be signed in to change notification settings - Fork 209
pixelRound #2371
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Fil
wants to merge
1
commit into
main
Choose a base branch
from
fil/pixel-round
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
pixelRound #2371
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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]})); | ||
| }); | ||
| }); | ||
| }); |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
InternalScaleorInternalScaleFunctionis probably a better name here, since the existingScaleinterface is already a “materialized” scale’s public representation, and what is unique about this interface is that it is the private internal representation (that we don’t want to expose publicly and that ideally we’d get rid of).