Skip to content

packages/core/src/features/viz/lib/utc-scale.ts

Read as Markdown

This is the source snapshot used to build these API details. View this revision on GitHub.

Back to reference

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 built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.