@pond-ts/charts 0.45.0 → 0.46.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,8 @@ The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`,
8
8
  tag, so this file covers them all. Pre-1.0: minor bumps may include new features
9
9
  and type-level changes; patch bumps are strictly additive.
10
10
 
11
- [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.45.0...HEAD
11
+ [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.46.0...HEAD
12
+ [0.46.0]: https://github.com/pjm17971/pond-ts/compare/v0.45.0...v0.46.0
12
13
  [0.45.0]: https://github.com/pjm17971/pond-ts/compare/v0.44.1...v0.45.0
13
14
  [0.44.1]: https://github.com/pjm17971/pond-ts/compare/v0.44.0...v0.44.1
14
15
  [0.44.0]: https://github.com/pjm17971/pond-ts/compare/v0.43.0...v0.44.0
@@ -44,6 +45,54 @@ and type-level changes; patch bumps are strictly additive.
44
45
 
45
46
  ## [Unreleased]
46
47
 
48
+ ## [0.46.0] — 2026-07-14
49
+
50
+ ### Changed
51
+
52
+ - **charts:** the boundary (second-row) axis label's **context** now pins to
53
+ the plot's left edge instead of riding the first tick: it shows the period
54
+ the *domain start* is in, and a crossing label sliding toward the edge
55
+ pushes it off (the sticky-header behavior). On a live sliding window the
56
+ old first-tick anchoring made `Jan 01` hop tick-to-tick as ticks scrolled
57
+ out; pinned, it stays put until the period actually changes. Crossing
58
+ labels (day/year turns) still ride their ticks — including a first tick
59
+ whose period differs from the domain start's. `TradingTimeScale` gains
60
+ `boundaryContext(count)`; `tickBoundaries` now labels crossings only.
61
+
62
+ ### Fixed
63
+
64
+ - **charts:** a live (sliding-window) time axis no longer flickers between two
65
+ tick grains: the clock-rung choice now derives from the window's live span
66
+ (constant while sliding) instead of the enumerated anchor count, which
67
+ oscillates ±1 with the window's phase and flipped the grain for single
68
+ frames whenever it sat exactly at the width-derived cap.
69
+
70
+ ### Added
71
+
72
+ - **charts:** **dual x-axes** — two tick layouts on one shared scale. A second
73
+ `<XAxis>` stacks by declaration order (above/below the plot, either side,
74
+ same side twice); the new **`transform`** prop (`{ to, from }`, exported
75
+ `AxisTransform`) relabels an axis into a derived unit: strike ↔ moneyness on
76
+ a top axis, or a nonlinear BS-delta strip under a std-moneyness chart. Ticks
77
+ are nice derived-unit values chosen by a pixel-aware multi-resolution fill
78
+ (1-2-5 steps, coarsest first, admitted where they keep room), so a span the
79
+ transform compresses gets coarse ticks and a stretched span picks up finer
80
+ ones — and a label-honesty filter drops any tick whose formatted label would
81
+ lie about its position. Gridlines stay on the container's primary ticks; the
82
+ cursor pill on a transformed axis reads in the derived unit. Stories under
83
+ `Charts/Axes/DualX`. Each `<XAxis>` **and `<YAxis>`** also takes a
84
+ per-instance **`color`** (labels, tick marks, rule, title) — the lever that
85
+ distinguishes stacked x strips (a blue delta strip under a grey primary) and
86
+ colours a y axis to match its series (the dual-axis convention).
87
+
88
+ ### Fixed
89
+
90
+ - **charts:** annotation label chips now clip to the plot: a marker whose pole
91
+ pans off-plot no longer leaves its chip floating in the axis gutter, and a
92
+ partially visible region's chip clamps to the plot's left edge (culled only
93
+ when the region is entirely out of view). The lines/fills were already
94
+ SVG-clipped — only the DOM chips escaped.
95
+
47
96
  ## [0.45.0] — 2026-07-14
48
97
 
49
98
  ### Added
@@ -470,7 +470,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
470
470
  }, [cursorX, xScale, sources, plotWidth]);
471
471
  // Pack overlapping top-flag labels (markers + regions) into stacked lanes so
472
472
  // close-in-x labels don't collide; chips read their lane back off the frame.
473
- const labelLanes = useMemo(() => computeLabelLanes(annotations, (v) => xScale(v), draggingKey), [annotations, xScale, draggingKey]);
473
+ const labelLanes = useMemo(() => computeLabelLanes(annotations, (v) => xScale(v), draggingKey, plotWidth), [annotations, xScale, draggingKey]);
474
474
  const frame = useMemo(() => ({
475
475
  timeRange: [d0, d1],
476
476
  width,
package/dist/XAxis.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type AxisTransform } from './derivedTicks.js';
1
2
  import { type AxisFormat } from './format.js';
2
3
  export interface XAxisProps {
3
4
  /**
@@ -24,6 +25,31 @@ export interface XAxisProps {
24
25
  readonly at: number;
25
26
  readonly label: string;
26
27
  }>;
28
+ /**
29
+ * Relabel this axis into a **derived unit on the same scale** — a second
30
+ * tick layout, not a second scale (the pixel mapping never changes). E.g. a
31
+ * BS-delta strip under a std-moneyness chart (`transform={{ to: sigmaToDelta,
32
+ * from: deltaToSigma }}`) or a moneyness axis over a strike chart
33
+ * (`transform={{ to: (k) => k / spot, from: (m) => m * spot }}`). `to`/`from`
34
+ * are monotonic inverses (either direction); they may be **nonlinear** —
35
+ * ticks are nice derived-unit values at mixed 1-2-5 step sizes, admitted
36
+ * wherever they keep pixel room, so a span the transform compresses gets
37
+ * coarser ticks and a span it stretches gets finer ones. `format` (or the
38
+ * d3 number default) formats the derived values; the cursor pill and marker
39
+ * indicators on this axis read in the derived unit too. Ignored on a
40
+ * category axis; explicit {@link ticks} win. Typically used on a second
41
+ * `<XAxis>` stacked with the primary one — declaration order places the
42
+ * strips; gridlines stay on the container's own (primary) ticks.
43
+ */
44
+ transform?: AxisTransform;
45
+ /**
46
+ * This axis instance's colour — tick marks, labels, the plot-facing rule,
47
+ * and the `label` title all take it, overriding the theme's `axis.label` /
48
+ * `axis.grid` / `axis.title.color`. The lever that distinguishes stacked
49
+ * dual axes (a blue derived-unit strip under a grey primary). Cursor and
50
+ * marker pills keep their own colours. Omit for the theme's axis colours.
51
+ */
52
+ color?: string;
27
53
  /**
28
54
  * Horizontal placement of each tick label relative to its tick.
29
55
  * - **`'center'` (default)** — every label centred on its tick. Note the
@@ -48,5 +74,5 @@ export interface XAxisProps {
48
74
  *
49
75
  * `<TimeAxis>` is the time-flavoured preset (`<XAxis />`).
50
76
  */
51
- export declare function XAxis({ format, label, side, height, ticks: customTicks, align, }?: XAxisProps): import("react/jsx-runtime").JSX.Element;
77
+ export declare function XAxis({ format, label, side, height, ticks: customTicks, transform, color, align, }?: XAxisProps): import("react/jsx-runtime").JSX.Element;
52
78
  //# sourceMappingURL=XAxis.d.ts.map
package/dist/XAxis.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Fragment, useContext } from 'react';
3
+ import { scaleLinear } from 'd3-scale';
4
+ import { derivedTicks } from './derivedTicks.js';
3
5
  import { ContainerContext } from './context.js';
4
6
  import { axisPillStyle } from './chip.js';
5
7
  import { resolveAxisFormat, resolveTimeFormat, } from './format.js';
@@ -9,6 +11,10 @@ const TICK_STRIP = 22;
9
11
  const LABEL_STRIP = 16;
10
12
  /** Extra height reserved for the boundary (second) label row. */
11
13
  const BOUNDARY_STRIP = 15;
14
+ /** Minimum pixel gap between derived-unit (`transform`) ticks — the room a
15
+ * short numeric label needs plus breathing space, in the spirit of the
16
+ * ladder's per-tick budget (a hair tighter: derived labels are short). */
17
+ const TRANSFORM_TICK_PX = 48;
12
18
  /**
13
19
  * Thin + truncate a **category** axis's labels so a dense axis stays legible: keep
14
20
  * every `stride`-th label (so a kept label has room), and ellipsize one that still
@@ -49,7 +55,7 @@ function thinCategoryLabels(ticks, plotWidth, fontSize) {
49
55
  *
50
56
  * `<TimeAxis>` is the time-flavoured preset (`<XAxis />`).
51
57
  */
52
- export function XAxis({ format, label, side = 'bottom', height, ticks: customTicks, align = 'center', } = {}) {
58
+ export function XAxis({ format, label, side = 'bottom', height, ticks: customTicks, transform, color, align = 'center', } = {}) {
53
59
  const container = useContext(ContainerContext);
54
60
  if (container === null) {
55
61
  throw new Error('<XAxis> must be rendered inside a <ChartContainer>');
@@ -69,19 +75,37 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
69
75
  cursorX <= plotWidth;
70
76
  const cursorColor = theme.cursor ?? theme.axis.label;
71
77
  const annotationColor = theme.annotation?.color ?? '#0d9488';
78
+ // Derived-unit (`transform`) layout: nice ticks in the derived unit at
79
+ // mixed step sizes, admitted where they keep pixel room (see derivedTicks).
80
+ // Explicit `ticks` win; a category axis has no numeric unit to derive from.
81
+ const derived = transform !== undefined && xKind !== 'category' && customTicks === undefined
82
+ ? derivedTicks(transform, xScale.domain(), (v) => xScale(v), plotWidth, TRANSFORM_TICK_PX)
83
+ : null;
84
+ // Formatter for derived-unit values — `format` resolved against a u-space
85
+ // linear scale (so `'+.2f'` and the d3 number default both work).
86
+ const uFmt = transform !== undefined && xKind !== 'category'
87
+ ? (() => {
88
+ const [d0, d1] = xScale.domain();
89
+ const u = [transform.to(d0), transform.to(d1)].sort((a, b) => a - b);
90
+ return resolveAxisFormat(scaleLinear().domain(u), xTickCount, format);
91
+ })()
92
+ : null;
72
93
  // Tick formatter: an explicit `format` is resolved against the axis kind
73
94
  // (a time specifier through the time scale, a number specifier through the
74
95
  // value scale); otherwise the container's shared formatter — the one the
75
- // cursor readout uses, so a tick and the cursor read identically.
76
- const fmt =
77
- // A category axis labels by name (the container's `formatTime` = the band
78
- // scale's label lookup); a d3 number/time `format` can't name a category, so
79
- // it's ignored here (customize the labels in the `categories` data instead).
80
- format === undefined || xKind === 'category'
81
- ? formatTime
82
- : xKind === 'time'
83
- ? resolveTimeFormat(xScale, xTickCount, format)
84
- : resolveAxisFormat(xScale, xTickCount, format);
96
+ // cursor readout uses, so a tick and the cursor read identically. On a
97
+ // transformed axis every readout (cursor pill, marker indicator) speaks the
98
+ // **derived unit** the axis's own language.
99
+ const fmt = transform !== undefined && uFmt !== null && xKind !== 'category'
100
+ ? (v) => uFmt(transform.to(v))
101
+ : // A category axis labels by name (the container's `formatTime` = the band
102
+ // scale's label lookup); a d3 number/time `format` can't name a category, so
103
+ // it's ignored here (customize the labels in the `categories` data instead).
104
+ format === undefined || xKind === 'category'
105
+ ? formatTime
106
+ : xKind === 'time'
107
+ ? resolveTimeFormat(xScale, xTickCount, format)
108
+ : resolveAxisFormat(xScale, xTickCount, format);
85
109
  // Marker annotations that opted into an axis indicator (`<Marker indicator>`)
86
110
  // pin their **time** to this shared x-axis — a pill at `at`, in the annotation
87
111
  // colour, reading like a tick. An indicator always shows the axis coordinate
@@ -136,18 +160,54 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
136
160
  // custom format owns the whole label, and custom ticks have no grain).
137
161
  const boundaryOf = xKind === 'time' &&
138
162
  customTicks === undefined &&
163
+ transform === undefined &&
139
164
  format === undefined &&
140
165
  !container.xFormatCustom &&
141
166
  'tickBoundaries' in xScale
142
167
  ? xScale.tickBoundaries(xTickCount)
143
168
  : undefined;
169
+ // The pinned left-edge **context** label — what period the domain starts in
170
+ // (`Jan 01` over an intraday axis, the year over a month axis). A property
171
+ // of the domain, not of any tick: anchoring it to the first tick made it
172
+ // hop tick-to-tick on a live sliding window. Crossing labels ride their
173
+ // ticks and **push it off** the left edge as they approach (below).
174
+ const boundaryContext = boundaryOf !== undefined && 'boundaryContext' in xScale
175
+ ? xScale.boundaryContext(xTickCount)
176
+ : undefined;
177
+ // Derived ticks pass a **label-honesty filter**: the fill can descend below
178
+ // the format's resolution (a delta tick at u = 0.498 renders as "+0.50" under
179
+ // `+.2f` — a lie about its position), so a tick survives only when its
180
+ // formatted label parses back to a value that maps to (±1px of) the tick's
181
+ // own pixel. This also caps density at the format's precision and drops
182
+ // would-be duplicate labels. Non-numeric labels (a custom format function)
183
+ // are trusted as-is.
184
+ const honestDerived = () => {
185
+ const out = [];
186
+ const seen = new Set();
187
+ for (const t of derived) {
188
+ const text = uFmt(t.u);
189
+ if (seen.has(text))
190
+ continue;
191
+ const back = parseFloat(text.replace(/\u2212/g, '-').replace(/,/g, ''));
192
+ if (Number.isFinite(back)) {
193
+ const bx = xScale(transform.from(back));
194
+ if (!Number.isFinite(bx) || Math.abs(bx - t.x) > 1)
195
+ continue;
196
+ }
197
+ seen.add(text);
198
+ out.push({ x: t.x, label: text });
199
+ }
200
+ return out;
201
+ };
144
202
  const rawTicks = customTicks
145
203
  ? customTicks.map((t) => ({ x: xScale(t.at), label: t.label }))
146
- : xScale.ticks(xTickCount).map((d) => ({
147
- x: xScale(d),
148
- label: fmt(+d),
149
- boundary: boundaryOf?.(+d),
150
- }));
204
+ : derived !== null
205
+ ? honestDerived()
206
+ : xScale.ticks(xTickCount).map((d) => ({
207
+ x: xScale(d),
208
+ label: fmt(+d),
209
+ boundary: boundaryOf?.(+d),
210
+ }));
151
211
  // A category axis ticks once per category; thin + truncate its labels when they
152
212
  // crowd (an explicit `customTicks` axis keeps its labels verbatim).
153
213
  const placed = xKind === 'category' && customTicks === undefined && rawTicks.length > 1
@@ -160,7 +220,31 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
160
220
  // Per-lane vertical step for stacked pills; grow the strip to fit the stack.
161
221
  const PILL_LANE_H = theme.font.size + 6;
162
222
  // Any boundary label in view grows the strip by one row (like pill lanes do).
163
- const hasBoundary = placed.some((t) => t.boundary !== undefined);
223
+ const hasBoundary = boundaryContext !== undefined ||
224
+ placed.some((t) => t.boundary !== undefined);
225
+ // The pinned context anchors at the plot's left edge (the y-axis line) with
226
+ // the SAME alignment as the tick labels — centred on it in `center` mode
227
+ // (exactly where a first-tick label at x=0 sat, half into the gutter by the
228
+ // same documented rule), left-anchored in `auto`, beside the line in
229
+ // `right`. As the leftmost crossing label slides toward the edge it would
230
+ // collide, so the context CULLS once the crossing label's left edge reaches
231
+ // the context's right edge (plus a gap) — no overlap, and nothing slides
232
+ // loose into the gutter. Widths from the same rough glyph metric the pills
233
+ // use; the crossing's label is centred on its tick.
234
+ const charW = theme.font.size * 0.62;
235
+ const contextWidth = (boundaryContext?.length ?? 0) * charW;
236
+ const contextRight = align === 'center'
237
+ ? contextWidth / 2
238
+ : align === 'right'
239
+ ? 4 + contextWidth
240
+ : contextWidth;
241
+ const firstCrossing = placed.find((t) => t.boundary !== undefined);
242
+ const crossingLeft = firstCrossing
243
+ ? align === 'right'
244
+ ? firstCrossing.x + 4
245
+ : firstCrossing.x - (firstCrossing.boundary.length * charW) / 2
246
+ : Infinity;
247
+ const showContext = boundaryContext !== undefined && crossingLeft > contextRight + 6;
164
248
  const stripHeight = (height ?? TICK_STRIP + (label ? LABEL_STRIP : 0)) +
165
249
  (hasBoundary ? BOUNDARY_STRIP : 0) +
166
250
  maxPillLane * PILL_LANE_H;
@@ -170,10 +254,10 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
170
254
  width: `${plotWidth}px`,
171
255
  height: `${stripHeight}px`,
172
256
  // The plot-facing edge carries the rule; a top axis rules its bottom.
173
- [onTop ? 'borderBottom' : 'borderTop']: `1px solid ${theme.axis.grid}`,
257
+ [onTop ? 'borderBottom' : 'borderTop']: `1px solid ${color ?? theme.axis.grid}`,
174
258
  fontFamily: theme.font.family,
175
259
  fontSize: `${theme.font.size}px`,
176
- color: theme.axis.label,
260
+ color: color ?? theme.axis.label,
177
261
  }, children: [placed.map((t, i) => {
178
262
  const isFirst = i === 0;
179
263
  const isLast = i === placed.length - 1;
@@ -197,7 +281,7 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
197
281
  [onTop ? 'bottom' : 'top']: 0,
198
282
  width: '1px',
199
283
  height: `${tickHeight}px`,
200
- background: theme.axis.grid,
284
+ background: color ?? theme.axis.grid,
201
285
  } }), _jsx("div", { style: {
202
286
  position: 'absolute',
203
287
  left: `${labelLeft}px`,
@@ -212,7 +296,14 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
212
296
  whiteSpace: 'nowrap',
213
297
  opacity: 0.75,
214
298
  }, children: t.boundary }))] }, `${t.x}-${i}`));
215
- }), label !== undefined && (_jsx("div", { style: {
299
+ }), showContext && (_jsx("div", { "data-boundary-label": true, "data-boundary-context": true, style: {
300
+ position: 'absolute',
301
+ left: `${align === 'right' ? 4 : 0}px`,
302
+ [onTop ? 'bottom' : 'top']: `${(align === 'right' ? 2 : 6) + theme.font.size + 3}px`,
303
+ transform: align === 'center' ? 'translateX(-50%)' : 'none',
304
+ whiteSpace: 'nowrap',
305
+ opacity: 0.75,
306
+ }, children: boundaryContext })), label !== undefined && (_jsx("div", { style: {
216
307
  position: 'absolute',
217
308
  left: 0,
218
309
  width: '100%',
@@ -220,7 +311,7 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
220
311
  [onTop ? 'top' : 'bottom']: 0,
221
312
  // Themeable axis-title text (shared with the rotated y-axis title).
222
313
  fontSize: `${theme.axis.title?.size ?? theme.font.size + 1}px`,
223
- color: theme.axis.title?.color ?? theme.axis.label,
314
+ color: color ?? theme.axis.title?.color ?? theme.axis.label,
224
315
  opacity: theme.axis.title?.opacity ?? 0.85,
225
316
  whiteSpace: 'nowrap',
226
317
  }, children: label })), markerTags.map((t) => {
package/dist/YAxis.d.ts CHANGED
@@ -70,6 +70,14 @@ export interface YAxisProps {
70
70
  boundaryLabels?: boolean;
71
71
  /** Gutter width in CSS pixels (default 50). */
72
72
  width?: number;
73
+ /**
74
+ * This axis instance's colour — tick labels and the axis title take it,
75
+ * overriding the theme's `axis.label` / `axis.title.color`. The multi-axis
76
+ * convention of colouring each y axis to match its series (`color`
77
+ * matching the layer's) — busy, but standard. Omit for the theme's axis
78
+ * colours. Presentation-only: it never re-registers the axis.
79
+ */
80
+ color?: string;
73
81
  /**
74
82
  * @internal Declaration position among the row's children, injected by
75
83
  * `ChartRow` so the first-declared axis stays the default. Do not set.
@@ -84,5 +92,5 @@ export interface YAxisProps {
84
92
  * tick marks + labels from that scale. Charts attach via `<LineChart axis="id">`
85
93
  * (default: the first axis).
86
94
  */
87
- export declare function YAxis({ id, side, label, min, max, format, ticks, pad, boundaryLabels, width, labelPlacement, index, }: YAxisProps): import("react/jsx-runtime").JSX.Element;
95
+ export declare function YAxis({ id, side, label, min, max, format, ticks, pad, boundaryLabels, width, labelPlacement, color, index, }: YAxisProps): import("react/jsx-runtime").JSX.Element;
88
96
  //# sourceMappingURL=YAxis.d.ts.map
package/dist/YAxis.js CHANGED
@@ -13,7 +13,7 @@ const TICK_COUNT = 5;
13
13
  * tick marks + labels from that scale. Charts attach via `<LineChart axis="id">`
14
14
  * (default: the first axis).
15
15
  */
16
- export function YAxis({ id, side = 'left', label, min, max, format, ticks, pad = 0, boundaryLabels = true, width = DEFAULT_WIDTH, labelPlacement = 'rotated', index = 0, }) {
16
+ export function YAxis({ id, side = 'left', label, min, max, format, ticks, pad = 0, boundaryLabels = true, width = DEFAULT_WIDTH, labelPlacement = 'rotated', color, index = 0, }) {
17
17
  const container = useContext(ContainerContext);
18
18
  if (container === null) {
19
19
  throw new Error('<YAxis> must be rendered inside a <ChartContainer>');
@@ -76,7 +76,7 @@ export function YAxis({ id, side = 'left', label, min, max, format, ticks, pad =
76
76
  height: `${row.height}px`,
77
77
  fontFamily: theme.font.family,
78
78
  fontSize: `${theme.font.size}px`,
79
- color: theme.axis.label,
79
+ color: color ?? theme.axis.label,
80
80
  }, children: [yScale &&
81
81
  tickList.map(({ value, label }, i) => {
82
82
  // Drop just the top & bottom labels when boundary labels are off
@@ -102,7 +102,7 @@ export function YAxis({ id, side = 'left', label, min, max, format, ticks, pad =
102
102
  // labels' alignment, rather than floating at the outer gutter edge.
103
103
  [side === 'left' ? 'right' : 'left']: '4px',
104
104
  fontSize: `${theme.axis.title?.size ?? theme.font.size + 1}px`,
105
- color: theme.axis.title?.color ?? theme.axis.label,
105
+ color: color ?? theme.axis.title?.color ?? theme.axis.label,
106
106
  opacity: theme.axis.title?.opacity ?? 0.85,
107
107
  whiteSpace: 'nowrap',
108
108
  pointerEvents: 'none',
@@ -116,7 +116,7 @@ export function YAxis({ id, side = 'left', label, min, max, format, ticks, pad =
116
116
  alignItems: 'center',
117
117
  justifyContent: 'center',
118
118
  fontSize: `${theme.axis.title?.size ?? theme.font.size + 1}px`,
119
- color: theme.axis.title?.color ?? theme.axis.label,
119
+ color: color ?? theme.axis.title?.color ?? theme.axis.label,
120
120
  opacity: theme.axis.title?.opacity ?? 0.85,
121
121
  pointerEvents: 'none',
122
122
  }, children: _jsx("span", { style: {
@@ -15,7 +15,7 @@ import { type AnnotationSpec, type ContainerFrame, type LabelPlacement } from '.
15
15
  * the next free lane. The `draggingKey` is excluded (pinned to lane 0, its own
16
16
  * label) so the static labels hold their lanes as it crosses them.
17
17
  */
18
- export declare function computeLabelLanes(annotations: readonly AnnotationSpec[], toPixel: (axisX: number) => number, draggingKey?: symbol | null): Map<symbol, LabelPlacement>;
18
+ export declare function computeLabelLanes(annotations: readonly AnnotationSpec[], toPixel: (axisX: number) => number, draggingKey?: symbol | null, plotWidth?: number): Map<symbol, LabelPlacement>;
19
19
  /**
20
20
  * Snap a dragged plot-pixel `px` to the nearest **guideline** within
21
21
  * {@link SNAP_PX} — another annotation's x, **or** a trading-axis **disjoint
@@ -169,7 +169,7 @@ const labelWidth = (text) => text.length * LABEL_CHAR_W + LABEL_PAD;
169
169
  * the next free lane. The `draggingKey` is excluded (pinned to lane 0, its own
170
170
  * label) so the static labels hold their lanes as it crosses them.
171
171
  */
172
- export function computeLabelLanes(annotations, toPixel, draggingKey) {
172
+ export function computeLabelLanes(annotations, toPixel, draggingKey, plotWidth) {
173
173
  const out = new Map();
174
174
  const byRow = new Map();
175
175
  for (const a of annotations) {
@@ -195,22 +195,33 @@ export function computeLabelLanes(annotations, toPixel, draggingKey) {
195
195
  markerGroups.set(a.xs[0], [a]);
196
196
  }
197
197
  else {
198
+ // Lane-pack at the position the chip will *render*: a region panned
199
+ // half off-plot renders clamped to the plot's left edge, and a fully
200
+ // off-plot region's chip is culled — so it must not hold a lane.
198
201
  const ax = a.kind === 'region' ? Math.min(a.xs[0], a.xs[1]) : a.xs[0];
202
+ const bx = a.kind === 'region' ? Math.max(a.xs[0], a.xs[1]) : a.xs[0];
203
+ const rawLeft = toPixel(ax);
204
+ if (plotWidth !== undefined && (rawLeft > plotWidth || toPixel(bx) < 0))
205
+ continue;
199
206
  flags.push({
200
207
  rep: a.key,
201
208
  members: [a.key],
202
- left: toPixel(ax),
209
+ left: plotWidth === undefined ? rawLeft : Math.max(rawLeft, 0),
203
210
  width: labelWidth(a.label),
204
211
  label: a.label,
205
212
  });
206
213
  }
207
214
  }
208
215
  for (const [x, group] of markerGroups) {
216
+ // A culled off-plot marker chip must not hold a lane either.
217
+ const px = toPixel(x);
218
+ if (plotWidth !== undefined && (px < 0 || px > plotWidth))
219
+ continue;
209
220
  const label = group.map((g) => g.label).join(', ');
210
221
  flags.push({
211
222
  rep: group[0].key,
212
223
  members: group.map((g) => g.key),
213
- left: toPixel(x),
224
+ left: px,
214
225
  width: labelWidth(label),
215
226
  label,
216
227
  });
@@ -473,7 +484,7 @@ export function Marker({ at, label, id, selected = false, selectable = true, hov
473
484
  // into a lower lane doesn't leave line poking above it. No label ⇒ full height.
474
485
  const staffTop = text ? FLAG_TOP + lane * LANE_H : 0;
475
486
  return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: container.plotWidth, height: h, style: overlayStyle, children: [_jsx("line", { x1: x, y1: staffTop, x2: x, y2: h, stroke: ann.color, strokeWidth: 1, opacity: opacity, shapeRendering: "crispEdges" }), showHandle && (_jsx(Pill, { cx: x, cy: h / 2, w: HANDLE_SHORT, h: HANDLE_LONG, color: ann.color })), selectable && (_jsx(DragArea, { x: x - HIT_PAD, y: 0, w: 2 * HIT_PAD, h: h, cursor: editing ? 'ew-resize' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragActive: (a) => container.setDragging(a ? selfKey : null), onDrag: (px) => onChange?.(snapToGuides(container, selfKey, px) ??
476
- +container.xScale.invert(px)) }))] }), chipLabel && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
487
+ +container.xScale.invert(px)) }))] }), chipLabel && x >= 0 && x <= container.plotWidth && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
477
488
  top: `${FLAG_TOP + lane * LANE_H}px`,
478
489
  ...flagChipX(x, container.plotWidth),
479
490
  }, children: chipLabel }))] }));
@@ -618,9 +629,9 @@ export function Region({ from, to, label, id, selected = false, selectable = tru
618
629
  +container.xScale.invert(px), edgeRef.current ?? to)) }), _jsx(DragArea, { x: xb - EDGE_GRAB / 2, y: 0, w: EDGE_GRAB, h: h, cursor: "ew-resize", editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragActive: (a) => container.setDragging(a ? selfKey : null), onDragStart: () => {
619
630
  edgeRef.current = from; // the fixed pivot = the near edge
620
631
  }, onDrag: (px) => onChange?.(orderRegion(snapToGuides(container, selfKey, px) ??
621
- +container.xScale.invert(px), edgeRef.current ?? from)) })] }))] }))] }), text && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
632
+ +container.xScale.invert(px), edgeRef.current ?? from)) })] }))] }))] }), text && left <= container.plotWidth && left + spanW >= 0 && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
622
633
  top: `${FLAG_TOP + lane * LANE_H}px`,
623
- ...flagChipX(left, container.plotWidth),
634
+ ...flagChipX(Math.max(left, 0), container.plotWidth),
624
635
  }, children: text }))] }));
625
636
  }
626
637
  //# sourceMappingURL=annotations.js.map
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Tick layout for a **derived-unit axis** — a second labeling of the same
3
+ * scale (`<XAxis transform>`): strike relabelled as moneyness, std-moneyness
4
+ * relabelled as BS delta. The transform may be nonlinear, which stretches or
5
+ * compresses the derived unit across the pixel range — so a single uniform
6
+ * step can't work (uniform delta ticks pile up mid-axis and leave the
7
+ * stretched wings empty). Instead: a **pixel-aware multi-resolution fill** —
8
+ * walk nice step sizes (the 1-2-5 ladder) coarsest→finest, admitting each
9
+ * candidate tick wherever it keeps `minPx` of room from every tick already
10
+ * placed. A compressed span ends up with coarse ticks, a stretched span picks
11
+ * up finer ones (the reference look: `0.10`-step deltas mid-axis, `0.45 /
12
+ * 0.49` out in the wings). A linear transform degenerates to ordinary
13
+ * evenly-spaced nice ticks through the same code path.
14
+ */
15
+ /** A derived-unit transform: `to`/`from` are monotonic inverses (either
16
+ * direction — a decreasing transform is fine); they may be nonlinear. */
17
+ export interface AxisTransform {
18
+ /** Axis value → derived unit (e.g. strike → moneyness). */
19
+ to(value: number): number;
20
+ /** Derived unit → axis value (inverse of {@link to}). */
21
+ from(unit: number): number;
22
+ }
23
+ /** One derived tick: its value in the derived unit and its plot pixel. */
24
+ export interface DerivedTick {
25
+ readonly u: number;
26
+ readonly x: number;
27
+ }
28
+ /**
29
+ * Compute the derived-unit ticks: nice values in `transform.to`-space at
30
+ * mixed 1-2-5 step sizes, greedily admitted coarsest-first wherever the
31
+ * mapped pixel keeps `minPx` from every tick already placed (and stays inside
32
+ * `[0, plotWidth]`). Returns ticks sorted by pixel. Pure — unit-testable
33
+ * without a DOM.
34
+ */
35
+ export declare function derivedTicks(transform: AxisTransform, domain: readonly [number, number], toPixel: (value: number) => number, plotWidth: number, minPx: number): DerivedTick[];
36
+ //# sourceMappingURL=derivedTicks.d.ts.map
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Tick layout for a **derived-unit axis** — a second labeling of the same
3
+ * scale (`<XAxis transform>`): strike relabelled as moneyness, std-moneyness
4
+ * relabelled as BS delta. The transform may be nonlinear, which stretches or
5
+ * compresses the derived unit across the pixel range — so a single uniform
6
+ * step can't work (uniform delta ticks pile up mid-axis and leave the
7
+ * stretched wings empty). Instead: a **pixel-aware multi-resolution fill** —
8
+ * walk nice step sizes (the 1-2-5 ladder) coarsest→finest, admitting each
9
+ * candidate tick wherever it keeps `minPx` of room from every tick already
10
+ * placed. A compressed span ends up with coarse ticks, a stretched span picks
11
+ * up finer ones (the reference look: `0.10`-step deltas mid-axis, `0.45 /
12
+ * 0.49` out in the wings). A linear transform degenerates to ordinary
13
+ * evenly-spaced nice ticks through the same code path.
14
+ */
15
+ /** The largest 1-2-5 nice step ≤ `span` (so the coarsest level yields at
16
+ * least one interval across the domain). */
17
+ function firstStep(span) {
18
+ const pow = 10 ** Math.floor(Math.log10(span));
19
+ for (const m of [5, 2, 1]) {
20
+ if (m * pow <= span)
21
+ return m * pow;
22
+ }
23
+ return pow / 2; // span < pow can't happen (pow ≤ span), belt-and-braces
24
+ }
25
+ /** The next step down the 1-2-5 ladder: 5→2→1→0.5→0.2→0.1… */
26
+ function nextFiner(step) {
27
+ const pow = 10 ** Math.floor(Math.log10(step));
28
+ const m = Math.round(step / pow);
29
+ if (m === 5)
30
+ return 2 * pow;
31
+ if (m === 2)
32
+ return pow;
33
+ return pow / 2;
34
+ }
35
+ /** Per-level enumeration cap — a backstop against a pathological transform
36
+ * requesting a step so fine the candidate walk explodes. Generous: the
37
+ * reference delta axis enumerates ~100 candidates at its finest level. */
38
+ const MAX_CANDIDATES = 4000;
39
+ /**
40
+ * Compute the derived-unit ticks: nice values in `transform.to`-space at
41
+ * mixed 1-2-5 step sizes, greedily admitted coarsest-first wherever the
42
+ * mapped pixel keeps `minPx` from every tick already placed (and stays inside
43
+ * `[0, plotWidth]`). Returns ticks sorted by pixel. Pure — unit-testable
44
+ * without a DOM.
45
+ */
46
+ export function derivedTicks(transform, domain, toPixel, plotWidth, minPx) {
47
+ const ua = transform.to(domain[0]);
48
+ const ub = transform.to(domain[1]);
49
+ const u0 = Math.min(ua, ub);
50
+ const u1 = Math.max(ua, ub);
51
+ if (!Number.isFinite(u0) ||
52
+ !Number.isFinite(u1) ||
53
+ u1 <= u0 ||
54
+ !(plotWidth > 0)) {
55
+ return [];
56
+ }
57
+ const kept = [];
58
+ const fits = (x) => x >= 0 && x <= plotWidth && kept.every((k) => Math.abs(k.x - x) >= minPx);
59
+ // More ticks than the plot has room for can never be admitted.
60
+ const maxTicks = Math.ceil(plotWidth / minPx) + 2;
61
+ let step = firstStep(u1 - u0);
62
+ // An empty level does NOT end the walk: under a nonlinear transform a
63
+ // pixel-wide gap can have a tiny u-span (the delta wings — no 0.1-grid
64
+ // value lands in [0.4, 0.4987], but 0.45 on the 0.05 grid does), so finer
65
+ // levels may fill where a coarser one placed nothing. Several *consecutive*
66
+ // empty levels mean the remaining gaps' u-spans are being outrun faster
67
+ // than the ladder descends — give up then (plus the enumeration backstop).
68
+ let emptyLevels = 0;
69
+ for (let level = 0; level < 24 && kept.length < maxTicks; level++) {
70
+ const i0 = Math.ceil(u0 / step - 1e-9);
71
+ const i1 = Math.floor(u1 / step + 1e-9);
72
+ if (i1 - i0 > MAX_CANDIDATES)
73
+ break;
74
+ let added = 0;
75
+ for (let i = i0; i <= i1 && kept.length < maxTicks; i++) {
76
+ // Clean the float (0.3, not 0.30000000000000004) — the raw `u` reaches
77
+ // a caller-supplied format function, so it must be presentable.
78
+ const u = Number((i * step).toPrecision(12));
79
+ const x = toPixel(transform.from(u));
80
+ if (Number.isFinite(x) && fits(x)) {
81
+ kept.push({ u, x });
82
+ added += 1;
83
+ }
84
+ }
85
+ emptyLevels = added === 0 && level > 0 ? emptyLevels + 1 : 0;
86
+ if (emptyLevels >= 3)
87
+ break;
88
+ step = nextFiner(step);
89
+ }
90
+ return kept.sort((a, b) => a.x - b.x);
91
+ }
92
+ //# sourceMappingURL=derivedTicks.js.map
package/dist/index.d.ts CHANGED
@@ -28,6 +28,7 @@ export { YAxis } from './YAxis.js';
28
28
  export type { YAxisProps } from './YAxis.js';
29
29
  export { XAxis } from './XAxis.js';
30
30
  export type { XAxisProps } from './XAxis.js';
31
+ export type { AxisTransform } from './derivedTicks.js';
31
32
  export { TimeAxis } from './TimeAxis.js';
32
33
  export { CategoryAxis } from './CategoryAxis.js';
33
34
  export type { AxisFormat } from './format.js';
@@ -79,11 +79,14 @@ export declare function majorFormatFor(g: TickGranularity): string;
79
79
  * (`Jan 2026` under a `Jan 05` tick reads as noise). */
80
80
  export declare function boundaryFormatFor(g: TickGranularity): string;
81
81
  /**
82
- * Which of `ticks` (at grain `granularity`) carry a boundary label: the first
83
- * tick always (the reader needs context immediately), then every tick whose
84
- * boundary-grain bucket differs from the previous tick's i.e. the first tick
85
- * of each new day / year. Returns the boundary-flagged tick values;
86
- * empty when the grain has no boundary row (year grain).
82
+ * Which of `ticks` (at grain `granularity`) carry a boundary label: every tick
83
+ * whose boundary-grain bucket differs from the previous tick's i.e. a
84
+ * **crossing**, the first tick of a new day / year. The first tick is *not*
85
+ * automatically flagged: the reader's left-edge context is the pinned
86
+ * {@link TradingTimeScale.boundaryContext} label (a property of the domain
87
+ * start, not of any tick — anchoring it to the first tick made it hop
88
+ * tick-to-tick on a live sliding window). Empty when the grain has no
89
+ * boundary row (year grain).
87
90
  */
88
- export declare function boundaryTicks(ticks: readonly number[], granularity: TickGranularity): number[];
91
+ export declare function boundaryTicks(ticks: readonly number[], granularity: TickGranularity, domainStart?: number): number[];
89
92
  //# sourceMappingURL=tickLadder.d.ts.map
@@ -154,13 +154,26 @@ function stepAnchors(provider, opens, domainEnd, stepMs, cap) {
154
154
  export function buildTicks(provider, opens, domainEnd, cap) {
155
155
  const result = (() => {
156
156
  if (opens.length <= cap) {
157
+ // Pick the clock rung from the **live-span estimate**, not the
158
+ // enumerated anchor count. On a live chart the domain slides every
159
+ // frame, and the number of aligned marks inside a sliding window
160
+ // oscillates ±1 with its phase — an exact count sitting at the cap
161
+ // flips the grain back and forth for single frames (the LiveSine
162
+ // flicker). The span is constant while sliding, so the estimate is
163
+ // stable; the enumerated count may then exceed the cap by a tick or
164
+ // two at some phases, which the per-tick pixel budget absorbs.
165
+ const liveSpan = provider.distance(opens[0], domainEnd);
157
166
  for (const { g, step } of SUB_DAY_GRAINS) {
158
- const ticks = stepAnchors(provider, opens, domainEnd, step, cap);
167
+ if (opens.length + Math.floor(liveSpan / step) > cap)
168
+ continue;
169
+ const ticks = stepAnchors(provider, opens, domainEnd, step, cap + 4);
159
170
  // A clock rung must earn its labels: if it adds no intraday anchor
160
171
  // beyond the opens themselves, it's really day grain (a row of
161
- // "09:30"s under every session is a worse day axis, not a clock axis).
162
- if (ticks.length <= cap && ticks.length > opens.length)
172
+ // "09:30"s under every session is a worse day axis, not a clock
173
+ // axis) and every coarser rung would earn even less.
174
+ if (ticks.length > opens.length)
163
175
  return { ticks, granularity: g };
176
+ break;
164
177
  }
165
178
  return { ticks: [...opens], granularity: 'day' };
166
179
  }
@@ -242,21 +255,29 @@ export function boundaryFormatFor(g) {
242
255
  return g === 'day' ? '%b %d' : '%Y';
243
256
  }
244
257
  /**
245
- * Which of `ticks` (at grain `granularity`) carry a boundary label: the first
246
- * tick always (the reader needs context immediately), then every tick whose
247
- * boundary-grain bucket differs from the previous tick's i.e. the first tick
248
- * of each new day / year. Returns the boundary-flagged tick values;
249
- * empty when the grain has no boundary row (year grain).
258
+ * Which of `ticks` (at grain `granularity`) carry a boundary label: every tick
259
+ * whose boundary-grain bucket differs from the previous tick's i.e. a
260
+ * **crossing**, the first tick of a new day / year. The first tick is *not*
261
+ * automatically flagged: the reader's left-edge context is the pinned
262
+ * {@link TradingTimeScale.boundaryContext} label (a property of the domain
263
+ * start, not of any tick — anchoring it to the first tick made it hop
264
+ * tick-to-tick on a live sliding window). Empty when the grain has no
265
+ * boundary row (year grain).
250
266
  */
251
- export function boundaryTicks(ticks, granularity) {
267
+ export function boundaryTicks(ticks, granularity, domainStart) {
252
268
  const bg = boundaryGrainFor(granularity);
253
269
  if (bg === undefined)
254
270
  return [];
255
271
  const out = [];
256
- let prev;
272
+ // Seed with the domain start's bucket when given: a first tick in a
273
+ // different period than the left edge IS a crossing (a 23:55-anchored
274
+ // window whose cramped 23:55 lead was dropped still marks 00:00 as the
275
+ // day turn); a first tick in the same period is not (no tick-hopping
276
+ // context on a live window).
277
+ let prev = domainStart !== undefined ? bucketKey(domainStart, bg) : undefined;
257
278
  for (const t of ticks) {
258
279
  const k = bucketKey(t, bg);
259
- if (prev === undefined || k !== prev)
280
+ if (prev !== undefined && k !== prev)
260
281
  out.push(t);
261
282
  prev = k;
262
283
  }
@@ -74,12 +74,21 @@ export interface TradingTimeScale {
74
74
  /**
75
75
  * The **second-row** (boundary) label for a tick value, or `undefined` for
76
76
  * ticks that don't open a new boundary period. Same grain selection as
77
- * {@link ticks} at the same `count`, so the rows agree: the first tick and
78
- * each tick starting a new day / year (whichever is the next-coarser
79
- * unit the first-row label omits) carry the label; year-grain ticks have no
80
- * second row.
77
+ * {@link ticks} at the same `count`, so the rows agree: each tick starting
78
+ * a new day / year (whichever is the next-coarser unit the first-row label
79
+ * omits) carries the label a **crossing**. The left-edge context (what
80
+ * period the domain starts in) is {@link boundaryContext}, pinned by the
81
+ * axis rather than riding a tick; year-grain ticks have no second row.
81
82
  */
82
83
  tickBoundaries(count?: number): (value: number) => string | undefined;
84
+ /**
85
+ * The boundary-row label for the **domain start** — the reader's left-edge
86
+ * context (`Jan 01` over an intraday axis, the year over a month axis),
87
+ * rendered pinned at the plot's left edge. A property of the domain, not of
88
+ * any tick — so it stays put on a live sliding window instead of hopping
89
+ * from tick to tick. `undefined` when the grain has no boundary row.
90
+ */
91
+ boundaryContext(count?: number): string | undefined;
83
92
  domain(): [number, number];
84
93
  domain(next: readonly [number, number]): TradingTimeScale;
85
94
  range(): [number, number];
@@ -125,11 +125,21 @@ export function scaleTradingTime(provider) {
125
125
  return () => undefined;
126
126
  const fmt = base.tickFormat(count, boundaryFormatFor(bg));
127
127
  const labelled = new Map();
128
- for (const t of boundaryTicks(ticks, granularity)) {
128
+ for (const t of boundaryTicks(ticks, granularity, domain[0])) {
129
129
  labelled.set(t, fmt(new Date(t)));
130
130
  }
131
131
  return (value) => labelled.get(value);
132
132
  };
133
+ scale.boundaryContext = (count = 10) => {
134
+ if (!hasCalendar())
135
+ return undefined;
136
+ const { granularity } = resolved(count);
137
+ const bg = boundaryGrainFor(granularity);
138
+ if (bg === undefined)
139
+ return undefined;
140
+ const fmt = base.tickFormat(count, boundaryFormatFor(bg));
141
+ return fmt(new Date(domain[0]));
142
+ };
133
143
  function domainFn(next) {
134
144
  if (next === undefined)
135
145
  return [domain[0], domain[1]];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pond-ts/charts",
3
- "version": "0.45.0",
3
+ "version": "0.46.0",
4
4
  "private": false,
5
5
  "description": "Canvas-rendered, streaming-first time-series charts for pond-ts",
6
6
  "license": "MIT",
@@ -38,8 +38,8 @@
38
38
  "perf": "PERF_BENCH=1 playwright test perf.spec.ts --workers=1"
39
39
  },
40
40
  "peerDependencies": {
41
- "@pond-ts/react": "^0.45.0",
42
- "pond-ts": "^0.45.0",
41
+ "@pond-ts/react": "^0.46.0",
42
+ "pond-ts": "^0.46.0",
43
43
  "react": "^18.0.0 || ^19.0.0"
44
44
  },
45
45
  "devDependencies": {