pond-ts 0.67.0 → 0.69.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +253 -0
- package/API.md +88 -83
- package/CHANGELOG.md +109 -1
- package/README.md +111 -9
- package/dist/batch/partitioned-time-series.d.ts +54 -20
- package/dist/batch/time-series.d.ts +2 -2
- package/dist/core/calendar.d.ts +2 -5
- package/dist/core/calendar.js +4 -50
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +1 -0
- package/dist/core/time-zone.d.ts +122 -0
- package/dist/core/time-zone.js +413 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/sequence/sequence.d.ts +4 -1
- package/dist/sequence/sequence.js +15 -13
- package/package.json +29 -4
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { CalendarUnit, WeekStartsOn } from './calendar.js';
|
|
2
|
+
/**
|
|
3
|
+
* How `TimeZone.instant()` resolves a wall-clock time that a transition
|
|
4
|
+
* makes ambiguous (a fall-back repeats an hour) or skips (a spring-forward
|
|
5
|
+
* drops one). Same vocabulary and same defaults as Temporal:
|
|
6
|
+
*
|
|
7
|
+
* - `'compatible'` (default) — the earlier instant when the time repeats,
|
|
8
|
+
* the instant *after* the gap when it is skipped (02:30 on a spring-forward
|
|
9
|
+
* night becomes 03:30). This is also what `startOf('day')` needs on the
|
|
10
|
+
* rare days that have no midnight.
|
|
11
|
+
* - `'earlier'` / `'later'` — always the earlier / later of the two readings.
|
|
12
|
+
* - `'reject'` — throw a `RangeError` for either case.
|
|
13
|
+
*/
|
|
14
|
+
export type Disambiguation = 'compatible' | 'earlier' | 'later' | 'reject';
|
|
15
|
+
/** The wall-clock reading of an instant in a zone. `weekday` is ISO: 1 = Monday … 7 = Sunday. */
|
|
16
|
+
export type ZonedParts = {
|
|
17
|
+
year: number;
|
|
18
|
+
month: number;
|
|
19
|
+
day: number;
|
|
20
|
+
hour: number;
|
|
21
|
+
minute: number;
|
|
22
|
+
second: number;
|
|
23
|
+
millisecond: number;
|
|
24
|
+
weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7;
|
|
25
|
+
};
|
|
26
|
+
/** The date-and-time fields `TimeZone.instant()` accepts. Time fields default to zero. */
|
|
27
|
+
export type ZonedPartsInput = {
|
|
28
|
+
year: number;
|
|
29
|
+
month: number;
|
|
30
|
+
day: number;
|
|
31
|
+
hour?: number;
|
|
32
|
+
minute?: number;
|
|
33
|
+
second?: number;
|
|
34
|
+
millisecond?: number;
|
|
35
|
+
};
|
|
36
|
+
export type StartOfOptions = {
|
|
37
|
+
/** First day of the week for `'week'`, ISO numbering (1 = Monday, default) … 7 = Sunday. */
|
|
38
|
+
weekStartsOn?: WeekStartsOn;
|
|
39
|
+
};
|
|
40
|
+
declare function assertCalendarUnit(unit: string): asserts unit is CalendarUnit;
|
|
41
|
+
/**
|
|
42
|
+
* An IANA time zone as a calendar: the one place in pond that turns instants
|
|
43
|
+
* (UTC epoch milliseconds) into wall-clock readings and calendar boundaries,
|
|
44
|
+
* and back.
|
|
45
|
+
*
|
|
46
|
+
* Instances are interned — `TimeZone.of('Europe/Berlin')` returns the same
|
|
47
|
+
* object every time — and cache the zone's offset transitions as they are
|
|
48
|
+
* discovered, so after the first query in a stretch of history every call
|
|
49
|
+
* is integer arithmetic. `Sequence.calendar` buckets with it; the charts'
|
|
50
|
+
* time axis places and labels ticks with it; the two therefore agree on
|
|
51
|
+
* every boundary by construction.
|
|
52
|
+
*
|
|
53
|
+
* A `TimeZone` holds no instant. Values (`Time`, `TimeRange`, `Interval`)
|
|
54
|
+
* stay plain UTC milliseconds; the zone is a parameter to the code that
|
|
55
|
+
* interprets them.
|
|
56
|
+
*/
|
|
57
|
+
export declare class TimeZone {
|
|
58
|
+
#private;
|
|
59
|
+
/** The canonical IANA identifier, e.g. `'America/New_York'` or `'UTC'`. */
|
|
60
|
+
readonly id: string;
|
|
61
|
+
private constructor();
|
|
62
|
+
/**
|
|
63
|
+
* Example: `TimeZone.of('Australia/Sydney')`. Looks up a zone by IANA
|
|
64
|
+
* identifier. Throws `RangeError` for an identifier the runtime does not
|
|
65
|
+
* know. Case-insensitive on input; `id` reports the canonical spelling.
|
|
66
|
+
* A fixed-offset identifier such as `'+05:30'` is accepted too, as
|
|
67
|
+
* Temporal accepts it, and behaves as a zone with a single segment.
|
|
68
|
+
*/
|
|
69
|
+
static of(id: string): TimeZone;
|
|
70
|
+
/** Example: `TimeZone.UTC.startOf('month', t)`. The UTC zone. */
|
|
71
|
+
static get UTC(): TimeZone;
|
|
72
|
+
/**
|
|
73
|
+
* Example: `TimeZone.local()`. The runtime's own zone, as the environment
|
|
74
|
+
* reports it. Resolved on every call, so a test that changes `TZ` between
|
|
75
|
+
* calls sees the change.
|
|
76
|
+
*/
|
|
77
|
+
static local(): TimeZone;
|
|
78
|
+
/** Example: `zone.offsetAt(t)`. The zone's UTC offset at an instant, in milliseconds east of UTC. */
|
|
79
|
+
offsetAt(ms: number): number;
|
|
80
|
+
/**
|
|
81
|
+
* Example: `zone.parts(t).hour`. The wall-clock reading of an instant in
|
|
82
|
+
* this zone.
|
|
83
|
+
*/
|
|
84
|
+
parts(ms: number): ZonedParts;
|
|
85
|
+
/**
|
|
86
|
+
* Example: `zone.instant({ year: 2026, month: 3, day: 29, hour: 2, minute: 30 })`.
|
|
87
|
+
* The instant at which this zone's clocks read the given wall time. When
|
|
88
|
+
* a transition makes the reading ambiguous or nonexistent,
|
|
89
|
+
* `disambiguation` decides (default `'compatible'`, as Temporal).
|
|
90
|
+
*/
|
|
91
|
+
instant(parts: ZonedPartsInput, options?: {
|
|
92
|
+
disambiguation?: Disambiguation;
|
|
93
|
+
}): number;
|
|
94
|
+
/**
|
|
95
|
+
* Example: `zone.startOf('week', t, { weekStartsOn: 7 })`. The first
|
|
96
|
+
* instant of the calendar unit containing `ms`, in this zone. On a day
|
|
97
|
+
* with no midnight (a spring-forward at 00:00) this is the first instant
|
|
98
|
+
* that exists, as `Temporal.ZonedDateTime.startOfDay` does.
|
|
99
|
+
*/
|
|
100
|
+
startOf(unit: CalendarUnit, ms: number, options?: StartOfOptions): number;
|
|
101
|
+
/**
|
|
102
|
+
* Example: `zone.next('month', t)`. The first instant of the calendar unit
|
|
103
|
+
* *after* the one containing `ms` — i.e. the exclusive end of `startOf`'s
|
|
104
|
+
* bucket.
|
|
105
|
+
*/
|
|
106
|
+
next(unit: CalendarUnit, ms: number, options?: StartOfOptions): number;
|
|
107
|
+
/**
|
|
108
|
+
* Example: `zone.abbreviation(t)` → `'EDT'`. The zone's short name at an
|
|
109
|
+
* instant, as `Intl` reports it for `locale` (default: the runtime's
|
|
110
|
+
* locale). Which zones have a conventional abbreviation is a locale
|
|
111
|
+
* question — `en-US` knows `EST`, `en-AU` knows `AEDT`, and neither knows
|
|
112
|
+
* the other's; the fallback is a `GMT±h[:mm]` offset string. Pass the
|
|
113
|
+
* viewer's locale when rendering.
|
|
114
|
+
*/
|
|
115
|
+
abbreviation(ms: number, options?: {
|
|
116
|
+
locale?: string;
|
|
117
|
+
}): string;
|
|
118
|
+
toString(): string;
|
|
119
|
+
toJSON(): string;
|
|
120
|
+
}
|
|
121
|
+
export { assertCalendarUnit };
|
|
122
|
+
//# sourceMappingURL=time-zone.d.ts.map
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
var _a;
|
|
2
|
+
import { Temporal } from '@js-temporal/polyfill';
|
|
3
|
+
const MS_PER_DAY = 86_400_000;
|
|
4
|
+
const MS_PER_HOUR = 3_600_000;
|
|
5
|
+
/**
|
|
6
|
+
* Half-window either side of a civil time within which every candidate
|
|
7
|
+
* offset for that wall time must lie. IANA offsets span −12:00 … +14:00, and
|
|
8
|
+
* the two offsets bracketing a transition differ by at most a few hours, so
|
|
9
|
+
* ±16 h comfortably covers both sides of any transition.
|
|
10
|
+
*/
|
|
11
|
+
const CANDIDATE_WINDOW_MS = 16 * MS_PER_HOUR;
|
|
12
|
+
/**
|
|
13
|
+
* Days since 1970-01-01 for a proleptic-Gregorian civil date (Howard
|
|
14
|
+
* Hinnant's algorithm, valid for the whole `Date` range).
|
|
15
|
+
*/
|
|
16
|
+
function daysFromCivil(year, month, day) {
|
|
17
|
+
const y = month <= 2 ? year - 1 : year;
|
|
18
|
+
const era = Math.floor(y / 400);
|
|
19
|
+
const yoe = y - era * 400;
|
|
20
|
+
const mp = (month + 9) % 12;
|
|
21
|
+
const doy = Math.floor((153 * mp + 2) / 5) + day - 1;
|
|
22
|
+
const doe = yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy;
|
|
23
|
+
return era * 146_097 + doe - 719_468;
|
|
24
|
+
}
|
|
25
|
+
/** Inverse of `daysFromCivil`: `[year, month, day]` for a day number. */
|
|
26
|
+
function civilFromDays(days) {
|
|
27
|
+
const z = days + 719_468;
|
|
28
|
+
const era = Math.floor(z / 146_097);
|
|
29
|
+
const doe = z - era * 146_097;
|
|
30
|
+
const yoe = Math.floor((doe -
|
|
31
|
+
Math.floor(doe / 1460) +
|
|
32
|
+
Math.floor(doe / 36_524) -
|
|
33
|
+
Math.floor(doe / 146_096)) /
|
|
34
|
+
365);
|
|
35
|
+
const y = yoe + era * 400;
|
|
36
|
+
const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100));
|
|
37
|
+
const mp = Math.floor((5 * doy + 2) / 153);
|
|
38
|
+
const d = doy - Math.floor((153 * mp + 2) / 5) + 1;
|
|
39
|
+
const m = mp < 10 ? mp + 3 : mp - 9;
|
|
40
|
+
return [m <= 2 ? y + 1 : y, m, d];
|
|
41
|
+
}
|
|
42
|
+
/** Days in a proleptic-Gregorian month. */
|
|
43
|
+
function daysInMonth(year, month) {
|
|
44
|
+
return (daysFromCivil(month === 12 ? year + 1 : year, (month % 12) + 1, 1) -
|
|
45
|
+
daysFromCivil(year, month, 1));
|
|
46
|
+
}
|
|
47
|
+
/** ISO weekday (1 = Monday … 7 = Sunday) for a day number; day 0 was a Thursday. */
|
|
48
|
+
function weekdayFromDays(days) {
|
|
49
|
+
return ((((days % 7) + 7 + 3) % 7) + 1);
|
|
50
|
+
}
|
|
51
|
+
function floorMs(ms, label) {
|
|
52
|
+
if (!Number.isFinite(ms)) {
|
|
53
|
+
throw new RangeError(`${label} must be a finite epoch millisecond`);
|
|
54
|
+
}
|
|
55
|
+
// Floor, not trunc: before 1970 they disagree, and -5.5 lies inside the
|
|
56
|
+
// millisecond spanning [-6, -5). See `toPlainDateStart` for why a fraction
|
|
57
|
+
// is not a caller error (a chart's wheel zoom hands us `1.7e12 + 0.37`).
|
|
58
|
+
return Math.floor(ms);
|
|
59
|
+
}
|
|
60
|
+
function assertCalendarUnit(unit) {
|
|
61
|
+
switch (unit) {
|
|
62
|
+
case 'day':
|
|
63
|
+
case 'week':
|
|
64
|
+
case 'month':
|
|
65
|
+
case 'quarter':
|
|
66
|
+
case 'year':
|
|
67
|
+
return;
|
|
68
|
+
default:
|
|
69
|
+
throw new RangeError(`unknown calendar unit ${JSON.stringify(unit)}; expected 'day', 'week', 'month', 'quarter' or 'year'`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const registry = new Map();
|
|
73
|
+
/**
|
|
74
|
+
* An IANA time zone as a calendar: the one place in pond that turns instants
|
|
75
|
+
* (UTC epoch milliseconds) into wall-clock readings and calendar boundaries,
|
|
76
|
+
* and back.
|
|
77
|
+
*
|
|
78
|
+
* Instances are interned — `TimeZone.of('Europe/Berlin')` returns the same
|
|
79
|
+
* object every time — and cache the zone's offset transitions as they are
|
|
80
|
+
* discovered, so after the first query in a stretch of history every call
|
|
81
|
+
* is integer arithmetic. `Sequence.calendar` buckets with it; the charts'
|
|
82
|
+
* time axis places and labels ticks with it; the two therefore agree on
|
|
83
|
+
* every boundary by construction.
|
|
84
|
+
*
|
|
85
|
+
* A `TimeZone` holds no instant. Values (`Time`, `TimeRange`, `Interval`)
|
|
86
|
+
* stay plain UTC milliseconds; the zone is a parameter to the code that
|
|
87
|
+
* interprets them.
|
|
88
|
+
*/
|
|
89
|
+
export class TimeZone {
|
|
90
|
+
/** The canonical IANA identifier, e.g. `'America/New_York'` or `'UTC'`. */
|
|
91
|
+
id;
|
|
92
|
+
/** Offset segments sorted by `start`, non-overlapping. */
|
|
93
|
+
#segments = [];
|
|
94
|
+
/** The segment most recently hit — almost every query lands here. */
|
|
95
|
+
#last;
|
|
96
|
+
#abbreviationFormats;
|
|
97
|
+
constructor(id) {
|
|
98
|
+
this.id = id;
|
|
99
|
+
Object.freeze(this);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Example: `TimeZone.of('Australia/Sydney')`. Looks up a zone by IANA
|
|
103
|
+
* identifier. Throws `RangeError` for an identifier the runtime does not
|
|
104
|
+
* know. Case-insensitive on input; `id` reports the canonical spelling.
|
|
105
|
+
* A fixed-offset identifier such as `'+05:30'` is accepted too, as
|
|
106
|
+
* Temporal accepts it, and behaves as a zone with a single segment.
|
|
107
|
+
*/
|
|
108
|
+
static of(id) {
|
|
109
|
+
const hit = registry.get(id);
|
|
110
|
+
if (hit !== undefined)
|
|
111
|
+
return hit;
|
|
112
|
+
let canonical;
|
|
113
|
+
try {
|
|
114
|
+
canonical =
|
|
115
|
+
Temporal.Instant.fromEpochMilliseconds(0).toZonedDateTimeISO(id).timeZoneId;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
throw new RangeError(`unknown time zone ${JSON.stringify(id)}`);
|
|
119
|
+
}
|
|
120
|
+
const existing = registry.get(canonical);
|
|
121
|
+
const zone = existing ?? new _a(canonical);
|
|
122
|
+
registry.set(id, zone);
|
|
123
|
+
registry.set(canonical, zone);
|
|
124
|
+
return zone;
|
|
125
|
+
}
|
|
126
|
+
/** Example: `TimeZone.UTC.startOf('month', t)`. The UTC zone. */
|
|
127
|
+
static get UTC() {
|
|
128
|
+
return _a.of('UTC');
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Example: `TimeZone.local()`. The runtime's own zone, as the environment
|
|
132
|
+
* reports it. Resolved on every call, so a test that changes `TZ` between
|
|
133
|
+
* calls sees the change.
|
|
134
|
+
*/
|
|
135
|
+
static local() {
|
|
136
|
+
return _a.of(Intl.DateTimeFormat().resolvedOptions().timeZone);
|
|
137
|
+
}
|
|
138
|
+
/** Example: `zone.offsetAt(t)`. The zone's UTC offset at an instant, in milliseconds east of UTC. */
|
|
139
|
+
offsetAt(ms) {
|
|
140
|
+
return this.#segmentAt(floorMs(ms, 'instant')).offsetMs;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Example: `zone.parts(t).hour`. The wall-clock reading of an instant in
|
|
144
|
+
* this zone.
|
|
145
|
+
*/
|
|
146
|
+
parts(ms) {
|
|
147
|
+
const t = floorMs(ms, 'instant');
|
|
148
|
+
const civil = t + this.#segmentAt(t).offsetMs;
|
|
149
|
+
return _a.#civilParts(civil);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Example: `zone.instant({ year: 2026, month: 3, day: 29, hour: 2, minute: 30 })`.
|
|
153
|
+
* The instant at which this zone's clocks read the given wall time. When
|
|
154
|
+
* a transition makes the reading ambiguous or nonexistent,
|
|
155
|
+
* `disambiguation` decides (default `'compatible'`, as Temporal).
|
|
156
|
+
*/
|
|
157
|
+
instant(parts, options = {}) {
|
|
158
|
+
const civil = _a.#civilMs(parts);
|
|
159
|
+
return this.#instantForCivil(civil, options.disambiguation ?? 'compatible');
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Example: `zone.startOf('week', t, { weekStartsOn: 7 })`. The first
|
|
163
|
+
* instant of the calendar unit containing `ms`, in this zone. On a day
|
|
164
|
+
* with no midnight (a spring-forward at 00:00) this is the first instant
|
|
165
|
+
* that exists, as `Temporal.ZonedDateTime.startOfDay` does.
|
|
166
|
+
*/
|
|
167
|
+
startOf(unit, ms, options = {}) {
|
|
168
|
+
assertCalendarUnit(unit);
|
|
169
|
+
const t = floorMs(ms, 'instant');
|
|
170
|
+
const civil = t + this.#segmentAt(t).offsetMs;
|
|
171
|
+
const days = Math.floor(civil / MS_PER_DAY);
|
|
172
|
+
return this.#instantForCivil(_a.#startDays(unit, days, options.weekStartsOn ?? 1) * MS_PER_DAY, 'compatible');
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Example: `zone.next('month', t)`. The first instant of the calendar unit
|
|
176
|
+
* *after* the one containing `ms` — i.e. the exclusive end of `startOf`'s
|
|
177
|
+
* bucket.
|
|
178
|
+
*/
|
|
179
|
+
next(unit, ms, options = {}) {
|
|
180
|
+
assertCalendarUnit(unit);
|
|
181
|
+
const t = floorMs(ms, 'instant');
|
|
182
|
+
const civil = t + this.#segmentAt(t).offsetMs;
|
|
183
|
+
const days = Math.floor(civil / MS_PER_DAY);
|
|
184
|
+
const startDays = _a.#startDays(unit, days, options.weekStartsOn ?? 1);
|
|
185
|
+
return this.#instantForCivil(_a.#advanceDays(unit, startDays) * MS_PER_DAY, 'compatible');
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Example: `zone.abbreviation(t)` → `'EDT'`. The zone's short name at an
|
|
189
|
+
* instant, as `Intl` reports it for `locale` (default: the runtime's
|
|
190
|
+
* locale). Which zones have a conventional abbreviation is a locale
|
|
191
|
+
* question — `en-US` knows `EST`, `en-AU` knows `AEDT`, and neither knows
|
|
192
|
+
* the other's; the fallback is a `GMT±h[:mm]` offset string. Pass the
|
|
193
|
+
* viewer's locale when rendering.
|
|
194
|
+
*/
|
|
195
|
+
abbreviation(ms, options = {}) {
|
|
196
|
+
const t = floorMs(ms, 'instant');
|
|
197
|
+
const segment = this.#segmentAt(t);
|
|
198
|
+
const locale = options.locale ?? '';
|
|
199
|
+
const cached = segment.abbreviations?.get(locale);
|
|
200
|
+
if (cached !== undefined)
|
|
201
|
+
return cached;
|
|
202
|
+
this.#abbreviationFormats ??= new Map();
|
|
203
|
+
let format = this.#abbreviationFormats.get(locale);
|
|
204
|
+
if (format === undefined) {
|
|
205
|
+
format = new Intl.DateTimeFormat(locale === '' ? undefined : locale, {
|
|
206
|
+
timeZone: this.id,
|
|
207
|
+
timeZoneName: 'short',
|
|
208
|
+
});
|
|
209
|
+
this.#abbreviationFormats.set(locale, format);
|
|
210
|
+
}
|
|
211
|
+
const part = format
|
|
212
|
+
.formatToParts(new Date(t))
|
|
213
|
+
.find((p) => p.type === 'timeZoneName');
|
|
214
|
+
const abbreviation = part?.value ?? this.id;
|
|
215
|
+
(segment.abbreviations ??= new Map()).set(locale, abbreviation);
|
|
216
|
+
return abbreviation;
|
|
217
|
+
}
|
|
218
|
+
toString() {
|
|
219
|
+
return this.id;
|
|
220
|
+
}
|
|
221
|
+
toJSON() {
|
|
222
|
+
return this.id;
|
|
223
|
+
}
|
|
224
|
+
// ---- internals ---------------------------------------------------------
|
|
225
|
+
#segmentAt(t) {
|
|
226
|
+
const last = this.#last;
|
|
227
|
+
if (last !== undefined && t >= last.start && t < last.end)
|
|
228
|
+
return last;
|
|
229
|
+
const segments = this.#segments;
|
|
230
|
+
// Binary search for the segment whose start is the greatest <= t.
|
|
231
|
+
let lo = 0;
|
|
232
|
+
let hi = segments.length - 1;
|
|
233
|
+
while (lo <= hi) {
|
|
234
|
+
const mid = (lo + hi) >> 1;
|
|
235
|
+
const s = segments[mid];
|
|
236
|
+
if (t < s.start)
|
|
237
|
+
hi = mid - 1;
|
|
238
|
+
else if (t >= s.end)
|
|
239
|
+
lo = mid + 1;
|
|
240
|
+
else {
|
|
241
|
+
this.#last = s;
|
|
242
|
+
return s;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const created = this.#discoverSegment(t);
|
|
246
|
+
segments.splice(lo, 0, created);
|
|
247
|
+
this.#last = created;
|
|
248
|
+
return created;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Ask Temporal for the offset at `t` and the transitions bracketing it.
|
|
252
|
+
*
|
|
253
|
+
* `getTimeZoneTransition('previous')` is *strictly* before its receiver,
|
|
254
|
+
* while the offset at a transition instant is already the new one — so
|
|
255
|
+
* asking at `t` itself, when `t` *is* a transition, would pair the new
|
|
256
|
+
* offset with the start of the old segment and poison the cache for the
|
|
257
|
+
* whole preceding stretch (every hourly series crosses a DST instant
|
|
258
|
+
* exactly). Asking from `t + 1 ms` returns transitions `<= t`, which is
|
|
259
|
+
* the segment start we want; `'next'` from `t` is strictly after, which
|
|
260
|
+
* is the exclusive end we want.
|
|
261
|
+
*/
|
|
262
|
+
#discoverSegment(t) {
|
|
263
|
+
const zoned = Temporal.Instant.fromEpochMilliseconds(t).toZonedDateTimeISO(this.id);
|
|
264
|
+
const previous = Temporal.Instant.fromEpochMilliseconds(t + 1)
|
|
265
|
+
.toZonedDateTimeISO(this.id)
|
|
266
|
+
.getTimeZoneTransition('previous');
|
|
267
|
+
const next = zoned.getTimeZoneTransition('next');
|
|
268
|
+
return {
|
|
269
|
+
start: previous === null ? -Infinity : previous.epochMilliseconds,
|
|
270
|
+
end: next === null ? Infinity : next.epochMilliseconds,
|
|
271
|
+
offsetMs: zoned.offsetNanoseconds / 1_000_000,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* The instant whose wall-clock reading is the civil millisecond `civil`
|
|
276
|
+
* (a UTC-encoded wall time). Candidate offsets are the ones in force just
|
|
277
|
+
* before and just after the wall time; a candidate is valid when the
|
|
278
|
+
* instant it implies actually carries that offset.
|
|
279
|
+
*/
|
|
280
|
+
#instantForCivil(civil, disambiguation) {
|
|
281
|
+
const before = this.#segmentAt(civil - CANDIDATE_WINDOW_MS);
|
|
282
|
+
const after = this.#segmentAt(civil + CANDIDATE_WINDOW_MS);
|
|
283
|
+
const tBefore = civil - before.offsetMs;
|
|
284
|
+
const validBefore = this.#segmentAt(tBefore).offsetMs === before.offsetMs;
|
|
285
|
+
if (before === after || before.offsetMs === after.offsetMs) {
|
|
286
|
+
if (validBefore)
|
|
287
|
+
return tBefore;
|
|
288
|
+
// Same offset either side but a transition pair sits in between
|
|
289
|
+
// (a brief change and back) — fall through to the general search.
|
|
290
|
+
}
|
|
291
|
+
const tAfter = civil - after.offsetMs;
|
|
292
|
+
const validAfter = this.#segmentAt(tAfter).offsetMs === after.offsetMs;
|
|
293
|
+
if (validBefore && validAfter) {
|
|
294
|
+
if (tBefore === tAfter)
|
|
295
|
+
return tBefore;
|
|
296
|
+
// The wall time repeats: `tBefore` is the earlier reading.
|
|
297
|
+
switch (disambiguation) {
|
|
298
|
+
case 'reject':
|
|
299
|
+
throw new RangeError(`wall-clock time ${_a.#civilString(civil)} is ambiguous in ${this.id}`);
|
|
300
|
+
case 'later':
|
|
301
|
+
return Math.max(tBefore, tAfter);
|
|
302
|
+
default:
|
|
303
|
+
return Math.min(tBefore, tAfter);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (validBefore)
|
|
307
|
+
return tBefore;
|
|
308
|
+
if (validAfter)
|
|
309
|
+
return tAfter;
|
|
310
|
+
// The wall time is skipped. Temporal's 'compatible' moves forward past
|
|
311
|
+
// the gap: keep the offset that was in force before it, which lands
|
|
312
|
+
// the same distance after the transition as the wall time was after
|
|
313
|
+
// the old clock's last reading.
|
|
314
|
+
switch (disambiguation) {
|
|
315
|
+
case 'reject':
|
|
316
|
+
throw new RangeError(`wall-clock time ${_a.#civilString(civil)} does not exist in ${this.id}`);
|
|
317
|
+
case 'earlier':
|
|
318
|
+
return civil - after.offsetMs;
|
|
319
|
+
default:
|
|
320
|
+
return civil - before.offsetMs;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
static #civilMs(parts) {
|
|
324
|
+
const { year, month, day } = parts;
|
|
325
|
+
const hour = parts.hour ?? 0;
|
|
326
|
+
const minute = parts.minute ?? 0;
|
|
327
|
+
const second = parts.second ?? 0;
|
|
328
|
+
const millisecond = parts.millisecond ?? 0;
|
|
329
|
+
const inRange = (v, lo, hi) => Number.isInteger(v) && v >= lo && v <= hi;
|
|
330
|
+
if (!Number.isInteger(year) ||
|
|
331
|
+
!inRange(month, 1, 12) ||
|
|
332
|
+
!inRange(day, 1, daysInMonth(year, month)) ||
|
|
333
|
+
!inRange(hour, 0, 23) ||
|
|
334
|
+
!inRange(minute, 0, 59) ||
|
|
335
|
+
!inRange(second, 0, 59) ||
|
|
336
|
+
!inRange(millisecond, 0, 999)) {
|
|
337
|
+
// Reject rather than roll over: `{ month: 2, day: 30 }` is a caller
|
|
338
|
+
// error, not February 30th, and silently landing on March 2nd is how
|
|
339
|
+
// a bucket edge ends up in the wrong month.
|
|
340
|
+
throw new RangeError(`invalid wall-clock time ${JSON.stringify(parts)}`);
|
|
341
|
+
}
|
|
342
|
+
return (daysFromCivil(year, month, day) * MS_PER_DAY +
|
|
343
|
+
hour * MS_PER_HOUR +
|
|
344
|
+
minute * 60_000 +
|
|
345
|
+
second * 1_000 +
|
|
346
|
+
millisecond);
|
|
347
|
+
}
|
|
348
|
+
static #civilParts(civil) {
|
|
349
|
+
const days = Math.floor(civil / MS_PER_DAY);
|
|
350
|
+
let rem = civil - days * MS_PER_DAY;
|
|
351
|
+
const [year, month, day] = civilFromDays(days);
|
|
352
|
+
const hour = Math.floor(rem / MS_PER_HOUR);
|
|
353
|
+
rem -= hour * MS_PER_HOUR;
|
|
354
|
+
const minute = Math.floor(rem / 60_000);
|
|
355
|
+
rem -= minute * 60_000;
|
|
356
|
+
const second = Math.floor(rem / 1_000);
|
|
357
|
+
const millisecond = rem - second * 1_000;
|
|
358
|
+
return {
|
|
359
|
+
year,
|
|
360
|
+
month,
|
|
361
|
+
day,
|
|
362
|
+
hour,
|
|
363
|
+
minute,
|
|
364
|
+
second,
|
|
365
|
+
millisecond,
|
|
366
|
+
weekday: weekdayFromDays(days),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
static #civilString(civil) {
|
|
370
|
+
return new Date(civil).toISOString().replace('Z', '');
|
|
371
|
+
}
|
|
372
|
+
/** Day number of the first day of the `unit` containing day number `days`. */
|
|
373
|
+
static #startDays(unit, days, weekStartsOn) {
|
|
374
|
+
switch (unit) {
|
|
375
|
+
case 'day':
|
|
376
|
+
return days;
|
|
377
|
+
case 'week':
|
|
378
|
+
return days - ((weekdayFromDays(days) - weekStartsOn + 7) % 7);
|
|
379
|
+
case 'month': {
|
|
380
|
+
const [y, m] = civilFromDays(days);
|
|
381
|
+
return daysFromCivil(y, m, 1);
|
|
382
|
+
}
|
|
383
|
+
case 'quarter': {
|
|
384
|
+
const [y, m] = civilFromDays(days);
|
|
385
|
+
return daysFromCivil(y, m - ((m - 1) % 3), 1);
|
|
386
|
+
}
|
|
387
|
+
case 'year': {
|
|
388
|
+
const [y] = civilFromDays(days);
|
|
389
|
+
return daysFromCivil(y, 1, 1);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
/** Day number of the start of the following `unit`, given a unit-start day number. */
|
|
394
|
+
static #advanceDays(unit, startDays) {
|
|
395
|
+
switch (unit) {
|
|
396
|
+
case 'day':
|
|
397
|
+
return startDays + 1;
|
|
398
|
+
case 'week':
|
|
399
|
+
return startDays + 7;
|
|
400
|
+
case 'month':
|
|
401
|
+
case 'quarter':
|
|
402
|
+
case 'year': {
|
|
403
|
+
const [y, m] = civilFromDays(startDays);
|
|
404
|
+
const months = unit === 'month' ? 1 : unit === 'quarter' ? 3 : 12;
|
|
405
|
+
const total = y * 12 + (m - 1) + months;
|
|
406
|
+
return daysFromCivil(Math.floor(total / 12), (total % 12) + 1, 1);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
_a = TimeZone;
|
|
412
|
+
export { assertCalendarUnit };
|
|
413
|
+
//# sourceMappingURL=time-zone.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -35,6 +35,8 @@ export { type IntervalLabelKind, type KeyColumn, IntervalKeyColumn, TimeKeyColum
|
|
|
35
35
|
export { type ValidityBitmap } from './columnar/validity.js';
|
|
36
36
|
export type { AlignSchema, ArrayColumnNameForSchema, BaselineSchema, AggregateFunction, AggregateReducer, AggregateOutputMap, AggregateOutputSpec, AggregateMap, AggregateSchema, ValidatedAggregateMap, ColumnDef, CollapseData, ArrayAggregateAppendSchema, ArrayAggregateKind, ArrayAggregateReplaceSchema, ArrayExplodeAppendSchema, ArrayExplodeReplaceSchema, CollapseSchema, DedupeKeep, EventDataForSchema, EventForSchema, EventKeyForKind, FillMapping, FillStrategy, MaterializeSchema, EventKeyForSchema, FirstColKind, FirstColumn, IntervalKeyedSchema, JsonIntervalInput, JsonObjectRowForSchema, JsonRowFormat, JsonRowForSchema, JsonTimeRangeInput, JsonTimestampInput, JsonValueForKind, RollingAlignment, RollingSchema, JoinConflictMode, JoinManySchema, PrefixedJoinManySchema, PrefixedJoinSchema, LiveSource, JoinType, JoinSchema, NormalizedRowForSchema, NormalizedObjectRowForSchema, NormalizedObjectRow, NormalizedValueForKind, ReduceResult, RenameData, RenameMap, RenameSchema, RekeySchema, RowForSchema, AppendColumn, OptionalNumberColumn, ArrayValue, ColumnValue, ScalarKind, ScalarValue, CustomAggregateReducer, DiffSchema, NumericColumnNameForSchema, SmoothMethod, SmoothAppendSchema, SmoothSchema, SelectData, SelectSchema, SeriesSchema, TimeKeyedSchema, FlatKeyColumns, TimeSeriesColumnarInput, TimeSeriesColumnarOutput, TimeSeriesInput, TimeSeriesJsonColumns, TimeSeriesJsonInput, TimeRangeKeyedSchema, ValueColumnsForSchema, ValueColumn, ValueForKind, ValueFirstColumn, ValueKeyedSchema, ValueSeriesColumnarInput, ValueSeriesColumnarOutput, ValueSeriesColumnName, ValueSeriesJsonCell, ValueSeriesJsonColumns, ValueSeriesJsonInput, ValueSeriesJsonObjectRow, ValueSeriesJsonOutputArray, ValueSeriesJsonOutputObject, ValueSeriesJsonRow, ValueSeriesObjectRow, ValueSeriesRow, ValueSeriesSchema, ValueSeriesValueColumns, } from './schema/index.js';
|
|
37
37
|
export type { CalendarOptions, CalendarUnit, TimeZoneOptions, } from './core/calendar.js';
|
|
38
|
+
export { TimeZone } from './core/time-zone.js';
|
|
39
|
+
export type { Disambiguation, StartOfOptions, ZonedParts, ZonedPartsInput, } from './core/time-zone.js';
|
|
38
40
|
export type { EventKey, IntervalInput, IntervalValue, TemporalLike, TimeRangeInput, TimestampInput, } from './core/temporal.js';
|
|
39
41
|
export type { DurationInput } from './core/duration.js';
|
|
40
42
|
export type { SequenceSample } from './sequence/sequence.js';
|
package/dist/index.js
CHANGED
|
@@ -55,4 +55,5 @@ export { StringColumn } from './columnar/string-column.js';
|
|
|
55
55
|
export { ArrayColumn } from './columnar/array-column.js';
|
|
56
56
|
export { ChunkedArrayColumn, ChunkedBooleanColumn, ChunkedFloat64Column, ChunkedStringColumn, } from './columnar/chunked-column.js';
|
|
57
57
|
export { IntervalKeyColumn, TimeKeyColumn, TimeRangeKeyColumn, ValueKeyColumn, } from './columnar/key-column.js';
|
|
58
|
+
export { TimeZone } from './core/time-zone.js';
|
|
58
59
|
//# sourceMappingURL=index.js.map
|
|
@@ -66,7 +66,10 @@ export declare class Sequence {
|
|
|
66
66
|
* Creates an unbounded calendar-aware sequence.
|
|
67
67
|
*
|
|
68
68
|
* Calendar sequences step by local calendar boundaries in an IANA time zone instead of by a
|
|
69
|
-
* fixed millisecond duration. Supported units are `"day"`, `"week"`,
|
|
69
|
+
* fixed millisecond duration. Supported units are `"day"`, `"week"`, `"month"`, `"quarter"`
|
|
70
|
+
* and `"year"`; any other unit throws `RangeError`. Boundaries come from `TimeZone`, the
|
|
71
|
+
* same primitive the charts' time axis places ticks with, so a bucket edge and the tick
|
|
72
|
+
* that labels it are one instant.
|
|
70
73
|
*
|
|
71
74
|
* Defaults:
|
|
72
75
|
* - `timeZone`: `"UTC"`
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BoundedSequence } from './bounded-sequence.js';
|
|
2
|
-
import { normalizeWeekStartsOn,
|
|
2
|
+
import { normalizeWeekStartsOn, resolveTimeZone, } from '../core/calendar.js';
|
|
3
|
+
import { TimeZone, assertCalendarUnit } from '../core/time-zone.js';
|
|
3
4
|
import { parseDuration } from '../core/duration.js';
|
|
4
5
|
import { Interval } from '../core/interval.js';
|
|
5
6
|
import { toTimeRange } from '../core/time-range.js';
|
|
@@ -31,8 +32,10 @@ export class Sequence {
|
|
|
31
32
|
}
|
|
32
33
|
else {
|
|
33
34
|
this.#kind = 'calendar';
|
|
35
|
+
assertCalendarUnit(input.unit);
|
|
34
36
|
this.#calendarUnit = input.unit;
|
|
35
|
-
|
|
37
|
+
// Resolve eagerly so an unknown zone fails here, not on first `bounded()`.
|
|
38
|
+
this.#timeZone = TimeZone.of(input.timeZone).id;
|
|
36
39
|
this.#weekStartsOn = normalizeWeekStartsOn(input.weekStartsOn);
|
|
37
40
|
}
|
|
38
41
|
Object.freeze(this);
|
|
@@ -62,7 +65,10 @@ export class Sequence {
|
|
|
62
65
|
* Creates an unbounded calendar-aware sequence.
|
|
63
66
|
*
|
|
64
67
|
* Calendar sequences step by local calendar boundaries in an IANA time zone instead of by a
|
|
65
|
-
* fixed millisecond duration. Supported units are `"day"`, `"week"`,
|
|
68
|
+
* fixed millisecond duration. Supported units are `"day"`, `"week"`, `"month"`, `"quarter"`
|
|
69
|
+
* and `"year"`; any other unit throws `RangeError`. Boundaries come from `TimeZone`, the
|
|
70
|
+
* same primitive the charts' time axis places ticks with, so a bucket edge and the tick
|
|
71
|
+
* that labels it are one instant.
|
|
66
72
|
*
|
|
67
73
|
* Defaults:
|
|
68
74
|
* - `timeZone`: `"UTC"`
|
|
@@ -181,16 +187,12 @@ export class Sequence {
|
|
|
181
187
|
}
|
|
182
188
|
return new BoundedSequence(intervals);
|
|
183
189
|
}
|
|
184
|
-
const
|
|
190
|
+
const zone = TimeZone.of(this.#timeZone);
|
|
185
191
|
const unit = this.#calendarUnit;
|
|
186
|
-
const
|
|
187
|
-
let
|
|
192
|
+
const startOptions = { weekStartsOn: this.#weekStartsOn };
|
|
193
|
+
let start = zone.startOf(unit, requested.begin(), startOptions);
|
|
188
194
|
while (true) {
|
|
189
|
-
const
|
|
190
|
-
const nextDate = nextCalendarStart(currentDate, unit);
|
|
191
|
-
const nextStart = plainDateToStart(nextDate, timeZone);
|
|
192
|
-
const start = currentStart.epochMilliseconds;
|
|
193
|
-
const end = nextStart.epochMilliseconds;
|
|
195
|
+
const end = zone.next(unit, start, startOptions);
|
|
194
196
|
const sampleTime = sample === 'end'
|
|
195
197
|
? end
|
|
196
198
|
: sample === 'center'
|
|
@@ -206,7 +208,7 @@ export class Sequence {
|
|
|
206
208
|
// 'end': sample ∈ (requested.begin, requested.end]
|
|
207
209
|
//
|
|
208
210
|
// Under 'overlap' every candidate is in: the walk starts at the bucket
|
|
209
|
-
// containing `requested.begin()` (`
|
|
211
|
+
// containing `requested.begin()` (`zone.startOf` floors to it), and
|
|
210
212
|
// every later bucket starts after that, so the leading test can only
|
|
211
213
|
// ever pass. This is the one line the leading-bucket drop turned on —
|
|
212
214
|
// the containing bucket was computed above and then discarded here.
|
|
@@ -218,7 +220,7 @@ export class Sequence {
|
|
|
218
220
|
if (include) {
|
|
219
221
|
intervals.push(new Interval({ value: start, start, end }));
|
|
220
222
|
}
|
|
221
|
-
|
|
223
|
+
start = end;
|
|
222
224
|
}
|
|
223
225
|
return new BoundedSequence(intervals);
|
|
224
226
|
}
|