@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 +26 -25
- package/CHANGELOG.md +107 -1
- package/dist/CategoryAxis.d.ts +8 -4
- package/dist/CategoryAxis.js +8 -4
- package/dist/XAxis.d.ts +47 -3
- package/dist/XAxis.js +146 -27
- package/dist/YAxis.d.ts +15 -1
- package/dist/YAxis.js +29 -2
- package/dist/axis-events.d.ts +106 -0
- package/dist/axis-events.js +56 -0
- package/dist/index.d.ts +1 -0
- package/package.json +3 -3
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
|
|
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`
|
|
526
|
-
| Ports | `Inlet`, `Outlet` (typed fields on `node.in` / `node.out`; `get()`, `peek()`, `version`, `connect`, `disconnect`)
|
|
527
|
-
| Nodes | `Node` (`in`, `out`, `dirty`, `error`, `invalidate()`), `defineNode` (reusable multi-output node type), `derive` (single-output, wired inline)
|
|
528
|
-
| Port declaration | `port<T>({ equals, defaultValue })`; types `PortSpec`, `PortSpecMap`, `PortValue`, `PortValues`
|
|
529
|
-
| Sources | `source<T>()` → `SourceNode` (`set()`), `fromLive(liveSource)` → `LiveSourceNode` (`dispose()`); `GraphSource` (bind contract — looser than core's `LiveSource`, accepts `LiveAggregation`), `SnapshotSource`, `NoInputs`
|
|
530
|
-
| Graph view | `Graph` (`Graph.from(...roots)`, `nodes`, `order()`, `edges()`, `toJSON()`); types `GraphEdge`, `GraphJson`, `GraphNodeJson`, `GraphEdgeJson`
|
|
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]`
|
|
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
|
|
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
|
|
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`)
|
|
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`)
|
|
536
|
-
| Plan — registry | `createRegistry({ folds })` / `Registry` (`define`, `get`, `foldFor`, `outputsOf`, `resolveParams
|
|
537
|
-
| Plan — identity | `specId` (content-addressed, param-order invariant, defaults materialized), `refToId`, `explain`, `unitOf`, `columnsOf`, `dependsOn`, `outputKey`
|
|
538
|
-
| Plan — types | `Spec`, `Plan`, `Input` (column name \| `Spec` \| `PickedOutput`), `SpecRef`, `Def` (`OpDef` \| `FoldDef`), `OpContext`, `OpResult`, `FoldContext`, `FactBody`, `isFold`, `ParamDef`, `Params`, `Units`, `InputDef`, `OutputDef`
|
|
539
|
-
| Plan — bind / run | `bind(series, { registry, units })` → `BoundGraph` (`compile`, `setSource`, `ids`, `series`, `columnOf`), `run(graph, { plan, select, onError })` → `RunResult`, `UnitError`
|
|
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`)
|
|
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`
|
|
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`
|
|
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`
|
|
544
|
-
| Plan — async sources | `defineSource({ name, load })`, `createSourceRegistry()` / `SourceRegistry`, `sourceId`, `UnknownSourceError`; types `SourceRef`, `SourceParams`, `LoadedSource` (value + revision), `SourceLoadContext`, `SourceDef`
|
|
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
|
|
546
|
-
| Errors | `ProcessError` (base), `CycleError`, `UnconnectedInputError`, `MissingOutputError`, `UnsetSourceError`
|
|
547
|
-
| Node type helpers | `NodeSpec`, `NodeFactory`, `InletsFor`, `OutletsFor`, `OutletValue`, `SpecsForOutlets`, `DerivedOutput`
|
|
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.
|
|
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
|
package/dist/CategoryAxis.d.ts
CHANGED
|
@@ -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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
package/dist/CategoryAxis.js
CHANGED
|
@@ -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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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) —
|
|
63
|
-
*
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
if (!(slot > 0))
|
|
35
|
-
return [
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
43
|
-
"pond-ts": "^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": {
|