@pond-ts/charts 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/CHANGELOG.md CHANGED
@@ -8,7 +8,9 @@ The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`,
8
8
  under a single `v*` tag, so this file covers them all. Pre-1.0: minor bumps may
9
9
  include new features and type-level changes; patch bumps are strictly additive.
10
10
 
11
- [Unreleased]: https://github.com/pond-ts/pond/compare/v0.67.0...HEAD
11
+ [Unreleased]: https://github.com/pond-ts/pond/compare/v0.69.0...HEAD
12
+ [0.69.0]: https://github.com/pond-ts/pond/compare/v0.68.0...v0.69.0
13
+ [0.68.0]: https://github.com/pond-ts/pond/compare/v0.67.0...v0.68.0
12
14
  [0.67.0]: https://github.com/pond-ts/pond/compare/v0.66.0...v0.67.0
13
15
  [0.66.0]: https://github.com/pond-ts/pond/compare/v0.65.0...v0.66.0
14
16
  [0.65.0]: https://github.com/pond-ts/pond/compare/v0.64.0...v0.65.0
@@ -70,6 +72,112 @@ include new features and type-level changes; patch bumps are strictly additive.
70
72
 
71
73
  ## [Unreleased]
72
74
 
75
+ ## [0.69.0] — 2026-09-13
76
+
77
+ ### Added
78
+
79
+ - **`<ChartContainer timeZone>` — the time axis in any IANA zone
80
+ ([PND-TZAXIS]).** Day / week / month ticks land on that zone's midnights,
81
+ Mondays and month starts; labels, the stacked date bands, the hierarchical
82
+ grid, session dividers and every cursor / marker / annotation readout read
83
+ in it. **Omitted ⇒ the viewer's zone**, exactly as before. The d3 specifier
84
+ strings on `timeFormat` / `cursorFormat` are unchanged; `%Z` / `%z` now read
85
+ the zone's abbreviation / offset. Sub-day ticks align to the zone's wall
86
+ clock, so a 6 h grain reads 00 / 06 / 12 / 18 across a DST jump instead of
87
+ drifting by an hour until the next midnight. The resolved zone is on the
88
+ chart context as `timeZone`. Built on core's `TimeZone` ([PND-TZCAL]), so a
89
+ `Sequence.calendar('day', { timeZone })` bucket edge and the tick that
90
+ labels it are one instant — pinned by a cross-package test.
91
+ - **`<XAxis timeZone>` — a second strip in another zone.** Two time axes
92
+ over one shared mapping, each ticking and labelling (and pilling) in its
93
+ own zone: `<XAxis side="top" timeZone="America/New_York" />` above a
94
+ UTC container's own strip below. Backed by
95
+ `TradingTimeScale.withTimeZone(zone)` / `.timeZone()` and an optional
96
+ `DiscontinuityProvider.withTimeZone` (the identity provider re-derives its
97
+ day anchors; a trading calendar's session opens are zone-independent).
98
+ - **`TradingCalendarLike.timeZone?`** — a calendar that carries its exchange
99
+ zone supplies the axis default (`calendar={cal}` renders in exchange time
100
+ wherever it is viewed); an explicit `timeZone` prop wins.
101
+ - `scaleTradingTime(provider, { timeZone })` and
102
+ `identityProvider({ timeZone })` take the zone directly for consumers
103
+ building the scale themselves; `identityProvider`, `TradingCalendarLike`
104
+ and `ScaleTimeZoneOptions` are now exported. Internally the tick ladder
105
+ runs on a `TickCalendar` seam whose local implementation is the previous
106
+ `Date` arithmetic verbatim — the default path is unchanged.
107
+ - **`TradingCalendar.timeZone` ([PND-TZFIN]).** `@pond-ts/financial`'s
108
+ calendar keeps the zone its sessions were resolved in — `fromRules` carries
109
+ `rules.timeZone`, `fromSessions(list, { timeZone })` takes it — so
110
+ `<ChartContainer calendar={cal}>` renders the axis in exchange time with no
111
+ further wiring.
112
+ - `@pond-ts/charts` now depends on `d3-time-format` directly (it was already
113
+ a transitive dependency via `d3-scale`).
114
+ - **`TimeZone` — the zone-calendar primitive ([PND-TZCAL]).** `pond-ts`
115
+ exports `TimeZone.of(id)` (interned; also `TimeZone.UTC`,
116
+ `TimeZone.local()`) with `startOf(unit, t)`, `next(unit, t)`, `parts(t)`,
117
+ `instant(parts, { disambiguation })`, `offsetAt(t)` and
118
+ `abbreviation(t, { locale })`. Temporal underneath, but each zone caches its
119
+ offset transitions as it discovers them, so steady-state calls are integer
120
+ arithmetic: `startOf('day')` went from ~24 µs to ~23 ns per call, and a
121
+ three-year hourly series aggregated to `America/New_York` days from 38 ms
122
+ to 0.5 ms. `Sequence.calendar`, `TimeRange.fromCalendar` and
123
+ `Interval.fromCalendar` now bucket through it (no behaviour change; pinned
124
+ against Temporal on eight zones including southern-hemisphere DST, a
125
+ 30-minute DST shift, a +05:30 zone, a day with no midnight and Samoa's
126
+ skipped day). This is the primitive the charts' time axis will place and
127
+ label ticks with, so a bucket edge and the tick that labels it are one
128
+ instant. First task of the time-zone plan
129
+ (`docs/plans/PND_TIMEZONE_PLAN.md`).
130
+ - **`CalendarUnit` gains `'quarter'` and `'year'`** for
131
+ `Sequence.calendar`, `TimeRange.fromCalendar` and `Interval.fromCalendar`.
132
+
133
+ ### Changed
134
+
135
+ - **`Sequence.calendar` validates its inputs at construction.** An unknown
136
+ unit (`'hour'`) or zone (`'Nowhere'`) now throws `RangeError` immediately;
137
+ before, an unknown unit silently produced wrong buckets (the two unit
138
+ dispatchers fell through to different defaults — the 2026-06 audit's §6
139
+ finding) and an unknown zone failed only on first `bounded()`.
140
+
141
+ ### Fixed
142
+
143
+ - **`pond-ts`: two type-level corners of [PND-PARTCOL] (0.68.0) found by the Codex pass on #724.** (1) On a broad `TimeSeries<SeriesSchema>` with a _literal_ partition column, the injected `'first'` could not look up a kind and typed the column as `undefined`; `WithPartitionColumns` now takes the schema and leaves the mapping alone when the schema is broad, so the result type is exactly 0.67's. (2) `By` had no variance pin, so `PartitionedTimeSeries<S, K, 'host'>` accepted a view partitioned by `region` (and an untyped view could be narrowed to any column); a phantom contravariant member now rejects both while a specialised view still assigns to the legacy `PartitionedTimeSeries<S>` shape. Type tests cover both plus the `K`-survives-`smooth`/`baseline` claim. No runtime change.
144
+ - **Docs said a wall-clock string without `parse.timeZone` throws. It never
145
+ did** ([PND-TZDOCS]) — it is read as UTC, silently. `creating.mdx`, the
146
+ agent guide (`AGENTS.md`) and the decision table now say so and describe
147
+ how the shift shows up. The agent guide also gains the one time-zone rule:
148
+ pass the same `timeZone` to `Sequence.calendar` and `<ChartContainer>`.
149
+ The aggregation page cross-links `Sequence.calendar` for weekly / monthly
150
+ bars (issue #358 item 1, supersedes #359). The finance gallery's off-chart
151
+ readout takes the calendar's zone instead of hard-coding New York; the
152
+ Niño 3.4 heat map's year grain uses `Sequence.calendar('year')`.
153
+
154
+ ## [0.68.0] — 2026-09-13
155
+
156
+ ### Added
157
+
158
+ - **Agent adoption tranche ([PND-ADOPTMETA] / [PND-ADOPTLINKS] /
159
+ [PND-LLMSTXT] / [PND-AGENTGUIDE] / [PND-SKILL] / [PND-CONTEXT7]).** Every
160
+ package now declares `keywords`, `homepage` and `bugs` (there were none —
161
+ `pond-ts` ranked last in `npm search "time series"`). Every tarball ships an
162
+ `AGENTS.md` (source `docs/agents/USING_POND.md`): which package for which
163
+ task, the core idioms, the mistakes agents make. `pond-ts.org/llms.txt` is
164
+ now llmstxt.org-shaped (titles + descriptions per page, one section per
165
+ docs area, `Optional` links to `API.md` / the agent guide) with per-area
166
+ `llms-<area>.txt` dumps so a single fetch stays small. A Claude Code plugin
167
+ marketplace lives in the repo (`/plugin marketplace add pond-ts/pond`) with
168
+ `pond-ts`, `pond-charts` and `pond-financial` skills. `context7.json`
169
+ configures docs-MCP indexing. Plan and baseline:
170
+ `docs/plans/PND_ADOPTION_PLAN.md`.
171
+
172
+ - **Agent guide + skill hardened by the first cold-start run** (`docs/agents/USING_POND.md`, shipped as `AGENTS.md`; `plugins/pond-ts/skills/pond-ts`): install with `@latest` and the `.d.ts` paths that carry signatures. Cold-start harness committed at `docs/adoption/cold-start/`.
173
+
174
+ ### Fixed
175
+
176
+ - **`pond-ts`: the partition column is now in the static type after a partitioned `aggregate` / `rolling` ([PND-PARTCOL]).** `series.partitionBy('host').aggregate(seq, { p95: { from: 'ms', using: 'p95' } }).collect()` always carried `host` at runtime (auto-injected as `'first'`) but the result type omitted it, so `e.get('host')` failed to compile — every fresh agent in the cold-start experiment hit or pre-empted it. `PartitionedTimeSeries` gains a third type parameter `By` (the partition column names, captured by `partitionBy`, default `never`), and the two schema-replacing operators are typed over `WithPartitionColumns<Mapping, By>` — the user's keys win, kind and all; missing partition columns are added as `'first'`. Composite partitions and typed `groups` carry through; `smooth` / `baseline` under `partitionBy` now also keep `K`. Additive: untyped views are unchanged.
177
+ - `@pond-ts/charts` and `@pond-ts/fit` READMEs (rendered on npm) and eight
178
+ docs pages pointed at the retired `pjm17971.github.io/pond-ts` site /
179
+ `pjm17971/pond-ts` repo; now `pond-ts.org` / `pond-ts/pond`.
180
+
73
181
  ## [0.67.0] — 2026-09-11
74
182
 
75
183
  ### Added
package/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # @pond-ts/charts
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/@pond-ts/charts?label=%40pond-ts%2Fcharts)](https://www.npmjs.com/package/@pond-ts/charts)
4
+ [![CI](https://github.com/pond-ts/pond/actions/workflows/ci.yml/badge.svg)](https://github.com/pond-ts/pond/actions/workflows/ci.yml)
5
+ [![docs](https://img.shields.io/badge/docs-pond--ts.org-1f6feb)](https://pond-ts.org/docs/charts/)
6
+
3
7
  **React charts for [pond-ts](https://www.npmjs.com/package/pond-ts) time series.**
4
8
 
5
9
  A composable charting layer built directly on pond-ts series: a canvas data
@@ -62,8 +66,8 @@ instead of time, with no axis-type prop.
62
66
  ## Documentation
63
67
 
64
68
  Guides, the component reference, and live examples live at
65
- **<https://pjm17971.github.io/pond-ts/>**. Source and issues:
66
- [github.com/pjm17971/pond-ts](https://github.com/pjm17971/pond-ts).
69
+ **<https://pond-ts.org/docs/charts/>**. Source and issues:
70
+ [github.com/pond-ts/pond](https://github.com/pond-ts/pond).
67
71
 
68
72
  ## License
69
73
 
@@ -192,6 +192,29 @@ export interface ChartContainerProps {
192
192
  * calendar reference (build it once, not inline in JSX).
193
193
  */
194
194
  calendar?: TradingCalendarLike;
195
+ /**
196
+ * The IANA **time zone the time axis renders in** — ticks land on that
197
+ * zone's midnights / Mondays / month starts, labels, grid, date bands,
198
+ * session dividers and every cursor / marker readout read in it.
199
+ * **Omitted ⇒ the viewer's own zone** (the runtime's), which is what every
200
+ * chart did before this prop existed; `'UTC'` or any id `Intl` knows
201
+ * (`'Europe/Berlin'`, `'Australia/Sydney'`, …) names one. A trading
202
+ * {@link calendar} that carries a `timeZone` (a `@pond-ts/financial`
203
+ * `TradingCalendar.fromRules`) supplies the default, so a NYSE chart reads
204
+ * New York time wherever it is viewed; an explicit prop still wins. The
205
+ * calendar's zone is used even when a low-level {@link discontinuities}
206
+ * provider overrides its gap topology — the calendar still says which
207
+ * exchange this is.
208
+ *
209
+ * Pair it with the aggregate that produced the data — the same primitive
210
+ * (`TimeZone`) places these ticks and cuts `Sequence.calendar` buckets, so
211
+ * `Sequence.calendar('day', { timeZone })` and `<ChartContainer timeZone>`
212
+ * given the same zone put a bucket edge and its tick on one instant.
213
+ * Function formatters (`timeFormat`, `cursorFormat`) still receive epoch ms;
214
+ * read the resolved zone from the chart context. An unknown id throws
215
+ * `RangeError`. Only affects a **time** axis.
216
+ */
217
+ timeZone?: string | undefined;
195
218
  /**
196
219
  * The trading axis **metric**, when a {@link calendar} is supplied
197
220
  * (trading-calendar RFC Q7). `'proportional'` (default) keeps time
@@ -4,7 +4,7 @@ import { scaleLinear, scaleLog, scaleSymlog } from 'd3-scale';
4
4
  import { identityProvider, scaleTradingTime, } from './tradingTimeScale.js';
5
5
  import { scaleBand } from './bandScale.js';
6
6
  import { scaleElapsed } from './elapsed.js';
7
- import { Sequence } from 'pond-ts';
7
+ import { Sequence, TimeZone } from 'pond-ts';
8
8
  import { ContainerContext, CursorContext, } from './context.js';
9
9
  import { LegacyCursor, legacyCursorWarning, presetNameFor, warnOnDuplicateGestureOwners, } from './cursors.js';
10
10
  import { effectiveSelectorEntries, resolveControlledHovered, resolveControlledSelected, selectorEntryEqual, warnInertClick, } from './selectors.js';
@@ -198,7 +198,7 @@ function AutoSizeContainer(props) {
198
198
  * pass; a chart legitimately gated this long is not painting anyway. */
199
199
  const ZERO_SIZE_WARNING_MS = 600;
200
200
  /** {@link ChartContainer} with its width resolved to a concrete pixel number. */
201
- function ResolvedChartContainer({ range, categories: categoriesProp, maxBandWidth, bandAlign = 'start', width, height, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, onDrawStats, panZoom = false, axisPanZoom = false, bounds, onTimeRangeChange, minDuration = 1, cursor: cursorProp, cursorSequence: cursorSequenceProp, onRegionSelect, regionSelectModifier, cursorTime: cursorTimeProp, crosshairSnap: crosshairSnapProp, editAnnotations = false, creating = null, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap = true, timeFormat, cursorFormat: cursorFormatProp, origin, theme, discontinuities, calendar, spacing, xScale: xScaleKind = 'linear', grid = true, sessionDividers = 'none', children, }) {
201
+ function ResolvedChartContainer({ range, categories: categoriesProp, maxBandWidth, bandAlign = 'start', width, height, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, onDrawStats, panZoom = false, axisPanZoom = false, bounds, onTimeRangeChange, minDuration = 1, cursor: cursorProp, cursorSequence: cursorSequenceProp, onRegionSelect, regionSelectModifier, cursorTime: cursorTimeProp, crosshairSnap: crosshairSnapProp, editAnnotations = false, creating = null, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap = true, timeFormat, cursorFormat: cursorFormatProp, origin, theme, discontinuities, calendar, timeZone: timeZoneProp, spacing, xScale: xScaleKind = 'linear', grid = true, sessionDividers = 'none', children, }) {
202
202
  // ── Legacy cursor props (deprecated) ───────────────────────────────────────
203
203
  // The string surface keeps working for one minor: the resolved mode is
204
204
  // synthesized into the equivalent mounted preset below (`<LegacyCursor>`),
@@ -967,6 +967,13 @@ function ResolvedChartContainer({ range, categories: categoriesProp, maxBandWidt
967
967
  ? calendar.discontinuities(spacing ? { spacing } : undefined)
968
968
  : undefined, [resolvedKind, discontinuities, calendar, spacing]);
969
969
  const xDiscontinuities = resolvedKind === 'time' ? (discontinuities ?? calendarProvider) : undefined;
970
+ // The axis zone: the explicit prop, else the calendar's exchange zone, else
971
+ // runtime-local (`undefined`). Canonicalised through `TimeZone.of` so a bad
972
+ // id fails here, once, with its name, and so `'utc'` and `'UTC'` are one key.
973
+ const timeZone = useMemo(() => {
974
+ const id = timeZoneProp ?? calendar?.timeZone;
975
+ return id === undefined ? undefined : TimeZone.of(id).id;
976
+ }, [timeZoneProp, calendar]);
970
977
  // The shared x-side tick count — labels, x gridlines, session dividers, and
971
978
  // `formatTime` all pass this one value, so they derive from the same instants
972
979
  // (the alignment previously held by three hardcoded constants agreeing).
@@ -1147,7 +1154,7 @@ function ResolvedChartContainer({ range, categories: categoriesProp, maxBandWidt
1147
1154
  // sessions. Same tickFormat surface as scaleTime, so the readout is shared.
1148
1155
  // `xTickCount` reaches `tickFormat` too: the trading scale picks its anchor
1149
1156
  // grain from the count, so labels sit on the exact instants the ticks do.
1150
- const s = scaleTradingTime(xDiscontinuities)
1157
+ const s = scaleTradingTime(xDiscontinuities, { timeZone })
1151
1158
  .domain([d0, d1])
1152
1159
  .range([0, plotWidth]);
1153
1160
  if (elapsedOrigin !== undefined)
@@ -1164,7 +1171,7 @@ function ResolvedChartContainer({ range, categories: categoriesProp, maxBandWidt
1164
1171
  // never d3's mixed multi-scale default. Interactions stay on continuous
1165
1172
  // time math: the frame's `discontinuities` remains undefined, and identity
1166
1173
  // distance/offset are plain subtraction/addition anyway.
1167
- const s = scaleTradingTime(identityProvider())
1174
+ const s = scaleTradingTime(identityProvider({ timeZone }), { timeZone })
1168
1175
  .domain([d0, d1])
1169
1176
  .range([0, plotWidth]);
1170
1177
  if (elapsedOrigin !== undefined)
@@ -1187,6 +1194,7 @@ function ResolvedChartContainer({ range, categories: categoriesProp, maxBandWidt
1187
1194
  elapsedOrigin,
1188
1195
  xDiscontinuities,
1189
1196
  xTickCount,
1197
+ timeZone,
1190
1198
  ]);
1191
1199
  // The crosshair pixel (see resolveCursorX). A stored hoverX is a *plot* pixel;
1192
1200
  // if plotWidth changes mid-hover (a gutter reserving, or a width change) it's
@@ -1361,6 +1369,8 @@ function ResolvedChartContainer({ range, categories: categoriesProp, maxBandWidt
1361
1369
  onEditAnnotation,
1362
1370
  formatTime,
1363
1371
  formatReadout,
1372
+ timeZone,
1373
+ timeFormat,
1364
1374
  xFormatCustom: timeFormat !== undefined,
1365
1375
  xReadoutCustom: cursorFormat !== undefined,
1366
1376
  xTickCount,
@@ -1445,6 +1455,7 @@ function ResolvedChartContainer({ range, categories: categoriesProp, maxBandWidt
1445
1455
  onEditAnnotation,
1446
1456
  formatTime,
1447
1457
  formatReadout,
1458
+ timeZone,
1448
1459
  timeFormat,
1449
1460
  cursorFormat,
1450
1461
  xTickCount,
package/dist/XAxis.d.ts CHANGED
@@ -90,6 +90,18 @@ export interface XAxisProps {
90
90
  * measured fit (thin + middle-ellipsize) is what prevents collisions.
91
91
  */
92
92
  align?: 'auto' | 'center' | 'right';
93
+ /**
94
+ * Render **this strip** in an IANA zone other than the container's — the
95
+ * second axis of a two-zone pair (`<XAxis />` in the container's zone below
96
+ * the plot, `<XAxis side="top" timeZone="Asia/Tokyo" />` above it). Same
97
+ * pixel mapping, its own calendar: day ticks on *this* zone's midnights,
98
+ * labels, the date bands and this strip's cursor / marker pills reading in
99
+ * it. Time axis only; ignored under a `transform` or explicit `ticks`. A
100
+ * container `cursorFormat` still wins for the pill (it is its own channel);
101
+ * a container `timeFormat` string is re-resolved in this zone. Omit to
102
+ * follow the container's `timeZone` (or the viewer's zone).
103
+ */
104
+ timeZone?: string | undefined;
93
105
  /**
94
106
  * How a **time** axis lays out its date context (ignored on value / category
95
107
  * axes, and whenever a custom `format`, `transform`, or explicit `ticks`
@@ -140,6 +152,6 @@ export interface XAxisProps {
140
152
  * plot's own drag, including `bounds` / `minDuration` and the trading calendar.
141
153
  * A category axis has no continuous domain and stays inert.
142
154
  */
143
- export declare function XAxis({ format, label, side, height, ticks: customTicks, transform, color, align, dateStyle, onMouseEvent, }?: XAxisProps): import("react/jsx-runtime").JSX.Element;
155
+ export declare function XAxis({ format, label, side, height, ticks: customTicks, transform, color, align, dateStyle, timeZone, onMouseEvent, }?: XAxisProps): import("react/jsx-runtime").JSX.Element;
144
156
  export {};
145
157
  //# sourceMappingURL=XAxis.d.ts.map
package/dist/XAxis.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Fragment, useContext, useRef } from 'react';
2
+ import { Fragment, useContext, useMemo, useRef } from 'react';
3
3
  import { scaleLinear } from 'd3-scale';
4
+ import { TimeZone } from 'pond-ts';
4
5
  import { derivedTicks } from './derivedTicks.js';
5
6
  import { ContainerContext, CursorContext, } from './context.js';
6
7
  import { tickValues } from './yticks.js';
@@ -166,7 +167,7 @@ export function thinCategoryLabels(ticks, slot, plotWidth, fontSize, fontFamily)
166
167
  * plot's own drag, including `bounds` / `minDuration` and the trading calendar.
167
168
  * A category axis has no continuous domain and stays inert.
168
169
  */
169
- export function XAxis({ format, label, side = 'bottom', height, ticks: customTicks, transform, color, align = 'center', dateStyle = 'flat', onMouseEvent, } = {}) {
170
+ export function XAxis({ format, label, side = 'bottom', height, ticks: customTicks, transform, color, align = 'center', dateStyle = 'flat', timeZone, onMouseEvent, } = {}) {
170
171
  const container = useContext(ContainerContext);
171
172
  if (container === null) {
172
173
  throw new Error('<XAxis> must be rendered inside a <ChartContainer>');
@@ -175,7 +176,38 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
175
176
  // `xTickCount` is the container's shared x-side count — the same value the x
176
177
  // gridlines and `formatTime` use, so labels and grid stay on the same instants
177
178
  // (width-derived on a trading-time axis).
178
- const { xScale, plotWidth, leftGutter, theme, formatTime, xKind, xTickCount, } = container;
179
+ const { xScale: containerScale, plotWidth, leftGutter, theme, formatTime: containerFormatTime, xKind, xTickCount, } = container;
180
+ // A per-strip zone: the container's shared scale re-derived with its
181
+ // calendar in `timeZone` — identical pixel mapping, so every `xScale(v)`
182
+ // below lands where the plot puts it, but ticks / labels / bands / this
183
+ // strip's pills come from this zone's ladder. Canonicalised (and validated)
184
+ // so an unknown id throws by name and `'utc'` / `'UTC'` memoize as one.
185
+ const zonedScale = useMemo(() => {
186
+ if (timeZone === undefined ||
187
+ xKind !== 'time' ||
188
+ !('withTimeZone' in containerScale)) {
189
+ return undefined;
190
+ }
191
+ const id = TimeZone.of(timeZone).id;
192
+ const s = containerScale;
193
+ return s.timeZone() === id ? undefined : s.withTimeZone(id);
194
+ }, [containerScale, timeZone, xKind]);
195
+ const xScale = zonedScale ?? containerScale;
196
+ // The label formatter this strip falls back to when no explicit `format`
197
+ // shapes it. The container's `formatTime` was resolved in the container's
198
+ // zone; a zoned strip needs the same channel in its own — the grain-aware
199
+ // default, or the container's `timeFormat` specifier re-resolved (a
200
+ // function `timeFormat` receives epoch ms and is used verbatim either way).
201
+ const formatTime = useMemo(() => {
202
+ if (zonedScale === undefined)
203
+ return containerFormatTime;
204
+ const custom = container.timeFormat;
205
+ if (typeof custom === 'function')
206
+ return custom;
207
+ return custom === undefined
208
+ ? zonedScale.readoutFormat(xTickCount)
209
+ : resolveTimeFormat(zonedScale, xTickCount, custom);
210
+ }, [zonedScale, containerFormatTime, container.timeFormat, xTickCount]);
179
211
  // The cursor's x-axis slot: did the mounted cursor in effect register one
180
212
  // (`renderXAxis` — the crosshair's time pill)? While hovering, that is the
181
213
  // **hovered row's** effective cursor — so a per-row override reaches this
package/dist/context.d.ts CHANGED
@@ -270,6 +270,20 @@ export interface ContainerFrame {
270
270
  * tick labels) without moving them.
271
271
  */
272
272
  readonly formatReadout?: ((value: number) => string) | undefined;
273
+ /**
274
+ * The IANA zone the time axis renders in — the container's resolved
275
+ * `timeZone` (explicit prop, else the calendar's), canonical id; `undefined`
276
+ * when the axis is in the runtime's local zone. For a consumer's own
277
+ * formatter (`timeFormat` / `cursorFormat` functions receive epoch ms) to
278
+ * read the same zone the ticks do.
279
+ */
280
+ readonly timeZone: string | undefined;
281
+ /**
282
+ * The container's raw `timeFormat` prop, for a strip that must re-resolve it
283
+ * in another zone (`<XAxis timeZone>`): a specifier string is re-resolved
284
+ * against that strip's zoned scale, a function is used verbatim.
285
+ */
286
+ readonly timeFormat: AxisFormat | undefined;
273
287
  /** Whether an explicit container `timeFormat` shaped {@link formatTime}. The
274
288
  * x axis suppresses its boundary (second) label row when it's set — a
275
289
  * custom format owns the whole label, so the ladder mustn't second-line it. */
package/dist/index.d.ts CHANGED
@@ -68,8 +68,8 @@ export type { ChartLegend, LegendRow, LegendItem } from './useChartLegend.js';
68
68
  export { useChartFrame } from './useChartFrame.js';
69
69
  export type { ChartFrame, ChartFrameRow, ChartBands, ChartBand, } from './useChartFrame.js';
70
70
  export type { ChartXScale } from './context.js';
71
- export { scaleTradingTime } from './tradingTimeScale.js';
72
- export type { TradingTimeScale, DiscontinuityProvider, TimeGrain, } from './tradingTimeScale.js';
71
+ export { scaleTradingTime, identityProvider } from './tradingTimeScale.js';
72
+ export type { TradingTimeScale, DiscontinuityProvider, TradingCalendarLike, ScaleTimeZoneOptions, TimeGrain, } from './tradingTimeScale.js';
73
73
  export { scaleBand } from './bandScale.js';
74
74
  export type { ScaleBand } from './bandScale.js';
75
75
  export { Region, Baseline, Marker, Zone } from './annotations.js';
package/dist/index.js CHANGED
@@ -58,7 +58,7 @@ export { useChartLegend } from './useChartLegend.js';
58
58
  // by mirroring the library's own gutter arithmetic — a duplicate that drifts
59
59
  // silently the moment the library changes how a gutter is sized.
60
60
  export { useChartFrame } from './useChartFrame.js';
61
- export { scaleTradingTime } from './tradingTimeScale.js';
61
+ export { scaleTradingTime, identityProvider } from './tradingTimeScale.js';
62
62
  // The ordinal category (band) scale — the transpose view's "columns on x" axis.
63
63
  export { scaleBand } from './bandScale.js';
64
64
  // Annotations — user-authored marks in the turquoise register (distinct from the
@@ -1,3 +1,4 @@
1
+ import { TimeZone } from 'pond-ts';
1
2
  import type { DiscontinuityProvider } from './tradingTimeScale.js';
2
3
  /**
3
4
  * The logical tick ladder — grain selection for a time axis. Ticks sit on real
@@ -34,16 +35,62 @@ export type TickGranularity = 'second1' | 'second5' | 'second15' | 'second30' |
34
35
  export type TimeGrain = 'year' | 'quarter' | 'month' | 'week' | 'day' | 'hour' | 'minute' | 'second';
35
36
  /** Collapse a {@link TickGranularity} to its coarse {@link TimeGrain} unit. */
36
37
  export declare function coarseUnitOf(g: TickGranularity): TimeGrain;
38
+ /** The calendar operations the tick ladder needs. Months are `1…12`. */
39
+ export interface TickCalendar {
40
+ /** Midnight of the day containing `t`. */
41
+ startOfDay(t: number): number;
42
+ /** Midnight of the day after the one containing `t`. */
43
+ nextDay(t: number): number;
44
+ /** Midnight of the Monday of the week containing `t`. */
45
+ startOfWeek(t: number): number;
46
+ /** The civil date of `t`. */
47
+ parts(t: number): {
48
+ year: number;
49
+ month: number;
50
+ day: number;
51
+ };
52
+ /** Midnight of the first of `month` (`1…12`; `13` carries into the next year, `0` into the previous). */
53
+ monthStart(year: number, month: number): number;
54
+ /** Days in `month` of `year`. */
55
+ daysInMonth(year: number, month: number): number;
56
+ /**
57
+ * The first clock-aligned `stepMs` multiple at or after `t`, counted from
58
+ * `t`'s own midnight — 00:00 / 03:00 / 06:00 … for a 3 h step. The local
59
+ * calendar steps **fixed milliseconds** from midnight (so on a DST day the
60
+ * later anchors drift off the wall clock by the shift, until the next
61
+ * midnight re-anchors — the pre-seam behaviour, kept); a zoned calendar
62
+ * aligns to the **wall clock**, so 6 h anchors read 00 / 06 / 12 / 18 on
63
+ * both sides of the jump, with one short or long gap at the transition.
64
+ * Always `>= t`.
65
+ */
66
+ nextAligned(t: number, stepMs: number): number;
67
+ /**
68
+ * The anchor after an aligned anchor `t` at `stepMs`. The local calendar
69
+ * steps `t + stepMs` — fixed elapsed milliseconds, the pre-seam loop
70
+ * verbatim, which is what keeps a session that spans a DST midnight (a
71
+ * futures 18:00 → 17:00 session) ticking exactly as it did. A zoned
72
+ * calendar re-aligns through {@link nextAligned} so the anchors stay on
73
+ * the wall clock across the jump.
74
+ */
75
+ nextAnchor(t: number, stepMs: number): number;
76
+ }
77
+ /** The runtime-local calendar — `Date`'s local accessors, exactly as the
78
+ * ladder computed before it had a zone. The default {@link TickCalendar}. */
79
+ export declare const localTickCalendar: TickCalendar;
80
+ /** A {@link TickCalendar} for an IANA zone, on core's `TimeZone`. */
81
+ export declare function zonedTickCalendar(zone: TimeZone): TickCalendar;
82
+ /** Resolve an optional IANA id to the calendar the ladder should use: the
83
+ * runtime-local calendar when `timeZone` is undefined, else the zone's. */
84
+ export declare function tickCalendarFor(timeZone: string | undefined): TickCalendar;
37
85
  /**
38
- * The local-time bucket key for `t` at grain `g` — two instants in the same
39
- * day / week / month / quarter / year share a key. Local time (not UTC) so it
40
- * agrees with the local `scaleTime` label formatter; the exchange's own time
41
- * zone is unknown to the scale (the deferred refinement), and a session open
42
- * sits well inside its local day, so runtime-local grouping matches the
43
- * exchange day in every ordinary case. Hour grains are never bucketed (each
44
- * anchor is its own tick), so they key by identity.
86
+ * The calendar bucket key for `t` at grain `g` — two instants in the same
87
+ * day / week / month / quarter / year share a key. Computed in `cal`'s zone
88
+ * (runtime-local by default) so it agrees with the label formatter for the
89
+ * same zone; a trading axis passes its exchange zone so the grain buckets by
90
+ * the exchange day rather than the viewer's. Hour grains are never bucketed
91
+ * (each anchor is its own tick), so they key by identity.
45
92
  */
46
- export declare function bucketKey(t: number, g: TickGranularity): number;
93
+ export declare function bucketKey(t: number, g: TickGranularity, cal?: TickCalendar): number;
47
94
  /**
48
95
  * Thin an ascending run of **session opens** down to about `count` axis ticks.
49
96
  * Picks the finest rung: every session → **per-month uniform session stride**
@@ -82,7 +129,7 @@ export declare function bucketKey(t: number, g: TickGranularity): number;
82
129
  * This is the day-and-coarser half of the ladder; {@link buildTicks} adds the
83
130
  * sub-day rungs.
84
131
  */
85
- export declare function coarsenCalendar(opens: readonly number[], count: number, spanDays?: number, provider?: DiscontinuityProvider): {
132
+ export declare function coarsenCalendar(opens: readonly number[], count: number, spanDays?: number, provider?: DiscontinuityProvider, cal?: TickCalendar): {
86
133
  ticks: number[];
87
134
  granularity: TickGranularity;
88
135
  };
@@ -105,7 +152,7 @@ export declare function coarsenCalendar(opens: readonly number[], count: number,
105
152
  * the live-span estimate first (like {@link buildTicks}) and skipped when they
106
153
  * add no anchor beyond the session opens themselves (that is the day level).
107
154
  */
108
- export declare function buildGridLevels(provider: DiscontinuityProvider, opens: readonly number[], domainEnd: number, cap: number): Array<{
155
+ export declare function buildGridLevels(provider: DiscontinuityProvider, opens: readonly number[], domainEnd: number, cap: number, cal?: TickCalendar): Array<{
109
156
  granularity: TickGranularity;
110
157
  values: number[];
111
158
  }>;
@@ -130,7 +177,7 @@ export declare function nominalStepMs(g: TickGranularity): number;
130
177
  * themselves fit — a year of daily sessions never wastes time generating hour
131
178
  * anchors.
132
179
  */
133
- export declare function buildTicks(provider: DiscontinuityProvider, opens: readonly number[], domainEnd: number, cap: number): {
180
+ export declare function buildTicks(provider: DiscontinuityProvider, opens: readonly number[], domainEnd: number, cap: number, cal?: TickCalendar): {
134
181
  ticks: number[];
135
182
  granularity: TickGranularity;
136
183
  };
@@ -181,14 +228,13 @@ export declare function bandFormatFor(g: TickGranularity): string;
181
228
  * same-shade day-bands side by side on a gappy calendar. A rare, cosmetic
182
229
  * consequence of keeping the shade fixed to the date rather than the slot.
183
230
  */
184
- export declare function bandShaded(t: number, g: TickGranularity): boolean;
185
- /** The local-time start of the band grain `g` containing `t` (the band's left
186
- * edge): local midnight, month start, or Jan 1. Through the Date ctor so DST
187
- * and month/year overflow normalize correctly. */
188
- export declare function bandStartOf(t: number, g: TickGranularity): number;
231
+ export declare function bandShaded(t: number, g: TickGranularity, cal?: TickCalendar): boolean;
232
+ /** The start of the band grain `g` containing `t` (the band's left edge) in
233
+ * `cal`'s zone: midnight, month start, or Jan 1. */
234
+ export declare function bandStartOf(t: number, g: TickGranularity, cal?: TickCalendar): number;
189
235
  /** The start of the band grain `g` **after** the one containing `t` — the next
190
- * local midnight / month start / Jan 1. */
191
- export declare function bandNext(t: number, g: TickGranularity): number;
236
+ * midnight / month start / Jan 1 in `cal`'s zone. */
237
+ export declare function bandNext(t: number, g: TickGranularity, cal?: TickCalendar): number;
192
238
  /**
193
239
  * Which of `ticks` (at grain `granularity`) carry a boundary label: every tick
194
240
  * whose boundary-grain bucket differs from the previous tick's — i.e. a
@@ -199,7 +245,7 @@ export declare function bandNext(t: number, g: TickGranularity): number;
199
245
  * tick-to-tick on a live sliding window). Empty when the grain has no
200
246
  * boundary row (year grain).
201
247
  */
202
- export declare function boundaryTicks(ticks: readonly number[], granularity: TickGranularity, domainStart?: number): number[];
248
+ export declare function boundaryTicks(ticks: readonly number[], granularity: TickGranularity, domainStart?: number, cal?: TickCalendar): number[];
203
249
  /**
204
250
  * The terse **base** (non-promoted) flat label format for grain `g` — the label
205
251
  * a tick carries when it opens no coarser period: the clock time for a sub-day
@@ -228,5 +274,5 @@ export declare function flatBaseFormatFor(g: TickGranularity): string;
228
274
  * opening at May 1 midnight reads `May 16 …`, not `1 16 …`). Without
229
275
  * `domainStart` the first tick is never promoted.
230
276
  */
231
- export declare function flatFormats(ticks: readonly number[], granularity: TickGranularity, domainStart?: number): string[];
277
+ export declare function flatFormats(ticks: readonly number[], granularity: TickGranularity, domainStart?: number, cal?: TickCalendar): string[];
232
278
  //# sourceMappingURL=tickLadder.d.ts.map