@pond-ts/charts 0.60.0 → 0.62.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/API.md CHANGED
@@ -421,6 +421,7 @@ Series shapes (same file): `ChartSeries`, `BandSeries`, `BoxSeries`,
421
421
  | `CandleVariant` / `ColorBy` | OHLC mark shape / colouring strategy | `packages/charts/src/ohlc.ts` |
422
422
  | `AxisFormat` / `CursorFormat` | Tick and cursor-readout formatting (d3 specifier or fn) | `packages/charts/src/format.ts` |
423
423
  | `AxisTransform` | Monotonic `to`/`from` pair for derived-unit x-axis relabeling | `packages/charts/src/derivedTicks.ts` |
424
+ | `AxisMouseEvent` / `AxisMouseHandler` | Axis `onMouseEvent` payload — the mouse event, the axis's `id`, and the value/label under the pointer | `packages/charts/src/axis-events.ts` |
424
425
  | `Orientation` | Bar growth direction | `packages/charts/src/bars.ts` |
425
426
 
426
427
  ---
@@ -520,31 +521,31 @@ Typed dataflow graphs for pipelines whose **shape is data** (runtime-assembled,
520
521
  user-edited, one computation fanned out to several consumers). Chaining stays
521
522
  the default for pipelines known at authoring time — see the package README.
522
523
 
523
- | Group | Exports | Source |
524
- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
525
- | Worker pool (Node) | `HostPool` (`start`, `run`, `close`, `size`, `inFlight`); types `HostPoolOptions`, `PoolSetup`, `PoolSetupConfig`; `toWire` / `fromWire`, types `WireResult`, `WireColumn` — subpath `@pond-ts/process/pool` | `packages/process/src/pool/index.ts` |
526
- | Ports | `Inlet`, `Outlet` (typed fields on `node.in` / `node.out`; `get()`, `peek()`, `version`, `connect`, `disconnect`) | `packages/process/src/port.ts` |
527
- | Nodes | `Node` (`in`, `out`, `dirty`, `error`, `invalidate()`), `defineNode` (reusable multi-output node type), `derive` (single-output, wired inline) | `packages/process/src/node.ts` |
528
- | Port declaration | `port<T>({ equals, defaultValue })`; types `PortSpec`, `PortSpecMap`, `PortValue`, `PortValues` | `packages/process/src/types.ts` |
529
- | Sources | `source<T>()` → `SourceNode` (`set()`), `fromLive(liveSource)` → `LiveSourceNode` (`dispose()`); `GraphSource` (bind contract — looser than core's `LiveSource`, accepts `LiveAggregation`), `SnapshotSource`, `NoInputs` | `packages/process/src/source.ts` |
530
- | Graph view | `Graph` (`Graph.from(...roots)`, `nodes`, `order()`, `edges()`, `toJSON()`); types `GraphEdge`, `GraphJson`, `GraphNodeJson`, `GraphEdgeJson` | `packages/process/src/graph.ts` |
531
- | Range output buffers | `prepareRange(length, keep, prior)` → `RangeOutput` (`values`, `bits`, `set`, `clear`) carrying `[0, keep)` forward as blocks — values **and** validity; `sealRange(out, length)` → `Float64Column`; `validityByteCount`. Reached from an op as `ctx.out[n]` | `packages/process/src/column.ts` |
532
- | Ranged recompute | `graph.setSourceFrom(series, changedFrom)` — declares which row first changed; `graph.recomputes` → `{ ranged, full }`. An op opts in with `OpDef.runRange(ctx)`, receiving `{ from, to, previous, previousView, out }` (type `RangeContext`) — write into `out` and return nothing for the block path alongside the usual context. Requires `lookback`. Falls back to a full `run` whenever anything is missing | `packages/process/src/plan/graph.ts` |
533
- | Node budget | `bind(series, { registry, budgetBytes })` — engine-wide cap on retained node values, LRU, enforced after each `run`; `graph.retainedBytes` / `graph.evictions` / `graph.enforceBudget()`. Unbounded when omitted. Skips a node whose consumer still holds its outlet | `packages/process/src/plan/graph.ts` |
534
- | Plan history | `requiredHistory(registry, plan)` → `{ known, rows?, undeclared, byOp }` — the minimum safe tail in rows, folded from per-op `OpDef.lookback`. Sums along nesting, maxes across siblings. `known: false` names ops with no declared lookback rather than defaulting to zero (type `HistoryResult`) | `packages/process/src/plan/history.ts` |
535
- | Column values | `packColumn` (values → packed `Float64Column`, NaN = missing), `columnBytes` (retained size, for a byte budget), `appendColumn` (column → series; boxing-free when gapless), `columnBuffers` / `columnFromBuffers` (the buffer pair a column is, for an isolate boundary; type `ColumnBuffers`), `columnView` (zero-copy borrowed read view for in-process folds; type `ColumnView`) | `packages/process/src/column.ts` |
536
- | Plan — registry | `createRegistry({ folds })` / `Registry` (`define`, `get`, `foldFor`, `outputsOf`, `resolveParams`, `byFamily`, `describe`, `toJsonSchema`), param builders `int` / `num` / `choice` / `flag`, `UnknownOpError`, `ParamError` | `packages/process/src/plan/registry.ts`, `params.ts` |
537
- | Plan — identity | `specId` (content-addressed, param-order invariant, defaults materialized), `refToId`, `explain`, `unitOf`, `columnsOf`, `dependsOn`, `outputKey` | `packages/process/src/plan/identity.ts` |
538
- | Plan — types | `Spec`, `Plan`, `Input` (column name \| `Spec` \| `PickedOutput`), `SpecRef`, `Def` (`OpDef` \| `FoldDef`), `OpContext`, `OpResult`, `FoldContext`, `FactBody`, `isFold`, `ParamDef`, `Params`, `Units`, `InputDef`, `OutputDef` | `packages/process/src/plan/types.ts` |
539
- | Plan — bind / run | `bind(series, { registry, units })` → `BoundGraph` (`compile`, `setSource`, `ids`, `series`, `columnOf`), `run(graph, { plan, select, onError })` → `RunResult`, `UnitError` | `packages/process/src/plan/graph.ts`, `run.ts` |
540
- | Plan — request/response | `RunRequest` (`PlanRequest` \| `SlotRequest`), `RunOptions`, `RunResult`, `Select` (`{ on, output?, name? }` — points at a node; what comes back is what that node produces), `ErrorPolicy`, `Fact` (carries `op`), `OutputInfo`, `Skipped`, `NodeTiming` (`slot`, `pulled`, `cached`, `ms`, `inputs`) | `packages/process/src/plan/run.ts` |
541
- | Plan — host | `createHost({ registry, units, sources })` → `Host` (`add`, `has`, `datasets`, `graphFor`, `run`, `runAsync`), `toWire`, `UnknownDatasetError`; local-string `Envelope` (`PlanEnvelope` \| `SlotEnvelope`), remote-capable `AsyncEnvelope` (`AsyncPlanEnvelope` \| `AsyncSlotEnvelope` \| `Envelope`), `DatasetInfo`, `WireResult` | `packages/process/src/plan/host.ts` |
542
- | Plan — slots | `expandSlots(slots, columns)` → `Map<slot, Spec>` (expands to the nested form, so ids match by construction; `slot#Output` picks one output), `SlotError`; types `SlotDef` (`{ op, params, in }`), `Slots` | `packages/process/src/plan/slots.ts` |
543
- | Plan — builder | `plan(from)` → low-level `PlanBuilder`; `process(registry, from)` → typed fluent `ProcessBuilder` (`column`, op methods, `outputs`), `BuilderError`; types `NodeHandle`, `OutputHandle`, `FluentColumnRef`, `SingleColumnNode`, `MultiColumnNode`, `ColumnSelection`, `FactRef`, `BuiltRequest` | `packages/process/src/plan/builder.ts`, `fluent.ts` |
544
- | Plan — async sources | `defineSource({ name, load })`, `createSourceRegistry()` / `SourceRegistry`, `sourceId`, `UnknownSourceError`; types `SourceRef`, `SourceParams`, `LoadedSource` (value + revision), `SourceLoadContext`, `SourceDef` | `packages/process/src/plan/source.ts` |
545
- | Plan — folds | `STANDARD_FOLDS` and the four it holds — `last`, `extremes`, `percentileRank`, `shape` — pre-registered by `createRegistry()`; each a plain `FoldDef`, so a consumer can `define` over one | `packages/process/src/plan/folds.ts` |
546
- | Errors | `ProcessError` (base), `CycleError`, `UnconnectedInputError`, `MissingOutputError`, `UnsetSourceError` | `packages/process/src/errors.ts` |
547
- | Node type helpers | `NodeSpec`, `NodeFactory`, `InletsFor`, `OutletsFor`, `OutletValue`, `SpecsForOutlets`, `DerivedOutput` | `packages/process/src/node.ts` |
524
+ | Group | Exports | Source |
525
+ | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
526
+ | Worker pool (Node) | `HostPool` (`start`, `run`, `close`, `size`, `inFlight`); types `HostPoolOptions`, `PoolSetup`, `PoolSetupConfig`; `toWire` / `fromWire`, types `WireResult`, `WireColumn` — subpath `@pond-ts/process/pool` | `packages/process/src/pool/index.ts` |
527
+ | Ports | `Inlet`, `Outlet` (typed fields on `node.in` / `node.out`; `get()`, `peek()`, `version`, `connect`, `disconnect`) | `packages/process/src/port.ts` |
528
+ | Nodes | `Node` (`in`, `out`, `dirty`, `error`, `invalidate()`), `defineNode` (reusable multi-output node type), `derive` (single-output, wired inline) | `packages/process/src/node.ts` |
529
+ | Port declaration | `port<T>({ equals, defaultValue })`; types `PortSpec`, `PortSpecMap`, `PortValue`, `PortValues` | `packages/process/src/types.ts` |
530
+ | Sources | `source<T>()` → `SourceNode` (`set()`), `fromLive(liveSource)` → `LiveSourceNode` (`dispose()`); `GraphSource` (bind contract — looser than core's `LiveSource`, accepts `LiveAggregation`), `SnapshotSource`, `NoInputs` | `packages/process/src/source.ts` |
531
+ | Graph view | `Graph` (`Graph.from(...roots)`, `nodes`, `order()`, `edges()`, `toJSON()`); types `GraphEdge`, `GraphJson`, `GraphNodeJson`, `GraphEdgeJson` | `packages/process/src/graph.ts` |
532
+ | Range output buffers | `prepareRange(length, keep, prior)` → `RangeOutput` (`values`, `bits`, `set`, `clear`) carrying `[0, keep)` forward as blocks — values **and** validity; `sealRange(out, length)` → `Float64Column`; `validityByteCount`. Reached from an op as `ctx.out[n]` | `packages/process/src/column.ts` |
533
+ | Ranged recompute | `graph.setSourceFrom(series, changedFrom)` — declares which row first changed; `graph.recomputes` → `{ ranged, full }`. An op opts in with `OpDef.runRange(ctx)`, receiving `{ from, to, previous, previousView, out }` (type `RangeContext`) — write into `out` and return nothing for the block path alongside the usual context. Requires `lookback`. Falls back to a full `run` whenever anything is missing | `packages/process/src/plan/graph.ts` |
534
+ | Node budget | `bind(series, { registry, budgetBytes })` — engine-wide cap on retained node values, LRU, enforced after each `run`; `graph.retainedBytes` / `graph.evictions` / `graph.enforceBudget()`. Unbounded when omitted. Skips a node whose consumer still holds its outlet | `packages/process/src/plan/graph.ts` |
535
+ | Plan history | `requiredHistory(registry, plan)` → `{ known, rows?, undeclared, byOp }` — the minimum safe tail in rows, folded from per-op `OpDef.lookback`. Sums along nesting, maxes across siblings. `known: false` names ops with no declared lookback rather than defaulting to zero (type `HistoryResult`) | `packages/process/src/plan/history.ts` |
536
+ | Column values | `packColumn` (values → packed `Float64Column`, NaN = missing), `columnBytes` (retained size, for a byte budget), `appendColumn` (column → series; boxing-free when gapless), `columnBuffers` / `columnFromBuffers` (the buffer pair a column is, for an isolate boundary; type `ColumnBuffers`), `columnView` (zero-copy borrowed read view for in-process folds; type `ColumnView`) | `packages/process/src/column.ts` |
537
+ | Plan — registry | `createRegistry({ folds })` / `Registry` (`define`, `get`, `foldFor`, `outputsOf`, `resolveParams` (`{ validate: false }` applies defaults and skips every check), `byFamily`, `describe`, `toJsonSchema`), param builders `int` / `num` / `choice` / `flag`, `UnknownOpError`, `ParamError` | `packages/process/src/plan/registry.ts`, `params.ts` |
538
+ | Plan — identity | `specId(registry, spec, { validate })` (content-addressed, param-order invariant, defaults materialized; `validate: false` is **total** — names a spec that would not compile in a separate `p1?:` namespace that cannot collide with a valid id; a valid spec's id is identical either way), `SpecIdOptions`, `refToId`, `explain`, `unitOf`, `columnsOf`, `dependsOn`, `outputKey` | `packages/process/src/plan/identity.ts` |
539
+ | Plan — types | `Spec`, `Plan`, `Input` (column name \| `Spec` \| `PickedOutput`), `SpecRef`, `Def` (`OpDef` \| `FoldDef`), `OpContext`, `OpResult`, `FoldContext`, `FactBody`, `isFold`, `ParamDef`, `Params`, `Units`, `InputDef`, `OutputDef` | `packages/process/src/plan/types.ts` |
540
+ | Plan — bind / run | `bind(series, { registry, units })` → `BoundGraph` (`compile`, `setSource`, `ids`, `series`, `columnOf`), `run(graph, { plan, select, onError })` → `RunResult`, `UnitError`, `UnknownColumnError` (a raw string input naming no column of the bound series, checked over the whole closure and re-checked on the warm path) | `packages/process/src/plan/graph.ts`, `run.ts` |
541
+ | Plan — request/response | `RunRequest` (`PlanRequest` \| `SlotRequest`), `RunOptions`, `RunResult`, `Select` (`{ on, output?, name? }` — points at a node; what comes back is what that node produces), `ErrorPolicy`, `Fact` (carries `op`), `OutputInfo`, `Skipped` (`spec`, `select`, `reason`, `code` — the failure's kind, matching the error class a throw would have carried), `NodeTiming` (`slot`, `pulled`, `cached`, `ms`, `inputs`) | `packages/process/src/plan/run.ts` |
542
+ | Plan — host | `createHost({ registry, units, sources })` → `Host` (`add`, `has`, `datasets`, `graphFor`, `run`, `runAsync`), `toWire`, `UnknownDatasetError`; local-string `Envelope` (`PlanEnvelope` \| `SlotEnvelope`), remote-capable `AsyncEnvelope` (`AsyncPlanEnvelope` \| `AsyncSlotEnvelope` \| `Envelope`), `DatasetInfo`, `WireResult` | `packages/process/src/plan/host.ts` |
543
+ | Plan — slots | `expandSlots(slots, columns)` → `Map<slot, Spec>` (expands to the nested form, so ids match by construction; `slot#Output` picks one output), `SlotError`; types `SlotDef` (`{ op, params, in }`), `Slots` | `packages/process/src/plan/slots.ts` |
544
+ | Plan — builder | `plan(from)` → low-level `PlanBuilder`; `process(registry, from)` → typed fluent `ProcessBuilder` (`column`, op methods, `outputs`), `BuilderError`; types `NodeHandle`, `OutputHandle`, `FluentColumnRef`, `SingleColumnNode`, `MultiColumnNode`, `ColumnSelection`, `FactRef`, `BuiltRequest` | `packages/process/src/plan/builder.ts`, `fluent.ts` |
545
+ | Plan — async sources | `defineSource({ name, load })`, `createSourceRegistry()` / `SourceRegistry`, `sourceId`, `UnknownSourceError`; types `SourceRef`, `SourceParams`, `LoadedSource` (value + revision), `SourceLoadContext`, `SourceDef` | `packages/process/src/plan/source.ts` |
546
+ | Plan — folds | `STANDARD_FOLDS` and the four it holds — `last`, `extremes`, `percentileRank`, `shape` — pre-registered by `createRegistry()`; each a plain `FoldDef`, so a consumer can `define` over one | `packages/process/src/plan/folds.ts` |
547
+ | Errors | `ProcessError` (base; `code` — a stable per-class literal, minification-proof, also surfaced on `Skipped`), `CycleError`, `UnconnectedInputError`, `MissingOutputError`, `UnsetSourceError` | `packages/process/src/errors.ts` |
548
+ | Node type helpers | `NodeSpec`, `NodeFactory`, `InletsFor`, `OutletsFor`, `OutletValue`, `SpecsForOutlets`, `DerivedOutput` | `packages/process/src/node.ts` |
548
549
 
549
550
  Note: this package's `npm test` includes a `test:dts` step that typechecks the
550
551
  **emitted** `dist/*.d.ts` from a consumer's perspective (`test-dts/`,
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.60.0...HEAD
11
+ [Unreleased]: https://github.com/pond-ts/pond/compare/v0.62.0...HEAD
12
+ [0.62.0]: https://github.com/pond-ts/pond/compare/v0.61.0...v0.62.0
13
+ [0.61.0]: https://github.com/pond-ts/pond/compare/v0.60.0...v0.61.0
12
14
  [0.60.0]: https://github.com/pond-ts/pond/compare/v0.59.0...v0.60.0
13
15
  [0.59.0]: https://github.com/pond-ts/pond/compare/v0.58.0...v0.59.0
14
16
  [0.58.0]: https://github.com/pond-ts/pond/compare/v0.57.0...v0.58.0
@@ -63,6 +65,110 @@ include new features and type-level changes; patch bumps are strictly additive.
63
65
 
64
66
  ## [Unreleased]
65
67
 
68
+ ## [0.62.0] — 2026-08-16
69
+
70
+ ### Added
71
+
72
+ - `@pond-ts/process`: **`specId` is total under `{ validate: false }`** — a
73
+ third options argument that names a spec which would not compile
74
+ (`specId(registry, spec, { validate: false })`). Identity used to be coupled
75
+ to validity, so the moments a consumer most needs an id — labelling the chip
76
+ it is skipping, keying "this persisted entry is broken", logging what was
77
+ rejected — were exactly the moments it threw, leaving the consumer to
78
+ re-implement canonicalization or carry a second key. Lenient mode still
79
+ applies defaults and sorts keys, carries an undeclared param through rather
80
+ than dropping it, and recurses into nested inputs; **a valid spec has the
81
+ same id under either mode**, so nothing needs a second cache line. Validity
82
+ stays `compile`'s job. `Registry.resolveParams` takes the same
83
+ `{ validate: false }`.
84
+
85
+ An id that did **not** validate is minted in a separate namespace — marked
86
+ `p1?:` instead of `p1:`, with type-preserving param encoding — so it can
87
+ never collide with a legal id. Both measures are confined to that branch:
88
+ a valid id is byte-identical to what shipped in 0.61.0. The mark rides up a
89
+ chain, so a spec over an unvalidated input is unvalidated too.
90
+
91
+ - `@pond-ts/process`: **`Skipped.code`** — every entry in `RunResult.skipped`
92
+ now carries the failure's kind (`'UnknownColumnError'`, `'ParamError'`,
93
+ `'UnitError'`, `'SlotError'`, …) beside its human `reason`. Under
94
+ `onError: 'skip' | 'collect'` nothing is thrown, so `instanceof` — the right
95
+ discriminator when a consumer catches — never reached a consumer reading
96
+ `skipped`, leaving it to match on prose whose wording is not a contract. The
97
+ value is `ProcessError.code`, a **literal declared per class** rather than
98
+ `constructor.name`, so a consumer's minifier cannot silently rename it. It is
99
+ absent when the throw did not come from this package, which is itself the
100
+ signal: op code failed, not the plan layer.
101
+
102
+ ### Fixed
103
+
104
+ - `@pond-ts/process`: **`run` under `onError: 'throw'` — the default — now
105
+ raises the original error rather than a base `ProcessError` rebuilt from its
106
+ message.** A caught `UnknownColumnError`, `ParamError`, `UnitError` or
107
+ `SlotError` reached the caller as a bare `ProcessError`, so `instanceof`
108
+ could not discriminate on the throw path at all.
109
+
110
+ - `@pond-ts/process`: **a raw string input naming a column the bound series
111
+ does not carry is now rejected**, at `compile`, with a new
112
+ `UnknownColumnError`. Nothing checked it at compile or at pull: the op ran
113
+ against an un-widened series, and one that doesn't defend its own inputs
114
+ appended a plausible-looking column of garbage under the spec's id — with
115
+ `skipped` empty and `onError` never engaged. A persisted plan citing a column
116
+ the feed has since dropped now skips (or throws) instead of returning a
117
+ value. The check is the one `expandSlots` already made against the same
118
+ column list, so the two request forms no longer disagree; it runs before the
119
+ unit check, whose "is 'unitless'" answer for an absent column named the wrong
120
+ problem. The key/time column is not a value column and is rejected too.
121
+
122
+ The check covers the **whole spec closure** (a missing column under a typed
123
+ parent otherwise surfaced as `UnitError`) and runs on the **warm** path as
124
+ well as the cold one — `setSource` replaces the data under compiled nodes by
125
+ design, so a memoized node could outlive the column it reads and go on
126
+ emitting the garbage column this fix exists to prevent. A node that fails the
127
+ re-check is dropped from the graph.
128
+ (Both items reported by Tidal —
129
+ `docs/notes/tidal-process-adoption-friction-2026-08.md`.)
130
+
131
+ ## [0.61.0] — 2026-08-16
132
+
133
+ ### Added
134
+
135
+ - `@pond-ts/charts`: **`onMouseEvent` on `<XAxis>` and `<YAxis>`** — mouse
136
+ events on an axis strip, carrying the **axis value under the pointer** (the
137
+ part a consumer can't compute, since the scale lives inside the container).
138
+ One handler takes every mouse event on the strip (`click`, `dblclick`,
139
+ `contextmenu`, `mousedown`/`mouseup`, `mousemove`, `mouseenter`/`mouseleave`)
140
+ — switch on `event.type`. The payload (`AxisMouseEvent`, exported) carries
141
+ the raw React event, `axis: 'x' | 'y'`, the axis's `id` (a `<YAxis>` has one;
142
+ an `<XAxis>` does not), the inverted `value`, and the `label` that axis would
143
+ print there — the **category name** on a category axis, whose scale inverts
144
+ to the nearest band centre. Nothing is attached when the prop is omitted, so
145
+ an axis that doesn't opt in pays nothing for the move events. Axis strips now
146
+ also carry `data-axis="x"` / `data-axis="y"` + `data-axis-id` hooks, so a
147
+ consumer can style one (`[data-axis='x'] { cursor: pointer }`) despite the
148
+ axes taking no `className`.
149
+
150
+ ### Fixed
151
+
152
+ - `@pond-ts/charts`: the category x-axis label fit now **measures** rendered
153
+ label widths (offscreen-canvas `measureText` in the axis font, with a
154
+ per-glyph estimate fallback for SSR/test DOMs) instead of estimating by
155
+ character count — labels wider than their band (e.g. `SYMBOL-VENUE-TYPE`
156
+ keys) no longer overprint into a smear. The fit measures against the scale's
157
+ real band pitch (so a `maxBandWidth`-packed axis thins correctly), keeps a
158
+ minimum clear gap between drawn labels, truncates from the **middle**
159
+ (`EDGE01…EQT`) so a shared prefix or a shared tail stays distinguishable,
160
+ and draws **no** labels at a degenerate (collapsed / pre-layout) width
161
+ rather than overprinting every label at x ≈ 0. Filed from the SPARC
162
+ migration ([PND-CATFIT]).
163
+
164
+ **Heads-up on a deliberate visual change:** an axis whose labels previously
165
+ packed edge-to-edge at a borderline pitch (label within a couple of px of
166
+ its band) now **thins to every 2nd label** instead — clear separation
167
+ outranks per-band labelling at the margin. On a dense dashboard axis this
168
+ can read as "half the labels disappeared"; it is the fit working as
169
+ intended, not data loss — every band still draws its mark, and the cursor
170
+ readout still names every category.
171
+
66
172
  ## [0.60.0] — 2026-08-13
67
173
 
68
174
  ### Added
@@ -7,10 +7,14 @@ import { type XAxisProps } from './XAxis.js';
7
7
  * formatter). Kept as the familiar name for categorical charts, mirroring
8
8
  * {@link TimeAxis}. Forwards every {@link XAxisProps} (`label`, `side`, …).
9
9
  *
10
- * A high-cardinality axis (many categories) thins + truncates its labels to stay
11
- * legible (categorical-axis RFC, Phase 1). The labels **come from the data** (the
12
- * `categories` list), so a d3 `format` prop does not apply here (it can't name a
13
- * category); customize a label by changing the `categories` datum's `label`.
10
+ * A crowded axis thins + truncates its labels to stay legible — triggered by
11
+ * **measured geometry** (rendered label width vs. band pitch), not category
12
+ * count, so few-but-wide labels fit as reliably as many short ones
13
+ * ([PND-CATFIT]; categorical-axis RFC, Phase 1). Truncation is from the
14
+ * middle (`EDGE01…EQT`), keeping both the prefix and the distinguishing tail.
15
+ * The labels **come from the data** (the `categories` list), so a d3 `format`
16
+ * prop does not apply here (it can't name a category); customize a label by
17
+ * changing the `categories` datum's `label`.
14
18
  */
15
19
  export declare function CategoryAxis(props?: XAxisProps): import("react/jsx-runtime").JSX.Element;
16
20
  //# sourceMappingURL=CategoryAxis.d.ts.map
@@ -8,10 +8,14 @@ import { XAxis } from './XAxis.js';
8
8
  * formatter). Kept as the familiar name for categorical charts, mirroring
9
9
  * {@link TimeAxis}. Forwards every {@link XAxisProps} (`label`, `side`, …).
10
10
  *
11
- * A high-cardinality axis (many categories) thins + truncates its labels to stay
12
- * legible (categorical-axis RFC, Phase 1). The labels **come from the data** (the
13
- * `categories` list), so a d3 `format` prop does not apply here (it can't name a
14
- * category); customize a label by changing the `categories` datum's `label`.
11
+ * A crowded axis thins + truncates its labels to stay legible — triggered by
12
+ * **measured geometry** (rendered label width vs. band pitch), not category
13
+ * count, so few-but-wide labels fit as reliably as many short ones
14
+ * ([PND-CATFIT]; categorical-axis RFC, Phase 1). Truncation is from the
15
+ * middle (`EDGE01…EQT`), keeping both the prefix and the distinguishing tail.
16
+ * The labels **come from the data** (the `categories` list), so a d3 `format`
17
+ * prop does not apply here (it can't name a category); customize a label by
18
+ * changing the `categories` datum's `label`.
15
19
  */
16
20
  export function CategoryAxis(props = {}) {
17
21
  return _jsx(XAxis, { ...props });
package/dist/XAxis.d.ts CHANGED
@@ -1,5 +1,30 @@
1
1
  import { type AxisTransform } from './derivedTicks.js';
2
2
  import { type AxisFormat } from './format.js';
3
+ import { type AxisMouseHandler } from './axis-events.js';
4
+ /** One placed tick — its plot-pixel x, the text to draw, and (stacked band
5
+ * style) whether it sits on a band turn and renders emphasized. */
6
+ interface PlacedTick {
7
+ readonly x: number;
8
+ readonly label: string;
9
+ /** This tick opens a coarser calendar period (a day/month/year turn), so it
10
+ * renders emphasized (bold): a band turn in stacked, an inline promotion in
11
+ * flat — the same boundaries in both styles. */
12
+ readonly bold?: boolean;
13
+ }
14
+ /**
15
+ * Thin + truncate a **category** axis's labels so a dense axis stays legible
16
+ * — triggered by **measured geometry**, not category count ([PND-CATFIT]):
17
+ * keep every `stride`-th label, and middle-ellipsize a kept one that still
18
+ * overruns its room. `stride` is the fewest slots the widest label needs once
19
+ * it may ellipsize to {@link TRUNC_KEEP} of itself, so short label sets keep
20
+ * every full label and long ones trade thinning against truncation instead of
21
+ * overprinting. `slot` is the **real band pitch** (`bandwidth()`), which a
22
+ * `maxBandWidth`-packed axis makes narrower than `plotWidth / n`. Rotation is
23
+ * a later option.
24
+ *
25
+ * Exported for tests only — not re-exported from the package index.
26
+ */
27
+ export declare function thinCategoryLabels(ticks: readonly PlacedTick[], slot: number, plotWidth: number, fontSize: number, fontFamily: string): PlacedTick[];
3
28
  export interface XAxisProps {
4
29
  /**
5
30
  * Tick / cursor value formatting — a d3 format/time specifier string or a
@@ -59,8 +84,10 @@ export interface XAxisProps {
59
84
  * - `'auto'` — centred, but the first label left-anchors and the last
60
85
  * right-anchors so the edge labels stay inside the plot (the old default).
61
86
  * - `'right'` — the label sits to the **right** of an extended tick that
62
- * drops from the axis line (label beside the tick, not under it) — useful
63
- * for dense or wide labels that would collide when centred.
87
+ * drops from the axis line (label beside the tick, not under it) — a
88
+ * *style* choice (the TradingView look). It re-anchors without measuring,
89
+ * so it is **not** a remedy for colliding labels; on a category axis the
90
+ * measured fit (thin + middle-ellipsize) is what prevents collisions.
64
91
  */
65
92
  align?: 'auto' | 'center' | 'right';
66
93
  /**
@@ -80,6 +107,22 @@ export interface XAxisProps {
80
107
  * on each turn is emphasized and joins its divider as one boundary line.
81
108
  */
82
109
  dateStyle?: 'flat' | 'stacked';
110
+ /**
111
+ * Mouse events on the axis strip, with the **axis value under the pointer**
112
+ * ({@link AxisMouseHandler}, whose `AxisMouseEvent` payload carries it) — a click on a time axis reports the instant it
113
+ * landed on, a click on a category axis reports the category. The lever for
114
+ * axis-driven UI: pick a date by clicking its tick, open a menu on the strip
115
+ * (`event.type === 'contextmenu'`), drill into a category.
116
+ *
117
+ * **One handler takes every mouse event** — click, double-click, context
118
+ * menu, down/up, move, enter, leave — so switch on `event.type`. Nothing is
119
+ * attached when the prop is omitted, so the move events cost nothing unless
120
+ * you ask for them.
121
+ *
122
+ * The x strip has no `id` (only a `<YAxis>` does); to distinguish stacked
123
+ * axes, close over it: `onMouseEvent={(e) => onAxis('delta', e)}`.
124
+ */
125
+ onMouseEvent?: AxisMouseHandler;
83
126
  }
84
127
  /**
85
128
  * The shared **x axis**, a sibling of {@link YAxis} for the horizontal axis. A
@@ -91,5 +134,6 @@ export interface XAxisProps {
91
134
  *
92
135
  * `<TimeAxis>` is the time-flavoured preset (`<XAxis />`).
93
136
  */
94
- export declare function XAxis({ format, label, side, height, ticks: customTicks, transform, color, align, dateStyle, }?: XAxisProps): import("react/jsx-runtime").JSX.Element;
137
+ export declare function XAxis({ format, label, side, height, ticks: customTicks, transform, color, align, dateStyle, onMouseEvent, }?: XAxisProps): import("react/jsx-runtime").JSX.Element;
138
+ export {};
95
139
  //# sourceMappingURL=XAxis.d.ts.map
package/dist/XAxis.js CHANGED
@@ -7,6 +7,7 @@ import { tickValues } from './yticks.js';
7
7
  import { xAxisCursorEntries } from './cursors.js';
8
8
  import { axisPillStyle } from './chip.js';
9
9
  import { resolveAxisFormat, resolveTimeFormat, } from './format.js';
10
+ import { axisMouseProps, axisPointerPx, } from './axis-events.js';
10
11
  /** Tick strip height (mark + value label) in CSS px. */
11
12
  const TICK_STRIP = 22;
12
13
  /** Extra height reserved for an axis `label` line. */
@@ -18,32 +19,132 @@ const BAND_STRIP = 20;
18
19
  * ladder's per-tick budget (a hair tighter: derived labels are short). */
19
20
  const TRANSFORM_TICK_PX = 48;
20
21
  /**
21
- * Thin + truncate a **category** axis's labels so a dense axis stays legible: keep
22
- * every `stride`-th label (so a kept label has room), and ellipsize one that still
23
- * overruns its space. `stride` grows with the longest label vs the per-category
24
- * slot width, so a few short categories keep every full label and many long ones
25
- * decimate. A rough `fontSize`-based width estimate (no DOM measure) — good enough
26
- * for placement; the exact metric is the browser's. Rotation is a later option.
22
+ * Measure a category label's rendered width in the axis font. A shared
23
+ * offscreen canvas gives the browser's own metric the thing the old
24
+ * per-character estimate could only approximate, and approximated low on
25
+ * exactly the labels category axes carry (all-caps keys with digits and
26
+ * hyphens), which let "fits by the estimate" labels overprint on screen
27
+ * ([PND-CATFIT]). Falls back to the estimate where no canvas backend exists
28
+ * (SSR, test DOMs). Results are cached per font+text; a webfont that loads
29
+ * after first measure keeps its fallback-font metric until the cache turns
30
+ * over, which the fit's inter-label gap absorbs.
27
31
  */
28
- function thinCategoryLabels(ticks, plotWidth, fontSize) {
32
+ const measureCache = new Map();
33
+ // Module state: the first render's canvas context is kept for the process
34
+ // lifetime. In tests this captures whatever canvas stub is installed at first
35
+ // measure — harmless while stubs measure 0 (the estimate fallback takes over
36
+ // per call), but a future *nonzero* canvas mock would need a reset hook here.
37
+ let measureCtx;
38
+ function labelWidth(text, font, fontSize) {
39
+ const key = `${font}|${text}`;
40
+ const hit = measureCache.get(key);
41
+ if (hit !== undefined)
42
+ return hit;
43
+ if (measureCtx === undefined) {
44
+ try {
45
+ measureCtx =
46
+ typeof document === 'undefined'
47
+ ? null
48
+ : (document.createElement('canvas').getContext('2d') ?? null);
49
+ }
50
+ catch {
51
+ measureCtx = null; // a DOM shim whose getContext throws → estimate path
52
+ }
53
+ }
54
+ let w = 0;
55
+ if (measureCtx !== null) {
56
+ measureCtx.font = font;
57
+ w = measureCtx.measureText(text).width;
58
+ }
59
+ // No backend, or a mock that measures everything at 0 → per-glyph estimate.
60
+ if (!(w > 0))
61
+ w = text.length * fontSize * 0.62;
62
+ if (measureCache.size > 4096)
63
+ measureCache.clear();
64
+ measureCache.set(key, w);
65
+ return w;
66
+ }
67
+ /** Minimum clear space between two neighbouring drawn labels, px. */
68
+ const LABEL_GAP = 4;
69
+ /**
70
+ * A kept label may ellipsize down to this fraction of its full width before
71
+ * the fit prefers dropping labels (growing `stride`) instead — below it, the
72
+ * text no longer identifies its category.
73
+ */
74
+ const TRUNC_KEEP = 0.6;
75
+ /**
76
+ * Ellipsize `text` from the **middle** to fit `room` px: category keys often
77
+ * share a prefix and differ in the tail (or the reverse), so keeping both ends
78
+ * preserves whichever part distinguishes — end-truncation makes shared-prefix
79
+ * keys visually identical. Head-heavy split (60/40). Binary search on the kept
80
+ * **code-point** count (a UTF-16 `slice` could split a surrogate pair and
81
+ * emit mojibake); the result is only accepted when it *measures* within
82
+ * `room`, so the returned label can never overrun its space.
83
+ */
84
+ function ellipsizeMiddle(text, room, font, fontSize) {
85
+ const cp = Array.from(text); // code points, not UTF-16 units
86
+ let lo = 1;
87
+ let hi = cp.length - 1;
88
+ let best = '…';
89
+ while (lo <= hi) {
90
+ const k = (lo + hi) >> 1;
91
+ const head = Math.ceil(k * 0.6);
92
+ const tail = k - head;
93
+ const s = cp.slice(0, head).join('') +
94
+ '…' +
95
+ (tail > 0 ? cp.slice(cp.length - tail).join('') : '');
96
+ if (labelWidth(s, font, fontSize) <= room) {
97
+ best = s;
98
+ lo = k + 1;
99
+ }
100
+ else {
101
+ hi = k - 1;
102
+ }
103
+ }
104
+ return best;
105
+ }
106
+ /**
107
+ * Thin + truncate a **category** axis's labels so a dense axis stays legible
108
+ * — triggered by **measured geometry**, not category count ([PND-CATFIT]):
109
+ * keep every `stride`-th label, and middle-ellipsize a kept one that still
110
+ * overruns its room. `stride` is the fewest slots the widest label needs once
111
+ * it may ellipsize to {@link TRUNC_KEEP} of itself, so short label sets keep
112
+ * every full label and long ones trade thinning against truncation instead of
113
+ * overprinting. `slot` is the **real band pitch** (`bandwidth()`), which a
114
+ * `maxBandWidth`-packed axis makes narrower than `plotWidth / n`. Rotation is
115
+ * a later option.
116
+ *
117
+ * Exported for tests only — not re-exported from the package index.
118
+ */
119
+ export function thinCategoryLabels(ticks, slot, plotWidth, fontSize, fontFamily) {
29
120
  const n = ticks.length;
30
- const slot = plotWidth / n; // per-category width in px
31
- // Before first layout `plotWidth` is 0 `slot` is 0 and the stride/room math
32
- // below goes to Infinity/NaN. Nothing is visible at zero width anyway, so pass
33
- // the ticks through untouched until a real width arrives.
34
- if (!(slot > 0))
35
- return [...ticks];
36
- const charW = fontSize * 0.62; // ~average glyph advance
37
- const longest = Math.min(12, ticks.reduce((m, t) => Math.max(m, t.label.length), 1));
38
- const stride = Math.max(1, Math.ceil((longest * charW) / slot));
39
- const room = Math.max(1, Math.floor((slot * stride) / charW));
121
+ // Degenerate / pre-layout width: nothing can be legible, so draw NO labels.
122
+ // The old passthrough here was the collapsed-panel smear: these are
123
+ // absolutely-positioned `nowrap` divs, so at width 0 every label rendered
124
+ // full-length at x 0, overflowing the strip and overprinting.
125
+ if (!(slot > 0) || !(plotWidth > 0))
126
+ return [];
127
+ const font = `${fontSize}px ${fontFamily}`;
128
+ const widths = ticks.map((t) => labelWidth(t.label, font, fontSize));
129
+ const maxW = widths.reduce((m, w) => Math.max(m, w), 0);
130
+ // The width a kept label must be allowed: its full measure when that's
131
+ // modest, else the legibility floor — TRUNC_KEEP of it, but never less than
132
+ // ~two glyphs of text.
133
+ const required = Math.min(maxW, Math.max(maxW * TRUNC_KEEP, fontSize * 2));
134
+ const stride = Math.max(1, Math.ceil((required + LABEL_GAP) / slot));
135
+ const room = Math.min(slot * stride - LABEL_GAP, plotWidth - LABEL_GAP);
136
+ // Not even ~two glyphs fit (a collapsed strip) — an empty axis over a smear.
137
+ if (!(room >= fontSize * 2))
138
+ return [];
40
139
  const out = [];
41
140
  for (let i = 0; i < n; i += stride) {
42
- const s = ticks[i].label;
43
- out.push({
44
- x: ticks[i].x,
45
- label: s.length <= room ? s : `${s.slice(0, Math.max(1, room - 1))}…`,
46
- });
141
+ const t = ticks[i];
142
+ const label = widths[i] <= room
143
+ ? t.label
144
+ : ellipsizeMiddle(t.label, room, font, fontSize);
145
+ // A bare ellipsis identifies nothing — leave that tick unlabeled.
146
+ if (label !== '…')
147
+ out.push({ x: t.x, label });
47
148
  }
48
149
  return out;
49
150
  }
@@ -57,7 +158,7 @@ function thinCategoryLabels(ticks, plotWidth, fontSize) {
57
158
  *
58
159
  * `<TimeAxis>` is the time-flavoured preset (`<XAxis />`).
59
160
  */
60
- export function XAxis({ format, label, side = 'bottom', height, ticks: customTicks, transform, color, align = 'center', dateStyle = 'flat', } = {}) {
161
+ export function XAxis({ format, label, side = 'bottom', height, ticks: customTicks, transform, color, align = 'center', dateStyle = 'flat', onMouseEvent, } = {}) {
61
162
  const container = useContext(ContainerContext);
62
163
  if (container === null) {
63
164
  throw new Error('<XAxis> must be rendered inside a <ChartContainer>');
@@ -281,9 +382,14 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
281
382
  flatFmt(+d) !== baseFmt(+d),
282
383
  }));
283
384
  // A category axis ticks once per category; thin + truncate its labels when they
284
- // crowd (an explicit `customTicks` axis keeps its labels verbatim).
285
- const placed = xKind === 'category' && customTicks === undefined && rawTicks.length > 1
286
- ? thinCategoryLabels(rawTicks, plotWidth, theme.font.size)
385
+ // crowd (an explicit `customTicks` axis keeps its labels verbatim). The slot is
386
+ // the scale's own band pitch under `maxBandWidth` packing it is narrower than
387
+ // `plotWidth / n`, and the fit must measure against the pitch labels actually
388
+ // sit on.
389
+ const placed = xKind === 'category' && customTicks === undefined && rawTicks.length > 0
390
+ ? thinCategoryLabels(rawTicks, 'bandwidth' in xScale
391
+ ? xScale.bandwidth()
392
+ : plotWidth / rawTicks.length, plotWidth, theme.font.size, theme.font.family)
287
393
  : rawTicks;
288
394
  const onTop = side === 'top';
289
395
  // Axis pills (marker / crosshair) sit at the same offset as the tick labels so
@@ -305,7 +411,20 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
305
411
  const stripHeight = (height ?? TICK_STRIP + (label ? LABEL_STRIP : 0)) +
306
412
  (hasBands ? BAND_STRIP : 0) +
307
413
  maxPillLane * PILL_LANE_H;
308
- return (_jsxs("div", { style: {
414
+ // The axis coordinate under the pointer, for `onMouseEvent`. The strip is
415
+ // laid out flush with the plot (the left gutter is its margin, its width is
416
+ // `plotWidth`), so a strip-local pixel inverts straight through the shared x
417
+ // scale — no gutter arithmetic. The label reads the same channel a cursor
418
+ // pill does: the band scale's category name on a category axis (a d3 number
419
+ // format can't name one), this axis's readout format everywhere else.
420
+ const mouse = axisMouseProps(onMouseEvent, 'x', undefined, (event) => {
421
+ const value = +xScale.invert(axisPointerPx(event, 'x', [0, plotWidth]));
422
+ return {
423
+ value,
424
+ label: xKind === 'category' ? fmt(value) : readoutFmt(value),
425
+ };
426
+ });
427
+ return (_jsxs("div", { "data-axis": "x", ...mouse, style: {
309
428
  position: 'relative',
310
429
  marginLeft: `${leftGutter}px`,
311
430
  width: `${plotWidth}px`,
package/dist/YAxis.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type AxisFormat } from './format.js';
2
+ import { type AxisMouseHandler } from './axis-events.js';
2
3
  export interface YAxisProps {
3
4
  /** Identifier a chart links to via its `axis` prop (and the first declared is
4
5
  * the row's default). */
@@ -197,6 +198,19 @@ export interface YAxisProps {
197
198
  * colours. Presentation-only: it never re-registers the axis.
198
199
  */
199
200
  color?: string;
201
+ /**
202
+ * Mouse events on this axis's gutter, with the **axis value under the
203
+ * pointer** ({@link AxisMouseHandler}, whose `AxisMouseEvent` payload carries it) — a click reports the value it landed
204
+ * on, and this axis's `id`, so one handler can serve several axes. The lever
205
+ * for axis-driven UI: set a threshold by clicking the gutter, open a scale
206
+ * menu (`event.type === 'contextmenu'`), drill into a categorical row.
207
+ *
208
+ * **One handler takes every mouse event** — click, double-click, context
209
+ * menu, down/up, move, enter, leave — so switch on `event.type`. Nothing is
210
+ * attached when the prop is omitted, so the move events cost nothing unless
211
+ * you ask for them. A `hide`den axis draws no gutter and so fires nothing.
212
+ */
213
+ onMouseEvent?: AxisMouseHandler;
200
214
  /**
201
215
  * @internal Declaration position among the row's children, injected by
202
216
  * `ChartRow` so the first-declared axis stays the default. Do not set.
@@ -211,5 +225,5 @@ export interface YAxisProps {
211
225
  * tick marks + labels from that scale. Charts attach via `<LineChart axis="id">`
212
226
  * (default: the first axis).
213
227
  */
214
- export declare function YAxis({ id, side, label, scale, linearWindow, min, max, format, ticks, tickCount, pad, boundaryLabels, width, hide, labelPlacement, color, index, }: YAxisProps): import("react/jsx-runtime").JSX.Element | null;
228
+ export declare function YAxis({ id, side, label, scale, linearWindow, min, max, format, ticks, tickCount, pad, boundaryLabels, width, hide, labelPlacement, color, onMouseEvent, index, }: YAxisProps): import("react/jsx-runtime").JSX.Element | null;
215
229
  //# sourceMappingURL=YAxis.d.ts.map
package/dist/YAxis.js CHANGED
@@ -4,6 +4,7 @@ import { ContainerContext, RowContext } from './context.js';
4
4
  import { resolveAxisFormat } from './format.js';
5
5
  import { useSlotKey } from './use-slot-key.js';
6
6
  import { tickValues } from './yticks.js';
7
+ import { axisMouseProps, axisPointerPx, } from './axis-events.js';
7
8
  const DEFAULT_WIDTH = 50;
8
9
  /** Fallback tick count before the row has published its resolved count (the
9
10
  * first render, pre-registration). The row's height-derived value takes over
@@ -17,7 +18,7 @@ const DEFAULT_TICK_COUNT = 5;
17
18
  * tick marks + labels from that scale. Charts attach via `<LineChart axis="id">`
18
19
  * (default: the first axis).
19
20
  */
20
- export function YAxis({ id, side = 'left', label, scale = 'linear', linearWindow, min, max, format, ticks, tickCount, pad = 0, boundaryLabels = true, width = DEFAULT_WIDTH, hide = false, labelPlacement = 'rotated', color, index = 0, }) {
21
+ export function YAxis({ id, side = 'left', label, scale = 'linear', linearWindow, min, max, format, ticks, tickCount, pad = 0, boundaryLabels = true, width = DEFAULT_WIDTH, hide = false, labelPlacement = 'rotated', color, onMouseEvent, index = 0, }) {
21
22
  const container = useContext(ContainerContext);
22
23
  if (container === null) {
23
24
  throw new Error('<YAxis> must be rendered inside a <ChartContainer>');
@@ -124,7 +125,33 @@ export function YAxis({ id, side = 'left', label, scale = 'linear', linearWindow
124
125
  // axes line up column-by-column. Keyed by this instance's slot key (not `id`,
125
126
  // which may repeat across a mirror). Falls back to own width until reserved.
126
127
  const slotWidth = row.axisSlots.get(slot) ?? width;
127
- return (_jsx("div", { style: {
128
+ // The axis value under the pointer, for `onMouseEvent` — read on the **slot**
129
+ // box (below), so the whole reserved gutter answers, not just this axis's own
130
+ // narrower content. The box is exactly the row's height and shares its top
131
+ // edge with the plot, so a box-local pixel inverts straight through this
132
+ // axis's scale. Before the row has published one there is no value to report
133
+ // and the event is dropped. A categorical row labels by slot, matching its
134
+ // ticks; every other row reads this axis's own tick format.
135
+ const mouse = axisMouseProps(onMouseEvent, 'y', id, (event) => {
136
+ if (!yScale)
137
+ return null;
138
+ // Clamp on the **scale's** range, not the box: a row with a
139
+ // `labelPlacement="top"` axis reserves a header, so the range is
140
+ // `[height, topHeader]` while the box still starts at 0 (`ChartRow`).
141
+ const [r0, r1] = yScale.range();
142
+ const value = yScale.invert(axisPointerPx(event, 'y', [r0, r1]));
143
+ return {
144
+ value,
145
+ label: layerCategories !== null
146
+ ? // A slot index, clamped to a real category: the domain's top edge
147
+ // inverts to exactly `n` (and rounding can nudge the bottom below
148
+ // 0), which no category occupies — the nearest one is the honest
149
+ // answer, matching the band scale's own `invert`.
150
+ (layerCategories[Math.min(layerCategories.length - 1, Math.max(0, Math.floor(value)))] ?? '')
151
+ : fmt(value),
152
+ };
153
+ });
154
+ return (_jsx("div", { "data-axis": "y", "data-axis-id": id, ...mouse, style: {
128
155
  flex: `0 0 ${slotWidth}px`,
129
156
  display: 'flex',
130
157
  justifyContent: side === 'left' ? 'flex-end' : 'flex-start',
@@ -0,0 +1,106 @@
1
+ import type { MouseEvent as ReactMouseEvent } from 'react';
2
+ /**
3
+ * What an axis hands its {@link AxisMouseHandler} — the raw mouse event, plus
4
+ * the **axis coordinate under the pointer**, which is the part a consumer
5
+ * cannot compute for itself (the scale lives inside the container).
6
+ */
7
+ export interface AxisMouseEvent {
8
+ /**
9
+ * The React mouse event, verbatim — `type` says which one fired
10
+ * (`'click'`, `'mousemove'`, `'contextmenu'`, …), and the modifier keys,
11
+ * `button`, `preventDefault()` and `stopPropagation()` are all the ordinary
12
+ * ones. **A single handler receives every mouse event on the strip**, so
13
+ * switch on `event.type` (or ignore the ones you don't want).
14
+ */
15
+ event: ReactMouseEvent<HTMLDivElement>;
16
+ /** Which axis fired — so one handler can serve both. */
17
+ axis: 'x' | 'y';
18
+ /**
19
+ * The axis's `id`, when it has one. A `<YAxis>` always does (it's required —
20
+ * charts link to it); an `<XAxis>` has none, so this is `undefined` there.
21
+ * To tell two stacked x-axes apart, close over the distinction at the call
22
+ * site (`onMouseEvent={(e) => onAxis('delta', e)}`).
23
+ */
24
+ id?: string | undefined;
25
+ /**
26
+ * The axis value under the pointer, **in the axis's own data units** — epoch
27
+ * ms on a time axis, the number on a value axis, the slot value on a
28
+ * categorical one. Read {@link label} for what the axis would *print* there.
29
+ *
30
+ * Not clamped to a tick — it is the continuous inverse of the pixel, so it
31
+ * lands between ticks. The one exception is a **categorical x-axis**, whose
32
+ * scale inverts to the nearest band **centre** (`i + 0.5`); a categorical
33
+ * *row* (horizontal bars on the y-axis) is a plain linear slot scale and does
34
+ * not snap, so `Math.floor(value)` is its slot index.
35
+ *
36
+ * On a `transform`ed x-axis this is the **underlying** value, not the derived
37
+ * unit — apply the same `transform.to` you passed the axis to get the unit
38
+ * its ticks read in.
39
+ */
40
+ value: number;
41
+ /**
42
+ * {@link value} formatted the way this axis reads it — the category name on a
43
+ * categorical axis, the axis's `format` (or the container's shared formatter)
44
+ * elsewhere.
45
+ *
46
+ * Precisely: it is the axis's **readout** channel, the one the cursor pill
47
+ * uses — so it always agrees with the pill at that pixel, and a container
48
+ * `cursorFormat` shapes it exactly as it shapes the pill. That is the
49
+ * documented precedence (`cursorFormat` → axis `format` → container), and it
50
+ * is the one case where `label` can read differently from the tick text: a
51
+ * chart with a precise `cursorFormat` over terse ticks gets the precise form
52
+ * here, which is the readout it asked for.
53
+ */
54
+ label: string;
55
+ }
56
+ /**
57
+ * A single handler for every mouse event on an axis strip — see
58
+ * {@link AxisMouseEvent}. Passed as `onMouseEvent` to `<XAxis>` / `<YAxis>`.
59
+ */
60
+ export type AxisMouseHandler = (info: AxisMouseEvent) => void;
61
+ /** The mouse props an axis strip spreads onto its root element. */
62
+ type AxisMouseProps = {
63
+ onClick?: (e: ReactMouseEvent<HTMLDivElement>) => void;
64
+ onDoubleClick?: (e: ReactMouseEvent<HTMLDivElement>) => void;
65
+ onContextMenu?: (e: ReactMouseEvent<HTMLDivElement>) => void;
66
+ onMouseDown?: (e: ReactMouseEvent<HTMLDivElement>) => void;
67
+ onMouseUp?: (e: ReactMouseEvent<HTMLDivElement>) => void;
68
+ onMouseMove?: (e: ReactMouseEvent<HTMLDivElement>) => void;
69
+ onMouseEnter?: (e: ReactMouseEvent<HTMLDivElement>) => void;
70
+ onMouseLeave?: (e: ReactMouseEvent<HTMLDivElement>) => void;
71
+ };
72
+ /**
73
+ * Build the mouse props for an axis strip: every mouse event routed to the one
74
+ * `onMouseEvent` handler, each carrying the axis coordinate `at` resolves from
75
+ * the pointer.
76
+ *
77
+ * With no handler this returns `{}` — **nothing is attached**, so an axis that
78
+ * doesn't opt in keeps costing nothing (no per-move callback, no listeners).
79
+ *
80
+ * `at` returns `null` when the pointer maps to no value — the transient render
81
+ * before a `<YAxis>` has a resolved scale — and the event is then dropped
82
+ * rather than reported at a made-up coordinate.
83
+ */
84
+ export declare function axisMouseProps(onMouseEvent: AxisMouseHandler | undefined, axis: 'x' | 'y', id: string | undefined, at: (event: ReactMouseEvent<HTMLDivElement>) => {
85
+ value: number;
86
+ label: string;
87
+ } | null): AxisMouseProps;
88
+ /**
89
+ * The pointer's position along an axis strip, in **strip-local pixels** — the
90
+ * coordinate the scale inverts. Read from the strip's own client rect
91
+ * (`currentTarget`, so it is the strip whichever tick label was hit), which is
92
+ * laid out flush with the plot on that dimension: the x strip carries the left
93
+ * gutter as a margin and is exactly `plotWidth` wide, and the y gutter is
94
+ * exactly the row's height.
95
+ *
96
+ * Clamped to **the scale's range, not the strip's box** — the two are not
97
+ * always the same. A row carrying a `labelPlacement="top"` axis reserves a
98
+ * header, so its y scales run `[height, topHeader]` while the gutter box still
99
+ * starts at 0; clamping to the box would invert the header band to values
100
+ * *above* the domain and report a coordinate the axis never draws. Passing the
101
+ * range means a `mouseleave` off the edge — or a press in that header — still
102
+ * reports a value the scale actually holds.
103
+ */
104
+ export declare function axisPointerPx(event: ReactMouseEvent<HTMLDivElement>, axis: 'x' | 'y', range: readonly [number, number]): number;
105
+ export {};
106
+ //# sourceMappingURL=axis-events.d.ts.map
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Build the mouse props for an axis strip: every mouse event routed to the one
3
+ * `onMouseEvent` handler, each carrying the axis coordinate `at` resolves from
4
+ * the pointer.
5
+ *
6
+ * With no handler this returns `{}` — **nothing is attached**, so an axis that
7
+ * doesn't opt in keeps costing nothing (no per-move callback, no listeners).
8
+ *
9
+ * `at` returns `null` when the pointer maps to no value — the transient render
10
+ * before a `<YAxis>` has a resolved scale — and the event is then dropped
11
+ * rather than reported at a made-up coordinate.
12
+ */
13
+ export function axisMouseProps(onMouseEvent, axis, id, at) {
14
+ if (onMouseEvent === undefined)
15
+ return {};
16
+ const fire = (event) => {
17
+ const hit = at(event);
18
+ if (hit === null)
19
+ return;
20
+ onMouseEvent({ event, axis, id, value: hit.value, label: hit.label });
21
+ };
22
+ return {
23
+ onClick: fire,
24
+ onDoubleClick: fire,
25
+ onContextMenu: fire,
26
+ onMouseDown: fire,
27
+ onMouseUp: fire,
28
+ onMouseMove: fire,
29
+ onMouseEnter: fire,
30
+ onMouseLeave: fire,
31
+ };
32
+ }
33
+ /**
34
+ * The pointer's position along an axis strip, in **strip-local pixels** — the
35
+ * coordinate the scale inverts. Read from the strip's own client rect
36
+ * (`currentTarget`, so it is the strip whichever tick label was hit), which is
37
+ * laid out flush with the plot on that dimension: the x strip carries the left
38
+ * gutter as a margin and is exactly `plotWidth` wide, and the y gutter is
39
+ * exactly the row's height.
40
+ *
41
+ * Clamped to **the scale's range, not the strip's box** — the two are not
42
+ * always the same. A row carrying a `labelPlacement="top"` axis reserves a
43
+ * header, so its y scales run `[height, topHeader]` while the gutter box still
44
+ * starts at 0; clamping to the box would invert the header band to values
45
+ * *above* the domain and report a coordinate the axis never draws. Passing the
46
+ * range means a `mouseleave` off the edge — or a press in that header — still
47
+ * reports a value the scale actually holds.
48
+ */
49
+ export function axisPointerPx(event, axis, range) {
50
+ const rect = event.currentTarget.getBoundingClientRect();
51
+ const px = axis === 'x' ? event.clientX - rect.left : event.clientY - rect.top;
52
+ const lo = Math.min(range[0], range[1]);
53
+ const hi = Math.max(range[0], range[1]);
54
+ return Math.max(lo, Math.min(hi, px));
55
+ }
56
+ //# sourceMappingURL=axis-events.js.map
package/dist/index.d.ts CHANGED
@@ -31,6 +31,7 @@ export type { YAxisProps } from './YAxis.js';
31
31
  export { XAxis } from './XAxis.js';
32
32
  export type { XAxisProps } from './XAxis.js';
33
33
  export type { AxisTransform } from './derivedTicks.js';
34
+ export type { AxisMouseEvent, AxisMouseHandler } from './axis-events.js';
34
35
  export { TimeAxis } from './TimeAxis.js';
35
36
  export { CategoryAxis } from './CategoryAxis.js';
36
37
  export { HeatMap } from './HeatMap.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pond-ts/charts",
3
- "version": "0.60.0",
3
+ "version": "0.62.0",
4
4
  "private": false,
5
5
  "description": "Canvas-rendered, streaming-first time-series charts for pond-ts",
6
6
  "license": "MIT",
@@ -39,8 +39,8 @@
39
39
  "perf": "PERF_BENCH=1 playwright test perf.spec.ts --workers=1"
40
40
  },
41
41
  "peerDependencies": {
42
- "@pond-ts/react": "^0.60.0",
43
- "pond-ts": "^0.60.0",
42
+ "@pond-ts/react": "^0.62.0",
43
+ "pond-ts": "^0.62.0",
44
44
  "react": "^18.0.0 || ^19.0.0"
45
45
  },
46
46
  "devDependencies": {