packages/core/src/features/viz/lib/utc-scale.ts
This is the source snapshot used to build these API details. View this revision on GitHub.
1 import type { UTCScale } from "./utc-scale-component.js";
2 import type { LinearDomain, ResolvedUtcScale, ScaleRange } from "./types.js";
3 import { resolveLinearScale } from "./linear-scale.js";
4 const LIMIT = 8_640_000_000_000_000;
5 const DAY = 86_400_000;
6
7 function timestamp(value: number, integer = false): number {
8 if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > LIMIT || (integer && !Number.isSafeInteger(value)))
9 throw new RangeError("UTC timestamps must be finite milliseconds within Date range; domain endpoints must be safe integers.");
10 return value;
11 }
12 function validateDomain(domain: LinearDomain): void {
13 if (!Array.isArray(domain) || domain.length !== 2)
14 throw new RangeError("UTC domain requires two endpoints.");
15 timestamp(domain[0], true);
16 timestamp(domain[1], true);
17 }
18 interface Cadence {
19 first: number;
20 last: number;
21 at(index: number): number;
22 }
23 function monthAt(ordinal: number): number {
24 const date = new Date(0);
25 date.setUTCFullYear(Math.floor(ordinal / 12), ((ordinal % 12) + 12) % 12, 1);
26 return date.getTime();
27 }
28 const fixedSteps = [1, 2, 5, 10, 20, 50, 100, 200, 500,
29 ...[1, 2, 5, 10, 15, 30].map(n => n * 1000),
30 ...[1, 2, 5, 10, 15, 30].map(n => n * 60_000),
31 ...[1, 2, 3, 6, 12].map(n => n * 3_600_000), DAY, 2 * DAY];
32 function selectCadence(lo: number, hi: number, count: number): Cadence {
33 for (const step of [...fixedSteps, 7 * DAY]) {
34 const offset = step === 7 * DAY ? -3 * DAY : 0;
35 const first = Math.floor((lo - offset) / step) + 1;
36 const last = Math.ceil((hi - offset) / step) - 1;
37 if (Math.max(0, last - first + 1) <= count - 2)
38 return { first, last, at: index => index * step + offset };
39 }
40 // Two Date objects for endpoint ordinals; candidate counting is arithmetic.
41 const start = new Date(lo), stop = new Date(hi);
42 const lowMonth = start.getUTCFullYear() * 12 + start.getUTCMonth();
43 const highMonth = stop.getUTCFullYear() * 12 + stop.getUTCMonth();
44 const highBoundary = stop.getUTCDate() === 1 && stop.getUTCHours() === 0 &&
45 stop.getUTCMinutes() === 0 && stop.getUTCSeconds() === 0 && stop.getUTCMilliseconds() === 0;
46 const steps = [1, 2, 3, 6,
47 ...Array.from({ length: 7 }, (_, exponent) => [1, 2, 5].map(n => 12 * n * 10 ** exponent)).flat()];
48 for (const step of steps) {
49 const first = Math.floor(lowMonth / step) + 1;
50 const last = Math.floor(highMonth / step) - (highBoundary && highMonth % step === 0 ? 1 : 0);
51 if (Math.max(0, last - first + 1) <= count - 2)
52 return { first, last, at: index => monthAt(index * step) };
53 }
54 // Every epoch-aligned cadence crosses year zero for a straddling domain.
55 // Two-tick output is endpoints-only; keep already integral millisecond bounds
56 // rather than overflow Date trying ever coarser calendar boundaries.
57 if (count === 2) return { first: lo + 1, last: hi - 1, at: index => index };
58 throw new RangeError("UTC could not select a bounded cadence.");
59 }
60 function tickCount(count: number): void {
61 if (!Number.isInteger(count) || count < 2 || count > 100)
62 throw new RangeError("UTC ticks count must be an integer from 2 to 100.");
63 }
64 function utcTicks(domain: LinearDomain, count: number): readonly number[] {
65 tickCount(count);
66 if (count === 2) return Object.freeze([...domain]);
67 const lo = Math.min(...domain), hi = Math.max(...domain);
68 const cadence = selectCadence(lo, hi, count);
69 const ticks = [lo];
70 for (let i = cadence.first; i <= cadence.last; i++) {
71 const value = cadence.at(i);
72 if (value > lo && value < hi) ticks.push(timestamp(value, true));
73 }
74 ticks.push(hi);
75 return Object.freeze(domain[0] > domain[1] ? ticks.reverse() : ticks);
76 }
77 export function resolveUtcScale(domain: LinearDomain, range: ScaleRange, reverse: boolean, width: number, height: number): ResolvedUtcScale {
78 validateDomain(domain);
79 const linear = resolveLinearScale(domain, range, reverse, width, height);
80 return Object.freeze({
81 ...linear,
82 type: "utc",
83 map: (value: number) => linear.map(timestamp(value)),
84 ticks: (count = 5) => utcTicks(linear.domain, count),
85 formatTick: (value: number) => new Date(timestamp(value)).toISOString(),
86 });
87 }
88
89 /**
90 * Expands a numeric timestamp domain to enclosing UTC calendar boundaries.
91 * @param domain - Safe integer epoch-millisecond endpoints, possibly equal or descending.
92 * @param options - Optional bounded target tick count (default 5).
93 * @returns A frozen outward-rounded domain preserving its direction. See {@link LinearDomain}.
94 * @see {@link UTCScale}
95 */
96 export function niceUTCDomain(domain: LinearDomain, options: { readonly count?: number } = {}): LinearDomain {
97 validateDomain(domain);
98 const count = options.count ?? 5;
99 tickCount(count);
100 if (domain[0] === domain[1]) {
101 const lo = Math.max(-LIMIT, Math.min(LIMIT - 2, domain[0] - 1));
102 return Object.freeze([lo, lo + 2]);
103 }
104 const lo = Math.min(...domain), hi = Math.max(...domain);
105 const cadence = selectCadence(lo, hi, count);
106 const start = timestamp(cadence.at(cadence.first - 1), true);
107 const stop = timestamp(cadence.at(cadence.last + 1), true);
108 return Object.freeze(domain[0] > domain[1] ? [stop, start] : [start, stop]);
109 }
110
111 /**
112 * Computes a finite numeric extent in source order, skipping only null and undefined.
113 * @param data - Source rows, which are never sorted or mutated.
114 * @param value - Accessor called once per row with its index and original array.
115 * @returns A frozen minimum/maximum pair, or undefined when every row is missing.
116 * @see {@link niceUTCDomain}
117 */
118 export function extent<D>(data: readonly D[], value: (datum: D, index: number, data: readonly D[]) => number | null | undefined): LinearDomain | undefined {
119 if (!Array.isArray(data) || typeof value !== "function") throw new TypeError("extent requires an array and accessor.");
120 let min = Infinity, max = -Infinity;
121 for (let i = 0; i < data.length; i++) {
122 const v = value(data[i], i, data);
123 if (v === null || v === undefined) continue;
124 if (typeof v !== "number" || !Number.isFinite(v)) throw new TypeError("extent values must be finite numbers or missing.");
125 min = Math.min(min, v); max = Math.max(max, v);
126 }
127 return min === Infinity ? undefined : Object.freeze([min, max]);
128 }
129
Documentation version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.