packages/core/src/features/viz/lib/numeric-ticks.ts
This is the source snapshot used to build these API details. View this revision on GitHub.
1 import type { NumericDomain } from "./types.js";
2
3 /** Readable linear tick density. See {@link linearTicks}. */
4 export interface LinearTickOptions {
5 /** Target number of intervals, from 2 to 100; defaults to 5. */
6 readonly count?: number;
7 }
8 function parameters(domain: NumericDomain, options: LinearTickOptions) {
9 const count = options.count ?? 5;
10 if (!Number.isInteger(count) || count < 2 || count > 100)
11 throw new RangeError("Linear tick count must be an integer from 2 to 100.");
12 if (!Array.isArray(domain) || domain.length !== 2 || !domain.every(Number.isFinite))
13 throw new RangeError("Linear tick domain requires two finite endpoints.");
14 const lo = Math.min(...domain), hi = Math.max(...domain);
15 const span = hi - lo;
16 if (!Number.isFinite(span) || span === 0)
17 throw new RangeError("Linear tick domain must have a finite nonzero span.");
18 const raw = span / count;
19 const power = 10 ** Math.floor(Math.log10(raw));
20 const error = raw / power;
21 let step = power * (error >= Math.sqrt(50) ? 10 : error >= Math.sqrt(10) ? 5 : error >= Math.sqrt(2) ? 2 : 1);
22 if (!Number.isFinite(step) || step === 0 || !Number.isSafeInteger(Math.floor(lo / step)) || !Number.isSafeInteger(Math.ceil(hi / step)))
23 throw new RangeError("Linear tick spacing exceeds numeric precision.");
24 if (Math.floor(hi / step) - Math.ceil(lo / step) + 1 > 100) {
25 const magnitude = 10 ** Math.floor(Math.log10(step));
26 const factor = step / magnitude;
27 step = magnitude * (factor < 1.5 ? 2 : factor < 3 ? 5 : 10);
28 }
29 return { lo, hi, step };
30 }
31 function clean(value: number) { return Number(value.toPrecision(15)) || 0; }
32 /**
33 * Generates bounded ticks at readable 1, 2, or 5 × powers-of-ten intervals.
34 * Endpoints are included only when aligned. Does not change scale geometry.
35 * @param domain - Distinct finite endpoints, ascending or descending.
36 * @param options - Target density; defaults to five intervals.
37 * @returns Frozen ticks in domain order. See {@link niceLinearDomain}.
38 */
39 export function linearTicks(domain: NumericDomain, options: LinearTickOptions = {}): readonly number[] {
40 const { lo, hi, step } = parameters(domain, options);
41 const start = Math.ceil(lo / step), end = Math.floor(hi / step);
42 const values = Array.from({ length: Math.max(0, end - start + 1) }, (_, i) => clean((start + i) * step)).filter(v => v >= lo && v <= hi);
43 return Object.freeze(domain[0] > domain[1] ? values.reverse() : values);
44 }
45 /**
46 * Expands a linear domain to readable tick boundaries, preserving direction.
47 * @param domain - Distinct finite numeric endpoints; never mutated.
48 * @param options - Target density; defaults to five intervals.
49 * @returns Frozen expanded endpoints. See {@link linearTicks}.
50 */
51 export function niceLinearDomain(domain: NumericDomain, options: LinearTickOptions = {}): NumericDomain {
52 const { lo, hi, step } = parameters(domain, options);
53 const a = clean(Math.floor(lo / step) * step), b = clean(Math.ceil(hi / step) * step);
54 if (!Number.isFinite(a) || !Number.isFinite(b)) throw new RangeError("Nice linear domain exceeds finite numeric bounds.");
55 return Object.freeze(domain[0] > domain[1] ? [b, a] : [a, b]);
56 }
57 /** Number formatting with explicit locale and literal unit affixes. See {@link formatNumber}. */
58 export interface NumberFormatOptions extends Intl.NumberFormatOptions {
59 /** Locale for formatting; defaults to en-US. */
60 readonly locale?: string;
61 /** Literal prefix; defaults to empty. */
62 readonly prefix?: string;
63 /** Literal suffix, including any desired space; defaults to empty. */
64 readonly suffix?: string;
65 }
66 /**
67 * Formats a finite number with Intl precision, currency, percent, or unit options.
68 * Rounded negative zero is displayed as positive zero.
69 * @param value - Finite numeric value.
70 * @param options - Intl options plus locale and literal affixes; default maximum fraction digits is 3.
71 * @returns Formatted text. Invalid Intl options retain native errors. See {@link NumberFormatOptions}.
72 */
73 export function formatNumber(value: number, options: NumberFormatOptions = {}): string {
74 if (!Number.isFinite(value)) throw new RangeError("formatNumber value must be finite.");
75 const { locale = "en-US", prefix = "", suffix = "", ...intl } = options;
76 const formatter = new Intl.NumberFormat(locale, intl);
77 const parts = formatter.formatToParts(value);
78 const zeroDigit = Array.from(formatter.formatToParts(0).find(p => p.type === "integer")!.value).at(-1)!;
79 const digits = parts.filter(p => p.type === "integer" || p.type === "fraction");
80 const zero = digits.length > 0 && digits.every(p => Array.from(p.value).every(digit => digit === zeroDigit));
81 return prefix + formatter.format(zero ? 0 : value) + suffix;
82 }
83
Documentation version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.