@pond-ts/charts 0.44.1 → 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,9 @@ 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.44.1...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
13
+ [0.45.0]: https://github.com/pjm17971/pond-ts/compare/v0.44.1...v0.45.0
12
14
  [0.44.1]: https://github.com/pjm17971/pond-ts/compare/v0.44.0...v0.44.1
13
15
  [0.44.0]: https://github.com/pjm17971/pond-ts/compare/v0.43.0...v0.44.0
14
16
  [0.43.0]: https://github.com/pjm17971/pond-ts/compare/v0.42.0...v0.43.0
@@ -43,6 +45,146 @@ and type-level changes; patch bumps are strictly additive.
43
45
 
44
46
  ## [Unreleased]
45
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
+
96
+ ## [0.45.0] — 2026-07-14
97
+
98
+ ### Added
99
+
100
+ - **charts:** the time axis now walks a **logical tick ladder** — clock/calendar
101
+ units (1s…30s, 1m…30m, 1H…12H, day / week / month / quarter / year), picking
102
+ the finest grain that fits the width-derived cap — and renders **two-tier labels**:
103
+ a first row at the tick grain (`14:00`, `Feb 02`, `Feb`, `2026`) plus a
104
+ **boundary row** carrying the coarser context the first row omits (the date
105
+ under clock ticks, the year under day/week/month ticks — never a unit the
106
+ first row already shows), shown once under
107
+ the first tick of each new period. Hour anchors are generated in **live**
108
+ trading time, so none lands in a collapsed gap, an early close, or a lunch
109
+ break. `TradingTimeScale` gains `tickBoundaries(count)`; a cramped leading
110
+ partial-period anchor (the `"Jun 23Jul 07"` pile-up) is dropped. Systematic
111
+ story matrix under `Charts/TimeAxisTicks`.
112
+ - **financial:** the rest of the first study batch — `rollingStdev`,
113
+ `rollingMin`, `rollingMax`, `rollingPercentile` (linear interpolation),
114
+ `zScore` (rolling), `envelope` (MA ± percent, `maType` sma/ema), and
115
+ `percentChange` (n-bar rate of change). Same shape as the others — `column` /
116
+ `output` (or `prefix`) on every one, bar-count periods, length-preserving
117
+ warm-up — and each has a fluent method (`bars.zScore({ period: 20 })`) and a
118
+ **pandas oracle** case (values cross-validated bar-for-bar). This completes the
119
+ #449 first batch.
120
+ - **financial:** opt-in **fluent studies** via `import '@pond-ts/financial/fluent'`
121
+ — mounts `sma` / `ema` / `bollinger` as chainable `TimeSeries` methods so
122
+ composition reads like the core operators beside it:
123
+ `bars.sma({ period: 20 }).ema({ period: 12 }).bollinger({ period: 20 })`.
124
+ Opt-in by import (the default entry leaves `TimeSeries` untouched, so a
125
+ non-financial series never sees `.sma()`); the methods are exactly the
126
+ standalone functions bound to `this`, fully typed (each appends its column to
127
+ the schema). Same prototype-augmentation pattern core uses for column methods.
128
+ - **financial:** `@pond-ts/financial` gains its first **studies** — `sma`, `ema`,
129
+ and `bollinger` (Bollinger Bands®) — pure functions that append a column (or a
130
+ `${prefix}Middle/Upper/Lower` family) to a bar series. Every study takes a
131
+ `column` source (default `'close'`) and an `output` name, so a study runs over
132
+ any numeric column including another study's output; periods are **bar counts**
133
+ (on core's count window, gap-correct); warm-up rows are `undefined`
134
+ (length-preserving). Plus the `OhlcvColumns` contract + `DEFAULT_OHLCV`. (Core
135
+ `AppendColumn` is now exported so study return types name their appended
136
+ column.)
137
+ - **core:** `smooth(col, 'ema', …)` gains the financial **`span`** rate
138
+ convention (`α = 2/(span+1)`, e.g. `{ span: 12 }` for a 12-period EMA) as an
139
+ alternative to `alpha` (exactly one required), and a length-preserving
140
+ **`minSamples`** warm-up that emits `undefined` for the first `N` present
141
+ values while **keeping the row count** — mirroring `rolling`'s `minSamples`,
142
+ the one warm-up convention studies use so a smoothed line aligns on its
143
+ source's time axis. (The existing `warmup` option is unchanged — it still
144
+ *drops* the head rows; `minSamples` is the length-preserving counterpart.)
145
+ - **core:** `TimeSeries.rolling` accepts a **count-based** window —
146
+ `rolling({ count: N }, mapping, opts?)` reduces the last / next / centered
147
+ `N` _rows_ (bars) by position instead of a time span. Unlike a duration
148
+ window, an N-bar window stays correct across session gaps (weekends,
149
+ overnight) where `N` bars do not equal `N × barSize` of time — the load-
150
+ bearing primitive for financial N-bar studies (SMA-20, Bollinger-20, rolling
151
+ stdev). Honours `alignment` and `minSamples` exactly like a duration window
152
+ (`minSamples: N` gives the conventional first-`N-1`-rows-`undefined` warmup);
153
+ per-row output only (not supported with a sequence). Same amortized-O(1)-per-
154
+ row sweep as the duration path.
155
+ - **charts:** `<LineChart sessionBreaks>` breaks the line at each trading-axis
156
+ discontinuity (a session / day / lunch close→open) instead of connecting the
157
+ prior close straight to the next open across the collapsed gap. For intraday
158
+ data on a `discontinuities` / `calendar` axis this gives the terminal look —
159
+ the line ends at the close and re-starts at the open, so one session's price
160
+ doesn't visually flow into the next. It's a **scale** break (driven by the
161
+ axis's collapsed gaps), orthogonal to `gaps` (a **data** break, a NaN run) —
162
+ set both independently. Default `false`; a no-op on a continuous axis or a
163
+ provider without `boundaries`. New `Charts/TradingTimeAxis/SessionBreaks`
164
+ story (connected vs broken) + `sessionRuns` / `gappingTicks` helpers.
165
+
166
+ ### Changed
167
+
168
+ - **charts:** a **plain continuous time axis** now runs the same ladder as a
169
+ trading-calendar axis (via an internal gap-free identity provider) instead of
170
+ d3's multi-scale default — so a year of daily data ticks cleanly on month
171
+ starts (`Jul Aug … Jun` + the year underneath) rather than mixed
172
+ `"Jun 23" / "Sep" / "Dec"` labels, and its tick count is now width-derived
173
+ like the trading axis's. Month/quarter-grain anchor labels are now bare
174
+ months (`Feb`, was `Feb 02`) with the year on the boundary row;
175
+ `coarsenCalendar`'s finest granularity is renamed `'session'` → `'day'`.
176
+
177
+ ### Fixed
178
+
179
+ - **core:** `TimeSeries.withColumn` now appends an **optional** column
180
+ (`required: false`) rather than a required one. It already accepted
181
+ `(number | undefined)[]`, but marking the column required meant a later
182
+ strict-intake rebuild (`smooth`, any row reconstruction) threw on the gaps —
183
+ so a column with a warm-up (a rolling study fed into an EMA, e.g.
184
+ `ema(sma(bars, …), …)`) crashed. The appended column's type is now
185
+ `number | undefined`, matching `smooth`'s optional append. (`OptionalNumberColumn`
186
+ is exported for the widened return type.)
187
+
46
188
  ## [0.44.1] — 2026-07-13
47
189
 
48
190
  A `@pond-ts/charts` patch: fixes trading-time axis tick density. `pond-ts`,
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
3
- import { scaleLinear, scaleTime } from 'd3-scale';
4
- import { scaleTradingTime, } from './tradingTimeScale.js';
3
+ import { scaleLinear } from 'd3-scale';
4
+ import { identityProvider, scaleTradingTime, } from './tradingTimeScale.js';
5
5
  import { scaleBand } from './bandScale.js';
6
6
  import { Sequence } from 'pond-ts';
7
7
  import { ContainerContext, } from './context.js';
@@ -335,10 +335,11 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
335
335
  // The shared x-side tick count — labels, x gridlines, session dividers, and
336
336
  // `formatTime` all pass this one value, so they derive from the same instants
337
337
  // (the alignment previously held by three hardcoded constants agreeing).
338
- // Trading axis: width-derived, since the trading scale's `count` caps its
339
- // calendar buckets rather than targeting a tick total; floored at 2 so a
340
- // pre-layout zero width still requests a drawable tick set.
341
- const xTickCount = xDiscontinuities !== undefined
338
+ // Time axis (trading or plain both run the logical tick ladder):
339
+ // width-derived, since the ladder's `count` caps its calendar buckets rather
340
+ // than targeting a tick total; floored at 2 so a pre-layout zero width still
341
+ // requests a drawable tick set. Value/category axes keep the d3 target count.
342
+ const xTickCount = resolvedKind === 'time'
342
343
  ? Math.max(2, Math.floor(plotWidth / TRADING_TICK_PX))
343
344
  : TIME_TICK_COUNT;
344
345
  const { xScale, formatTime } = useMemo(() => {
@@ -373,7 +374,15 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
373
374
  formatTime: resolveTimeFormat(s, xTickCount, timeFormat),
374
375
  };
375
376
  }
376
- const s = scaleTime().domain([d0, d1]).range([0, plotWidth]);
377
+ // Plain continuous time axis: the same trading-time scale over the
378
+ // gap-free identity provider, so it runs the same logical tick ladder
379
+ // (month starts over a year, clock-aligned hours over an afternoon) —
380
+ // never d3's mixed multi-scale default. Interactions stay on continuous
381
+ // time math: the frame's `discontinuities` remains undefined, and identity
382
+ // distance/offset are plain subtraction/addition anyway.
383
+ const s = scaleTradingTime(identityProvider())
384
+ .domain([d0, d1])
385
+ .range([0, plotWidth]);
377
386
  return {
378
387
  xScale: s,
379
388
  formatTime: resolveTimeFormat(s, xTickCount, timeFormat),
@@ -461,7 +470,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
461
470
  }, [cursorX, xScale, sources, plotWidth]);
462
471
  // Pack overlapping top-flag labels (markers + regions) into stacked lanes so
463
472
  // close-in-x labels don't collide; chips read their lane back off the frame.
464
- 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]);
465
474
  const frame = useMemo(() => ({
466
475
  timeRange: [d0, d1],
467
476
  width,
@@ -499,6 +508,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
499
508
  onHoverAnnotation,
500
509
  onEditAnnotation,
501
510
  formatTime,
511
+ xFormatCustom: timeFormat !== undefined,
502
512
  xTickCount,
503
513
  registerTrackerSource,
504
514
  unregisterTrackerSource,
@@ -553,6 +563,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
553
563
  onHoverAnnotation,
554
564
  onEditAnnotation,
555
565
  formatTime,
566
+ timeFormat,
556
567
  xTickCount,
557
568
  registerTrackerSource,
558
569
  unregisterTrackerSource,
@@ -49,6 +49,20 @@ export interface LineChartProps<S extends SeriesSchema = SeriesSchema, VS extend
49
49
  * `gap.connectorOpacity`.)
50
50
  */
51
51
  gaps?: GapMode;
52
+ /**
53
+ * Break the line at each **trading-axis discontinuity** (a session / day /
54
+ * lunch close→open) when the container renders on a trading-time axis (a
55
+ * `discontinuities` / `calendar` provider). **Omitted ⇒ `false`**: the line
56
+ * connects the last pre-close point straight to the next open across the
57
+ * collapsed gap (the near-vertical connector). `true` ends the line at the
58
+ * close and re-starts it at the open — the intraday look, where a session's
59
+ * price shouldn't visually flow into the next.
60
+ *
61
+ * This is a **scale** break (driven by the axis's collapsed gaps), orthogonal
62
+ * to {@link gaps} (a **data** break, a NaN run) — set both independently. A
63
+ * no-op on a continuous axis (no provider) or a provider without `boundaries`.
64
+ */
65
+ sessionBreaks?: boolean;
52
66
  /**
53
67
  * @internal Declaration position among the `<Layers>` children, injected by
54
68
  * `Layers` so z-order follows JSX order. Do not set.
@@ -61,5 +75,5 @@ export interface LineChartProps<S extends SeriesSchema = SeriesSchema, VS extend
61
75
  * (scaling against its `axis`), and renders nothing to the DOM — the row draws
62
76
  * it. The line breaks at gaps rather than spanning them.
63
77
  */
64
- export declare function LineChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, as: semantic, axis, curve, gaps, index, }: LineChartProps<S, VS>): null;
78
+ export declare function LineChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, as: semantic, axis, curve, gaps, sessionBreaks, index, }: LineChartProps<S, VS>): null;
65
79
  //# sourceMappingURL=LineChart.d.ts.map
package/dist/LineChart.js CHANGED
@@ -6,13 +6,16 @@ import { resolveCurve } from './curve.js';
6
6
  import { DEFAULT_GAP_MODE, DEFAULT_GAP_CONNECTOR_OPACITY, } from './gaps.js';
7
7
  import { ContainerContext, LayersContext } from './context.js';
8
8
  import { useSlotKey } from './use-slot-key.js';
9
+ /** Stable empty boundary list — so `sessionBreaks={false}` keeps a referentially
10
+ * constant array and the layer entry isn't rebuilt every render. */
11
+ const NO_BREAKS = [];
9
12
  /**
10
13
  * A line draw layer. Reads `column` from `series` into a {@link ChartSeries}
11
14
  * (columnar, gaps as NaN), registers itself into the enclosing {@link Layers}
12
15
  * (scaling against its `axis`), and renders nothing to the DOM — the row draws
13
16
  * it. The line breaks at gaps rather than spanning them.
14
17
  */
15
- export function LineChart({ series, column, as: semantic, axis, curve, gaps = DEFAULT_GAP_MODE, index = 0, }) {
18
+ export function LineChart({ series, column, as: semantic, axis, curve, gaps = DEFAULT_GAP_MODE, sessionBreaks = false, index = 0, }) {
16
19
  const container = useContext(ContainerContext);
17
20
  if (container === null) {
18
21
  throw new Error('<LineChart> must be rendered inside a <ChartContainer>');
@@ -33,6 +36,17 @@ export function LineChart({ series, column, as: semantic, axis, curve, gaps = DE
33
36
  // Faintness of the inferred dashed connectors (dashed / step) — theme-level,
34
37
  // falling back to the shared default so a theme without it still renders faint.
35
38
  const gapConnectorOpacity = container.theme.gap?.connectorOpacity ?? DEFAULT_GAP_CONNECTOR_OPACITY;
39
+ // Trading-axis session breaks: the collapse instants inside this series' span
40
+ // (session/day/lunch opens the axis skips). Data instants, not pixels — so the
41
+ // set is view-independent (pan/zoom reuse it). Only computed when opted in and
42
+ // the container carries a boundary-reporting discontinuity provider.
43
+ const sessionBreakInstants = useMemo(() => {
44
+ const provider = container.discontinuities;
45
+ if (!sessionBreaks || provider?.boundaries === undefined || cs.length < 2) {
46
+ return NO_BREAKS;
47
+ }
48
+ return provider.boundaries(cs.x[0], cs.x[cs.length - 1]);
49
+ }, [sessionBreaks, container.discontinuities, cs]);
36
50
  const entry = useMemo(() => ({
37
51
  layer: {
38
52
  yExtent: () => yExtent(cs),
@@ -67,7 +81,7 @@ export function LineChart({ series, column, as: semantic, axis, curve, gaps = DE
67
81
  ? [{ x: e.begin(), value: v, color: style.color, label }]
68
82
  : [];
69
83
  },
70
- draw: (ctx, xScale, yScale) => drawLine(ctx, cs, xScale, yScale, style, curveFactory, gaps, gapConnectorOpacity),
84
+ draw: (ctx, xScale, yScale) => drawLine(ctx, cs, xScale, yScale, style, curveFactory, gaps, gapConnectorOpacity, sessionBreakInstants),
71
85
  },
72
86
  axisId: axis,
73
87
  index,
@@ -80,6 +94,7 @@ export function LineChart({ series, column, as: semantic, axis, curve, gaps = DE
80
94
  curveFactory,
81
95
  gaps,
82
96
  gapConnectorOpacity,
97
+ sessionBreakInstants,
83
98
  axis,
84
99
  index,
85
100
  ]);
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';
@@ -7,6 +9,12 @@ import { resolveAxisFormat, resolveTimeFormat, } from './format.js';
7
9
  const TICK_STRIP = 22;
8
10
  /** Extra height reserved for an axis `label` line. */
9
11
  const LABEL_STRIP = 16;
12
+ /** Extra height reserved for the boundary (second) label row. */
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;
10
18
  /**
11
19
  * Thin + truncate a **category** axis's labels so a dense axis stays legible: keep
12
20
  * every `stride`-th label (so a kept label has room), and ellipsize one that still
@@ -47,7 +55,7 @@ function thinCategoryLabels(ticks, plotWidth, fontSize) {
47
55
  *
48
56
  * `<TimeAxis>` is the time-flavoured preset (`<XAxis />`).
49
57
  */
50
- 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', } = {}) {
51
59
  const container = useContext(ContainerContext);
52
60
  if (container === null) {
53
61
  throw new Error('<XAxis> must be rendered inside a <ChartContainer>');
@@ -67,19 +75,37 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
67
75
  cursorX <= plotWidth;
68
76
  const cursorColor = theme.cursor ?? theme.axis.label;
69
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;
70
93
  // Tick formatter: an explicit `format` is resolved against the axis kind
71
94
  // (a time specifier through the time scale, a number specifier through the
72
95
  // value scale); otherwise the container's shared formatter — the one the
73
- // cursor readout uses, so a tick and the cursor read identically.
74
- const fmt =
75
- // A category axis labels by name (the container's `formatTime` = the band
76
- // scale's label lookup); a d3 number/time `format` can't name a category, so
77
- // it's ignored here (customize the labels in the `categories` data instead).
78
- format === undefined || xKind === 'category'
79
- ? formatTime
80
- : xKind === 'time'
81
- ? resolveTimeFormat(xScale, xTickCount, format)
82
- : 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);
83
109
  // Marker annotations that opted into an axis indicator (`<Marker indicator>`)
84
110
  // pin their **time** to this shared x-axis — a pill at `at`, in the annotation
85
111
  // colour, reading like a tick. An indicator always shows the axis coordinate
@@ -126,12 +152,62 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
126
152
  markerLanes.set(t.id, lane);
127
153
  }
128
154
  const maxPillLane = Math.max(0, pillLaneEnds.length - 1);
155
+ // The boundary (second) label row — the coarser calendar unit the first-row
156
+ // label omits (the year under day / week / month ticks, the date under
157
+ // clock ticks), placed under the first tick of each new
158
+ // period. Only a ladder-driven time scale supplies it; explicit `ticks`, an
159
+ // explicit axis `format`, and a container-level `timeFormat` all opt out (a
160
+ // custom format owns the whole label, and custom ticks have no grain).
161
+ const boundaryOf = xKind === 'time' &&
162
+ customTicks === undefined &&
163
+ transform === undefined &&
164
+ format === undefined &&
165
+ !container.xFormatCustom &&
166
+ 'tickBoundaries' in xScale
167
+ ? xScale.tickBoundaries(xTickCount)
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
+ };
129
202
  const rawTicks = customTicks
130
203
  ? customTicks.map((t) => ({ x: xScale(t.at), label: t.label }))
131
- : xScale.ticks(xTickCount).map((d) => ({
132
- x: xScale(d),
133
- label: fmt(+d),
134
- }));
204
+ : derived !== null
205
+ ? honestDerived()
206
+ : xScale.ticks(xTickCount).map((d) => ({
207
+ x: xScale(d),
208
+ label: fmt(+d),
209
+ boundary: boundaryOf?.(+d),
210
+ }));
135
211
  // A category axis ticks once per category; thin + truncate its labels when they
136
212
  // crowd (an explicit `customTicks` axis keeps its labels verbatim).
137
213
  const placed = xKind === 'category' && customTicks === undefined && rawTicks.length > 1
@@ -143,7 +219,34 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
143
219
  const pillOffset = align === 'right' ? 2 : 6;
144
220
  // Per-lane vertical step for stacked pills; grow the strip to fit the stack.
145
221
  const PILL_LANE_H = theme.font.size + 6;
222
+ // Any boundary label in view grows the strip by one row (like pill lanes do).
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;
146
248
  const stripHeight = (height ?? TICK_STRIP + (label ? LABEL_STRIP : 0)) +
249
+ (hasBoundary ? BOUNDARY_STRIP : 0) +
147
250
  maxPillLane * PILL_LANE_H;
148
251
  return (_jsxs("div", { style: {
149
252
  position: 'relative',
@@ -151,10 +254,10 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
151
254
  width: `${plotWidth}px`,
152
255
  height: `${stripHeight}px`,
153
256
  // The plot-facing edge carries the rule; a top axis rules its bottom.
154
- [onTop ? 'borderBottom' : 'borderTop']: `1px solid ${theme.axis.grid}`,
257
+ [onTop ? 'borderBottom' : 'borderTop']: `1px solid ${color ?? theme.axis.grid}`,
155
258
  fontFamily: theme.font.family,
156
259
  fontSize: `${theme.font.size}px`,
157
- color: theme.axis.label,
260
+ color: color ?? theme.axis.label,
158
261
  }, children: [placed.map((t, i) => {
159
262
  const isFirst = i === 0;
160
263
  const isLast = i === placed.length - 1;
@@ -178,15 +281,29 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
178
281
  [onTop ? 'bottom' : 'top']: 0,
179
282
  width: '1px',
180
283
  height: `${tickHeight}px`,
181
- background: theme.axis.grid,
284
+ background: color ?? theme.axis.grid,
182
285
  } }), _jsx("div", { style: {
183
286
  position: 'absolute',
184
287
  left: `${labelLeft}px`,
185
288
  [onTop ? 'bottom' : 'top']: `${labelOffset}px`,
186
289
  transform: labelTransform,
187
290
  whiteSpace: 'nowrap',
188
- }, children: t.label })] }, `${t.x}-${i}`));
189
- }), label !== undefined && (_jsx("div", { style: {
291
+ }, children: t.label }), t.boundary !== undefined && (_jsx("div", { "data-boundary-label": true, style: {
292
+ position: 'absolute',
293
+ left: `${labelLeft}px`,
294
+ [onTop ? 'bottom' : 'top']: `${labelOffset + theme.font.size + 3}px`,
295
+ transform: labelTransform,
296
+ whiteSpace: 'nowrap',
297
+ opacity: 0.75,
298
+ }, children: t.boundary }))] }, `${t.x}-${i}`));
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: {
190
307
  position: 'absolute',
191
308
  left: 0,
192
309
  width: '100%',
@@ -194,7 +311,7 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
194
311
  [onTop ? 'top' : 'bottom']: 0,
195
312
  // Themeable axis-title text (shared with the rotated y-axis title).
196
313
  fontSize: `${theme.axis.title?.size ?? theme.font.size + 1}px`,
197
- color: theme.axis.title?.color ?? theme.axis.label,
314
+ color: color ?? theme.axis.title?.color ?? theme.axis.label,
198
315
  opacity: theme.axis.title?.opacity ?? 0.85,
199
316
  whiteSpace: 'nowrap',
200
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: {