@pond-ts/charts 0.37.0 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -3
- package/dist/ChartRow.js +10 -0
- package/dist/Layers.js +23 -3
- package/dist/XAxis.js +48 -1
- package/dist/annotations.d.ts +25 -3
- package/dist/annotations.js +28 -10
- package/dist/chip.d.ts +36 -0
- package/dist/chip.js +85 -1
- package/dist/context.d.ts +12 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/indicators.d.ts +105 -0
- package/dist/indicators.js +114 -0
- package/dist/tracker.d.ts +1 -1
- package/dist/tracker.js +4 -0
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -8,7 +8,8 @@ The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`, and
|
|
|
8
8
|
them all. Pre-1.0: minor bumps may include new features and type-level changes;
|
|
9
9
|
patch bumps are strictly additive.
|
|
10
10
|
|
|
11
|
-
[Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.
|
|
11
|
+
[Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.38.0...HEAD
|
|
12
|
+
[0.38.0]: https://github.com/pjm17971/pond-ts/compare/v0.37.0...v0.38.0
|
|
12
13
|
[0.37.0]: https://github.com/pjm17971/pond-ts/compare/v0.36.0...v0.37.0
|
|
13
14
|
[0.36.0]: https://github.com/pjm17971/pond-ts/compare/v0.35.0...v0.36.0
|
|
14
15
|
[0.35.0]: https://github.com/pjm17971/pond-ts/compare/v0.34.1...v0.35.0
|
|
@@ -33,6 +34,49 @@ patch bumps are strictly additive.
|
|
|
33
34
|
[0.19.0]: https://github.com/pjm17971/pond-ts/compare/v0.18.0...v0.19.0
|
|
34
35
|
[0.18.0]: https://github.com/pjm17971/pond-ts/compare/v0.17.1...v0.18.0
|
|
35
36
|
|
|
37
|
+
## [0.38.0] — 2026-07-03
|
|
38
|
+
|
|
39
|
+
A `@pond-ts/charts` release: **axis-edge value indicators + the crosshair
|
|
40
|
+
cursor** — the ChartIQ / Yahoo-Finance price-tag family, driven by the Tidal
|
|
41
|
+
terminal. `pond-ts`, `@pond-ts/react`, and `@pond-ts/fit` carry no code changes —
|
|
42
|
+
republished in lock-step (peer ranges widen to `^0.38.0`).
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- `@pond-ts/charts`: **`<YAxisIndicator>` + `createLiveValue`** — a value pill
|
|
47
|
+
pinned to a y-axis edge, decoupled from the series' last point. A `LiveValue`
|
|
48
|
+
`source` updates it at high frequency **without re-rendering the chart** (only
|
|
49
|
+
the subscribed pill repaints). Props: `value` / `source`, `axis`, `side`,
|
|
50
|
+
`color`, `format`, `line` (dashed guide), `pointer` (callout triangle).
|
|
51
|
+
- `@pond-ts/charts`: **`cursor="crosshair"`** `CursorMode` — a synced vertical
|
|
52
|
+
line + per-series dots, each series' value pinned to its y-axis and the hovered
|
|
53
|
+
time pinned to the x-axis.
|
|
54
|
+
- `@pond-ts/charts`: **`indicator`** opt-in on `<Baseline>` (a y-axis value pill)
|
|
55
|
+
and `<Marker>` (an x-axis time pill, with a connector down to the mark).
|
|
56
|
+
- `@pond-ts/charts`: `<Baseline labelSide>` (`left` / `right`) + `labelPosition`
|
|
57
|
+
(`center` on the line / `above` it) for the near-line label chip.
|
|
58
|
+
- `@pond-ts/charts`: `<Region edges>` (default `true`; `false` = shaded fill with
|
|
59
|
+
no side outlines).
|
|
60
|
+
- `@pond-ts/charts`: `axisPillStyle`, `contrastText`, `pointerStyle` chip helpers
|
|
61
|
+
are exported.
|
|
62
|
+
|
|
63
|
+
### Changed
|
|
64
|
+
|
|
65
|
+
- `@pond-ts/charts`: axis indicator pills are **solid** (colour fill +
|
|
66
|
+
auto-contrast text), aligned to the tick-label row, and **always show the axis
|
|
67
|
+
coordinate** — never a custom label (a label stays the in-plot chip).
|
|
68
|
+
- `@pond-ts/charts`: cursor flag / inline chips now have **square corners**; the
|
|
69
|
+
cursor **time** atop a flag stack renders as plain text (no chip background).
|
|
70
|
+
- `@pond-ts/charts`: Storybook reorganized into a feature-axis reference tree with
|
|
71
|
+
systematic per-prop coverage (dev-only; stories are excluded from the package).
|
|
72
|
+
|
|
73
|
+
### Fixed
|
|
74
|
+
|
|
75
|
+
- `@pond-ts/charts`: the crosshair x-axis pill used the container's time formatter
|
|
76
|
+
(showing a raw number on a value axis) — it now uses the axis's own resolved
|
|
77
|
+
formatter, matching the ticks. The crosshair also no longer double-renders the
|
|
78
|
+
time (a stray per-row chip alongside the x-axis pill).
|
|
79
|
+
|
|
36
80
|
## [0.37.0] — 2026-07-02
|
|
37
81
|
|
|
38
82
|
A `@pond-ts/charts` release: the axis wave — label, tick, and domain controls
|
|
@@ -110,7 +154,7 @@ lock-step (their `pond-ts` / `@pond-ts/react` peer ranges widen to `^0.35.0`).
|
|
|
110
154
|
(`[6, 4]` dashed, `[2, 3]` ≈ dotted; omit or `[]` = solid) applied to the
|
|
111
155
|
series stroke. Lets a theme set a **modeled / forecast** line (e.g. GARCH
|
|
112
156
|
vol) apart from an observed one at a glance. Distinct from a `GapMode`'s
|
|
113
|
-
inferred gap-bridge dashing (which marks
|
|
157
|
+
inferred gap-bridge dashing (which marks _missing data_, not the whole
|
|
114
158
|
line). Additive: existing themes are unaffected; a solid line never touches
|
|
115
159
|
`setLineDash`. New `Charts/LineChart → LineStyles` story. (#313)
|
|
116
160
|
|
|
@@ -149,7 +193,7 @@ Tidal wire-format spike. `@pond-ts/react`, `@pond-ts/charts`, and
|
|
|
149
193
|
- **`TimeSeries.fromColumns`** — the columnar (struct-of-arrays) ingress,
|
|
150
194
|
the counterpart to `fromJSON`'s row-tuple shape. Accepts either a plain
|
|
151
195
|
`number[]` or a `Float64Array` per column — one polymorphic door, so a
|
|
152
|
-
wire format only changes the
|
|
196
|
+
wire format only changes the _decoder_, not the ingest. `Float64Array`
|
|
153
197
|
columns are adopted directly (zero-copy); `number[]` columns are copied.
|
|
154
198
|
A `null`/`undefined` cell or a non-finite value (`NaN`/`Infinity`) is a
|
|
155
199
|
gap, identically across both input shapes. Enforces the same
|
package/dist/ChartRow.js
CHANGED
|
@@ -179,6 +179,14 @@ export function ChartRow({ height, cursor, children }) {
|
|
|
179
179
|
}
|
|
180
180
|
return map;
|
|
181
181
|
}, [effectiveAxes]);
|
|
182
|
+
// Which side each axis sits on — so an axis-edge overlay (the crosshair's
|
|
183
|
+
// value pills) hugs the correct gutter without re-deriving from the specs.
|
|
184
|
+
const axisSides = useMemo(() => {
|
|
185
|
+
const map = new Map();
|
|
186
|
+
for (const ax of effectiveAxes)
|
|
187
|
+
map.set(ax.id, ax.side);
|
|
188
|
+
return map;
|
|
189
|
+
}, [effectiveAxes]);
|
|
182
190
|
const frame = useMemo(() => ({
|
|
183
191
|
height,
|
|
184
192
|
cursor,
|
|
@@ -187,6 +195,7 @@ export function ChartRow({ height, cursor, children }) {
|
|
|
187
195
|
yScales,
|
|
188
196
|
formats,
|
|
189
197
|
tickValues,
|
|
198
|
+
axisSides,
|
|
190
199
|
defaultAxisId,
|
|
191
200
|
axisSlots,
|
|
192
201
|
registerAxis,
|
|
@@ -202,6 +211,7 @@ export function ChartRow({ height, cursor, children }) {
|
|
|
202
211
|
yScales,
|
|
203
212
|
formats,
|
|
204
213
|
tickValues,
|
|
214
|
+
axisSides,
|
|
205
215
|
defaultAxisId,
|
|
206
216
|
axisSlots,
|
|
207
217
|
registerAxis,
|
package/dist/Layers.js
CHANGED
|
@@ -5,7 +5,7 @@ import { drawGrid } from './grid.js';
|
|
|
5
5
|
import { cursorParts } from './tracker.js';
|
|
6
6
|
import { resolveSelection } from './select.js';
|
|
7
7
|
import { panRange, zoomRange } from './viewport.js';
|
|
8
|
-
import { flagChipStyle, flagChipX } from './chip.js';
|
|
8
|
+
import { flagChipStyle, flagChipX, axisPillX, axisPillStyle } from './chip.js';
|
|
9
9
|
import { ContainerContext, LayersContext, RowContext, } from './context.js';
|
|
10
10
|
/** Gridline tick count — matches the axes (`YAxis`/`TimeAxis`) so they align. */
|
|
11
11
|
const GRID_TICKS = 5;
|
|
@@ -49,7 +49,7 @@ export function Layers({ children }) {
|
|
|
49
49
|
}), [row.registerLayer, row.unregisterLayer]);
|
|
50
50
|
const background = container.theme.background;
|
|
51
51
|
const { grid: gridColor, gridDash } = container.theme.axis;
|
|
52
|
-
const { layers, yScales, formats, defaultAxisId, tickValues } = row;
|
|
52
|
+
const { layers, yScales, formats, defaultAxisId, tickValues, axisSides } = row;
|
|
53
53
|
// x geometry is shared and lives on the container (uniform across rows).
|
|
54
54
|
const { xScale, plotWidth } = container;
|
|
55
55
|
const draw = useCallback((ctx, w, h) => {
|
|
@@ -132,6 +132,8 @@ export function Layers({ children }) {
|
|
|
132
132
|
// The chip uses this layer's axis formatter, so a readout value reads
|
|
133
133
|
// exactly as the axis labels it.
|
|
134
134
|
const fmt = formats.get(axisId) ?? String;
|
|
135
|
+
// Which gutter the crosshair value pill hugs (the axis's own side).
|
|
136
|
+
const side = axisSides.get(axisId) ?? 'left';
|
|
135
137
|
for (const s of entry.layer.sampleAt(cursorTime)) {
|
|
136
138
|
out.push({
|
|
137
139
|
px: xScale(s.x),
|
|
@@ -139,6 +141,7 @@ export function Layers({ children }) {
|
|
|
139
141
|
value: s.value,
|
|
140
142
|
color: s.color,
|
|
141
143
|
format: fmt,
|
|
144
|
+
side,
|
|
142
145
|
});
|
|
143
146
|
}
|
|
144
147
|
}
|
|
@@ -148,6 +151,7 @@ export function Layers({ children }) {
|
|
|
148
151
|
layers,
|
|
149
152
|
yScales,
|
|
150
153
|
formats,
|
|
154
|
+
axisSides,
|
|
151
155
|
xScale,
|
|
152
156
|
defaultAxisId,
|
|
153
157
|
parts.dots,
|
|
@@ -403,9 +407,13 @@ export function Layers({ children }) {
|
|
|
403
407
|
// The time is shared across rows (one cursor, one time), so it shows **once**,
|
|
404
408
|
// atop the first row — not repeated per row. (Gating it here also drops the
|
|
405
409
|
// top-of-stack space reservation on the other rows, see `flagBase`.)
|
|
410
|
+
// Crosshair (`chip: 'axis'`) is excluded: it pins the time to the shared x-axis
|
|
411
|
+
// pill (`<XAxis>`), so a per-row chip here would double it (and land wrong on a
|
|
412
|
+
// stacked row).
|
|
406
413
|
const showTime = showCursorTime &&
|
|
407
414
|
cursorTime !== null &&
|
|
408
415
|
(parts.line || parts.dots) &&
|
|
416
|
+
parts.chip !== 'axis' &&
|
|
409
417
|
row.isFirstRow;
|
|
410
418
|
// Flag geometry: each value flies as a flag from the top of its own staff — the
|
|
411
419
|
// chip's top sits at `flagBase` (just below the time chip when shown) and the
|
|
@@ -491,9 +499,12 @@ export function Layers({ children }) {
|
|
|
491
499
|
cursorX >= 0 &&
|
|
492
500
|
cursorX <= plotWidth && (_jsx("line", { x1: Math.round(cursorX), y1: 0, x2: Math.round(cursorX), y2: row.height, stroke: cursorColor, strokeWidth: 1, shapeRendering: "crispEdges" })), parts.chip === 'flag' &&
|
|
493
501
|
trackerSamples.map((s, i) => s.py > flagBase ? (_jsx("line", { x1: s.px, y1: flagBase, x2: s.px, y2: s.py, stroke: cursorColor, strokeWidth: 1, opacity: 0.5 }, `staff-${i}`)) : null), parts.chip === 'flag' &&
|
|
494
|
-
trackerFlags.map((f, i) => f.topPy > flagBase ? (_jsx("line", { x1: f.px, y1: flagBase, x2: f.px, y2: f.topPy, stroke: cursorColor, strokeWidth: 1, opacity: 0.5 }, `boxstaff-${i}`)) : null), parts.
|
|
502
|
+
trackerFlags.map((f, i) => f.topPy > flagBase ? (_jsx("line", { x1: f.px, y1: flagBase, x2: f.px, y2: f.topPy, stroke: cursorColor, strokeWidth: 1, opacity: 0.5 }, `boxstaff-${i}`)) : null), parts.chip === 'axis' &&
|
|
503
|
+
trackerSamples.map((s, i) => (_jsx("line", { x1: s.side === 'right' ? Math.round(s.px) : 0, y1: Math.round(s.py), x2: s.side === 'right' ? plotWidth : Math.round(s.px), y2: Math.round(s.py), stroke: s.color, strokeWidth: 1, opacity: 0.4, strokeDasharray: "3 3", shapeRendering: "crispEdges" }, `hconn-${i}`))), parts.dots &&
|
|
495
504
|
trackerSamples.map((s, i) => (_jsx("circle", { cx: s.px, cy: s.py, r: 3, fill: s.color, stroke: background, strokeWidth: background ? 1 : 0 }, `dot-${i}`)))] }), showTime && timeX !== null && cursorTime !== null && (_jsx("div", { style: {
|
|
496
505
|
...chipStyle,
|
|
506
|
+
background: 'transparent',
|
|
507
|
+
padding: 0,
|
|
497
508
|
top: `${flagTop}px`,
|
|
498
509
|
left: timeX > plotWidth * LABEL_FLIP_FRACTION
|
|
499
510
|
? undefined
|
|
@@ -518,6 +529,15 @@ export function Layers({ children }) {
|
|
|
518
529
|
right: flip ? `${plotWidth - s.px + 8}px` : undefined,
|
|
519
530
|
color: s.color,
|
|
520
531
|
}, children: s.format(s.value) }, i));
|
|
532
|
+
}), parts.chip === 'axis' &&
|
|
533
|
+
trackerSamples.map((s, i) => {
|
|
534
|
+
const top = Math.max(flagLineHeight / 2, Math.min(row.height - flagLineHeight / 2, s.py));
|
|
535
|
+
return (_jsx("div", { style: {
|
|
536
|
+
...axisPillStyle(container.theme, s.color),
|
|
537
|
+
top: `${top}px`,
|
|
538
|
+
transform: 'translateY(-50%)',
|
|
539
|
+
...axisPillX(s.side, plotWidth),
|
|
540
|
+
}, children: s.format(s.value) }, `ytag-${i}`));
|
|
521
541
|
}), parts.chip === 'flag' &&
|
|
522
542
|
cursorX !== null &&
|
|
523
543
|
trackerSamples.map((s, i) => (
|
package/dist/XAxis.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Fragment, useContext } from 'react';
|
|
3
3
|
import { ContainerContext } from './context.js';
|
|
4
|
+
import { axisPillStyle } from './chip.js';
|
|
4
5
|
import { resolveAxisFormat, resolveTimeFormat, } from './format.js';
|
|
5
6
|
/** Tick strip height (mark + value label) in CSS px. */
|
|
6
7
|
const TICK_STRIP = 22;
|
|
@@ -23,6 +24,17 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
|
|
|
23
24
|
throw new Error('<XAxis> must be rendered inside a <ChartContainer>');
|
|
24
25
|
}
|
|
25
26
|
const { xScale, plotWidth, leftGutter, theme, formatTime, xKind } = container;
|
|
27
|
+
// The crosshair's x-time pill: when the container cursor is `'crosshair'` and a
|
|
28
|
+
// cursor is live in-bounds, pin the hovered time to this axis (covering the
|
|
29
|
+
// tick behind it), matching the on-axis y value pills the rows draw. Gated on
|
|
30
|
+
// the container default, so a per-row `cursor` override doesn't reach here.
|
|
31
|
+
const cursorX = container.cursorX;
|
|
32
|
+
const showCursorTag = container.cursor === 'crosshair' &&
|
|
33
|
+
cursorX !== null &&
|
|
34
|
+
cursorX >= 0 &&
|
|
35
|
+
cursorX <= plotWidth;
|
|
36
|
+
const cursorColor = theme.cursor ?? theme.axis.label;
|
|
37
|
+
const annotationColor = theme.annotation?.color ?? '#0d9488';
|
|
26
38
|
// Tick formatter: an explicit `format` is resolved against the axis kind
|
|
27
39
|
// (a time specifier through the time scale, a number specifier through the
|
|
28
40
|
// value scale); otherwise the container's shared formatter — the one the
|
|
@@ -32,6 +44,18 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
|
|
|
32
44
|
: xKind === 'time'
|
|
33
45
|
? resolveTimeFormat(xScale, TICK_COUNT, format)
|
|
34
46
|
: resolveAxisFormat(xScale, TICK_COUNT, format);
|
|
47
|
+
// Marker annotations that opted into an axis indicator (`<Marker indicator>`)
|
|
48
|
+
// pin their **time** to this shared x-axis — a pill at `at`, in the annotation
|
|
49
|
+
// colour, reading like a tick. An indicator always shows the axis coordinate
|
|
50
|
+
// (the formatted `at`), never the marker's custom label (that stays the in-plot
|
|
51
|
+
// chip). Skipped when off-plot.
|
|
52
|
+
const markerTags = container.annotations
|
|
53
|
+
.filter((a) => a.indicator && a.kind === 'marker' && a.xs[0] !== undefined)
|
|
54
|
+
.map((a) => {
|
|
55
|
+
const at = a.xs[0];
|
|
56
|
+
return { id: a.id ?? `marker-at-${at}`, x: xScale(at), text: fmt(at) };
|
|
57
|
+
})
|
|
58
|
+
.filter((t) => t.x >= 0 && t.x <= plotWidth);
|
|
35
59
|
const placed = customTicks
|
|
36
60
|
? customTicks.map((t) => ({ x: xScale(t.at), label: t.label }))
|
|
37
61
|
: xScale.ticks(TICK_COUNT).map((d) => ({
|
|
@@ -40,6 +64,9 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
|
|
|
40
64
|
}));
|
|
41
65
|
const stripHeight = height ?? TICK_STRIP + (label ? LABEL_STRIP : 0);
|
|
42
66
|
const onTop = side === 'top';
|
|
67
|
+
// Axis pills (marker / crosshair) sit at the same offset as the tick labels so
|
|
68
|
+
// they line up with their tick-label neighbours (matches `labelOffset` below).
|
|
69
|
+
const pillOffset = align === 'right' ? 2 : 6;
|
|
43
70
|
return (_jsxs("div", { style: {
|
|
44
71
|
position: 'relative',
|
|
45
72
|
marginLeft: `${leftGutter}px`,
|
|
@@ -92,6 +119,26 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
|
|
|
92
119
|
color: theme.axis.title?.color ?? theme.axis.label,
|
|
93
120
|
opacity: theme.axis.title?.opacity ?? 0.85,
|
|
94
121
|
whiteSpace: 'nowrap',
|
|
95
|
-
}, children: label }))
|
|
122
|
+
}, children: label })), markerTags.map((t) => (_jsxs(Fragment, { children: [_jsx("div", { style: {
|
|
123
|
+
position: 'absolute',
|
|
124
|
+
left: `${t.x}px`,
|
|
125
|
+
[onTop ? 'bottom' : 'top']: 0,
|
|
126
|
+
width: '1px',
|
|
127
|
+
height: `${pillOffset}px`,
|
|
128
|
+
background: annotationColor,
|
|
129
|
+
zIndex: 2,
|
|
130
|
+
} }), _jsx("div", { style: {
|
|
131
|
+
...axisPillStyle(theme, annotationColor),
|
|
132
|
+
left: `${t.x}px`,
|
|
133
|
+
transform: 'translateX(-50%)',
|
|
134
|
+
[onTop ? 'bottom' : 'top']: `${pillOffset}px`,
|
|
135
|
+
zIndex: 2,
|
|
136
|
+
}, children: t.text })] }, t.id))), showCursorTag && (_jsx("div", { style: {
|
|
137
|
+
...axisPillStyle(theme, cursorColor),
|
|
138
|
+
left: `${cursorX}px`,
|
|
139
|
+
transform: 'translateX(-50%)',
|
|
140
|
+
[onTop ? 'bottom' : 'top']: `${pillOffset}px`,
|
|
141
|
+
zIndex: 3,
|
|
142
|
+
}, children: fmt(+xScale.invert(cursorX)) }))] }));
|
|
96
143
|
}
|
|
97
144
|
//# sourceMappingURL=XAxis.js.map
|
package/dist/annotations.d.ts
CHANGED
|
@@ -51,9 +51,16 @@ export interface MarkerProps {
|
|
|
51
51
|
/** Make the marker **editable** (in edit mode): dragging its line reports the
|
|
52
52
|
* new `at` (controlled — wire it back to `at`). The whole line moves. */
|
|
53
53
|
onChange?: (at: number) => void;
|
|
54
|
+
/** Also pin this marker's **time** to the **x-axis** as an on-axis pill (drawn
|
|
55
|
+
* by `<XAxis>` at `at`, in the annotation colour) — the axis-edge counterpart
|
|
56
|
+
* of the near-line chip. Default `false`. The pill always shows the formatted
|
|
57
|
+
* `at` (the axis coordinate), never the custom `label` (which stays the
|
|
58
|
+
* near-line chip) — an indicator reads like a tick. A connector links the
|
|
59
|
+
* marker line to its pill. */
|
|
60
|
+
indicator?: boolean;
|
|
54
61
|
}
|
|
55
62
|
/** A vertical line at an x position (a time, a distance, a lap boundary). */
|
|
56
|
-
export declare function Marker({ at, label, id, selected, selectable, hovered, editing, onChange, }: MarkerProps): import("react/jsx-runtime").JSX.Element;
|
|
63
|
+
export declare function Marker({ at, label, id, selected, selectable, hovered, editing, onChange, indicator, }: MarkerProps): import("react/jsx-runtime").JSX.Element;
|
|
57
64
|
export interface BaselineProps {
|
|
58
65
|
/** y value in the linked axis's units. */
|
|
59
66
|
value: number;
|
|
@@ -62,6 +69,11 @@ export interface BaselineProps {
|
|
|
62
69
|
/** Chip label. Omit to format `value` with that axis's formatter; pass `false`
|
|
63
70
|
* (or `''`) to render **no label chip**. */
|
|
64
71
|
label?: string | false;
|
|
72
|
+
/** Which side of the chart the near-line label chip sits. **Default `left`.** */
|
|
73
|
+
labelSide?: 'left' | 'right';
|
|
74
|
+
/** Where the label chip sits relative to the line: **`center`** (default) rides
|
|
75
|
+
* on the line, vertically centred; `above` sits just on top of it. */
|
|
76
|
+
labelPosition?: 'center' | 'above';
|
|
65
77
|
/** Stable consumer id — a click reports it via `onSelectAnnotation`. */
|
|
66
78
|
id?: string;
|
|
67
79
|
/** Controlled selection — brightens to the front (level 1). Handles are an
|
|
@@ -82,10 +94,16 @@ export interface BaselineProps {
|
|
|
82
94
|
/** Make the baseline **editable** (in edit mode): dragging it vertically reports
|
|
83
95
|
* the new `value` (controlled — wire it back to `value`). */
|
|
84
96
|
onChange?: (value: number) => void;
|
|
97
|
+
/** Also pin this baseline's **value** to its **y-axis** as an on-axis pill (in
|
|
98
|
+
* the annotation colour) — the axis-edge counterpart of the near-line chip.
|
|
99
|
+
* Default `false`. The pill always shows the formatted `value` (the axis
|
|
100
|
+
* coordinate), never the custom `label` (which stays the near-line chip) — an
|
|
101
|
+
* indicator reads like a tick. */
|
|
102
|
+
indicator?: boolean;
|
|
85
103
|
}
|
|
86
104
|
/** A horizontal line at a y value, scaled against one row axis (RTC's `Baseline`).
|
|
87
105
|
* Its label anchors at the left, at the line's height. */
|
|
88
|
-
export declare function Baseline({ value, axis, label, id, selected, selectable, hovered, editing, onChange, }: BaselineProps): import("react/jsx-runtime").JSX.Element | null;
|
|
106
|
+
export declare function Baseline({ value, axis, label, labelSide, labelPosition, id, selected, selectable, hovered, editing, onChange, indicator, }: BaselineProps): import("react/jsx-runtime").JSX.Element | null;
|
|
89
107
|
export interface RegionProps {
|
|
90
108
|
/** Start x in axis units (time or value). */
|
|
91
109
|
from: number;
|
|
@@ -122,8 +140,12 @@ export interface RegionProps {
|
|
|
122
140
|
from: number;
|
|
123
141
|
to: number;
|
|
124
142
|
}) => void;
|
|
143
|
+
/** Draw the vertical **side outlines** at `from`/`to`. **Default `true`.**
|
|
144
|
+
* `false` shades the span with no edge lines (fill only) — a soft highlight
|
|
145
|
+
* band. Edit-mode resizing still works (the grab areas are invisible). */
|
|
146
|
+
edges?: boolean;
|
|
125
147
|
}
|
|
126
148
|
/** A shaded span over an x range — a lap, a zone, a selected interval. Its label
|
|
127
149
|
* flies as a flag off the left edge. */
|
|
128
|
-
export declare function Region({ from, to, label, id, selected, selectable, hovered, editing, onChange, }: RegionProps): import("react/jsx-runtime").JSX.Element;
|
|
150
|
+
export declare function Region({ from, to, label, id, selected, selectable, hovered, editing, onChange, edges, }: RegionProps): import("react/jsx-runtime").JSX.Element;
|
|
129
151
|
//# sourceMappingURL=annotations.d.ts.map
|
package/dist/annotations.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useContext, useEffect, useMemo, useRef, useState, } from 'react';
|
|
3
3
|
import { ContainerContext, RowContext, } from './context.js';
|
|
4
|
-
import { flagChipStyle, flagChipX } from './chip.js';
|
|
4
|
+
import { flagChipStyle, flagChipX, axisPillX, axisPillStyle } from './chip.js';
|
|
5
5
|
import { useSlotKey } from './use-slot-key.js';
|
|
6
6
|
/**
|
|
7
7
|
* User-authored **annotations** — marks you place *on* a chart, in a register
|
|
@@ -114,7 +114,7 @@ function useAnnotationFrame(name) {
|
|
|
114
114
|
* other rows, order regions, and serve snap targets), keyed by the caller's stable
|
|
115
115
|
* per-instance slot key; unregister on unmount. `xs` should be memoised by the
|
|
116
116
|
* caller so the effect only re-runs when the position actually moves. */
|
|
117
|
-
function useRegisterAnnotation(container, key, id, rowKey, kind, xs, selected, selectable, editing, label) {
|
|
117
|
+
function useRegisterAnnotation(container, key, id, rowKey, kind, xs, selected, selectable, editing, label, indicator) {
|
|
118
118
|
const { registerAnnotation, unregisterAnnotation } = container;
|
|
119
119
|
useEffect(() => () => unregisterAnnotation(key), [unregisterAnnotation, key]);
|
|
120
120
|
useEffect(() => {
|
|
@@ -128,6 +128,7 @@ function useRegisterAnnotation(container, key, id, rowKey, kind, xs, selected, s
|
|
|
128
128
|
selectable,
|
|
129
129
|
editing,
|
|
130
130
|
label,
|
|
131
|
+
indicator,
|
|
131
132
|
});
|
|
132
133
|
}, [
|
|
133
134
|
registerAnnotation,
|
|
@@ -140,6 +141,7 @@ function useRegisterAnnotation(container, key, id, rowKey, kind, xs, selected, s
|
|
|
140
141
|
selectable,
|
|
141
142
|
editing,
|
|
142
143
|
label,
|
|
144
|
+
indicator,
|
|
143
145
|
]);
|
|
144
146
|
}
|
|
145
147
|
/** Vertical px between stacked label lanes. */
|
|
@@ -338,7 +340,7 @@ function DragArea({ x, y, w, h, cursor, editable, onHover, onSelect, onEdit, onD
|
|
|
338
340
|
} }));
|
|
339
341
|
}
|
|
340
342
|
/** A vertical line at an x position (a time, a distance, a lap boundary). */
|
|
341
|
-
export function Marker({ at, label, id, selected = false, selectable = true, hovered, editing = false, onChange, }) {
|
|
343
|
+
export function Marker({ at, label, id, selected = false, selectable = true, hovered, editing = false, onChange, indicator = false, }) {
|
|
342
344
|
const { container, row, ann } = useAnnotationFrame('Marker');
|
|
343
345
|
const selfKey = useSlotKey();
|
|
344
346
|
const { hovering, reportHover } = useAnnotationHover(container, id, hovered);
|
|
@@ -350,7 +352,7 @@ export function Marker({ at, label, id, selected = false, selectable = true, hov
|
|
|
350
352
|
const xs = useMemo(() => [at], [at]);
|
|
351
353
|
// `label === false` (or '') ⇒ no chip; omitted ⇒ auto-label off the x formatter.
|
|
352
354
|
const text = label === false ? '' : (label ?? container.formatTime(at));
|
|
353
|
-
useRegisterAnnotation(container, selfKey, id, row.rowKey, 'marker', xs, selected, selectable, editing, text);
|
|
355
|
+
useRegisterAnnotation(container, selfKey, id, row.rowKey, 'marker', xs, selected, selectable, editing, text, indicator);
|
|
354
356
|
// No select/edit while a create tool is armed — the chart is in draw mode then.
|
|
355
357
|
const select = id !== undefined && container.creating === null
|
|
356
358
|
? () => container.onSelectAnnotation?.(id)
|
|
@@ -371,7 +373,7 @@ export function Marker({ at, label, id, selected = false, selectable = true, hov
|
|
|
371
373
|
}
|
|
372
374
|
/** A horizontal line at a y value, scaled against one row axis (RTC's `Baseline`).
|
|
373
375
|
* Its label anchors at the left, at the line's height. */
|
|
374
|
-
export function Baseline({ value, axis, label, id, selected = false, selectable = true, hovered, editing = false, onChange, }) {
|
|
376
|
+
export function Baseline({ value, axis, label, labelSide = 'left', labelPosition = 'center', id, selected = false, selectable = true, hovered, editing = false, onChange, indicator = false, }) {
|
|
375
377
|
const { container, row, ann } = useAnnotationFrame('Baseline');
|
|
376
378
|
const selfKey = useSlotKey();
|
|
377
379
|
const { hovering, reportHover } = useAnnotationHover(container, id, hovered);
|
|
@@ -388,7 +390,7 @@ export function Baseline({ value, axis, label, id, selected = false, selectable
|
|
|
388
390
|
// Baselines don't lane-pack (the label anchors at their y, not the top), so
|
|
389
391
|
// this registered string is unused by `computeLabelLanes` — `|| ''` just
|
|
390
392
|
// keeps it a string for `false`/'' (which mean "no label").
|
|
391
|
-
label || '');
|
|
393
|
+
label || '', indicator);
|
|
392
394
|
// No select/edit while a create tool is armed — the chart is in draw mode then.
|
|
393
395
|
const select = id !== undefined && container.creating === null
|
|
394
396
|
? () => container.onSelectAnnotation?.(id)
|
|
@@ -411,11 +413,27 @@ export function Baseline({ value, axis, label, id, selected = false, selectable
|
|
|
411
413
|
const text = label === false ? '' : (label ?? (fmt ? fmt(value) : String(value)));
|
|
412
414
|
// Handle pill near the right end (clears the left-anchored label).
|
|
413
415
|
const handleX = w - 14;
|
|
414
|
-
return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: w, height: row.height, style: overlayStyle, children: [_jsx("line", { x1: 0, y1: y, x2: w, y2: y, stroke: ann.color, strokeWidth: 1, opacity: opacity, shapeRendering: "crispEdges" }), showHandle && (_jsx(Pill, { cx: handleX, cy: y, w: HANDLE_LONG, h: HANDLE_SHORT, color: ann.color })), selectable && (_jsx(DragArea, { x: 0, y: y - HIT_PAD, w: w, h: 2 * HIT_PAD, cursor: editing ? 'ns-resize' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDrag: (_px, py) => onChange?.(yScale.invert(py)) }))] }), text && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
|
|
416
|
+
return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: w, height: row.height, style: overlayStyle, children: [_jsx("line", { x1: 0, y1: y, x2: w, y2: y, stroke: ann.color, strokeWidth: 1, opacity: opacity, shapeRendering: "crispEdges" }), showHandle && (_jsx(Pill, { cx: handleX, cy: y, w: HANDLE_LONG, h: HANDLE_SHORT, color: ann.color })), selectable && (_jsx(DragArea, { x: 0, y: y - HIT_PAD, w: w, h: 2 * HIT_PAD, cursor: editing ? 'ns-resize' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDrag: (_px, py) => onChange?.(yScale.invert(py)) }))] }), text && (_jsx(Chip, { theme: container.theme, color: ann.color, style: {
|
|
417
|
+
top: `${y}px`,
|
|
418
|
+
[labelSide === 'right' ? 'right' : 'left']: '2px',
|
|
419
|
+
// `center` rides on the line; `above` sits its bottom edge on the line.
|
|
420
|
+
transform: labelPosition === 'above'
|
|
421
|
+
? 'translateY(-100%)'
|
|
422
|
+
: 'translateY(-50%)',
|
|
423
|
+
}, children: text })), indicator &&
|
|
424
|
+
(() => {
|
|
425
|
+
const half = container.theme.font.size / 2 + 1;
|
|
426
|
+
return (_jsx("div", { style: {
|
|
427
|
+
...axisPillStyle(container.theme, ann.color),
|
|
428
|
+
top: `${Math.max(half, Math.min(row.height - half, y))}px`,
|
|
429
|
+
transform: 'translateY(-50%)',
|
|
430
|
+
...axisPillX(row.axisSides.get(axisId) ?? 'left', w),
|
|
431
|
+
}, children: fmt ? fmt(value) : String(value) }));
|
|
432
|
+
})()] }));
|
|
415
433
|
}
|
|
416
434
|
/** A shaded span over an x range — a lap, a zone, a selected interval. Its label
|
|
417
435
|
* flies as a flag off the left edge. */
|
|
418
|
-
export function Region({ from, to, label, id, selected = false, selectable = true, hovered, editing = false, onChange, }) {
|
|
436
|
+
export function Region({ from, to, label, id, selected = false, selectable = true, hovered, editing = false, onChange, edges = true, }) {
|
|
419
437
|
const { container, row, ann } = useAnnotationFrame('Region');
|
|
420
438
|
const selfKey = useSlotKey();
|
|
421
439
|
const { hovering, reportHover } = useAnnotationHover(container, id, hovered);
|
|
@@ -429,7 +447,7 @@ export function Region({ from, to, label, id, selected = false, selectable = tru
|
|
|
429
447
|
const text = label === false
|
|
430
448
|
? ''
|
|
431
449
|
: (label ?? `${container.formatTime(from)}–${container.formatTime(to)}`);
|
|
432
|
-
useRegisterAnnotation(container, selfKey, id, row.rowKey, 'region', xs, selected, selectable, editing, text);
|
|
450
|
+
useRegisterAnnotation(container, selfKey, id, row.rowKey, 'region', xs, selected, selectable, editing, text, false);
|
|
433
451
|
// No select/edit while a create tool is armed — the chart is in draw mode then.
|
|
434
452
|
const select = id !== undefined && container.creating === null
|
|
435
453
|
? () => container.onSelectAnnotation?.(id)
|
|
@@ -460,7 +478,7 @@ export function Region({ from, to, label, id, selected = false, selectable = tru
|
|
|
460
478
|
// region the other way instead of dead-ending at zero width.
|
|
461
479
|
const edgeRef = useRef(null);
|
|
462
480
|
const edge = (atX) => (_jsx("line", { x1: atX, y1: 0, x2: atX, y2: h, stroke: ann.color, strokeWidth: 1, opacity: edgeOpacity, shapeRendering: "crispEdges" }));
|
|
463
|
-
return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: container.plotWidth, height: h, style: overlayStyle, children: [_jsx("rect", { x: left, y: 0, width: spanW, height: h, fill: ann.color, opacity: fillOpacity }), edge(xa), edge(xb), showHandles && (_jsxs(_Fragment, { children: [_jsx(Pill, { cx: xa, cy: h / 2, w: HANDLE_SHORT, h: HANDLE_LONG, color: ann.color }), _jsx(Pill, { cx: xb, cy: h / 2, w: HANDLE_SHORT, h: HANDLE_LONG, color: ann.color })] })), selectable && (_jsxs(_Fragment, { children: [_jsx(DragArea, { x: left, y: 0, w: spanW, h: h, cursor: editing ? 'grab' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragStart: (px) => {
|
|
481
|
+
return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: container.plotWidth, height: h, style: overlayStyle, children: [_jsx("rect", { x: left, y: 0, width: spanW, height: h, fill: ann.color, opacity: fillOpacity }), edges && edge(xa), edges && edge(xb), showHandles && (_jsxs(_Fragment, { children: [_jsx(Pill, { cx: xa, cy: h / 2, w: HANDLE_SHORT, h: HANDLE_LONG, color: ann.color }), _jsx(Pill, { cx: xb, cy: h / 2, w: HANDLE_SHORT, h: HANDLE_LONG, color: ann.color })] })), selectable && (_jsxs(_Fragment, { children: [_jsx(DragArea, { x: left, y: 0, w: spanW, h: h, cursor: editing ? 'grab' : 'inherit', editable: editable, onHover: reportHover, onSelect: select, onEdit: edit, onDragStart: (px) => {
|
|
464
482
|
dragRef.current = { from, to, startPx: px };
|
|
465
483
|
}, onDrag: (px) => {
|
|
466
484
|
const s = dragRef.current;
|
package/dist/chip.d.ts
CHANGED
|
@@ -13,6 +13,42 @@ import type { ChartTheme } from './theme.js';
|
|
|
13
13
|
* contrasting chip background) — a token to settle before this ships.
|
|
14
14
|
*/
|
|
15
15
|
export declare function flagChipStyle(theme: ChartTheme): CSSProperties;
|
|
16
|
+
/**
|
|
17
|
+
* Pick a readable text colour (near-black or white) for text drawn **on top of**
|
|
18
|
+
* `bg`, by its sRGB relative luminance. Handles `#rgb`/`#rrggbb` (the theme
|
|
19
|
+
* palette); any other CSS colour falls back to white. So a saturated blue/red/
|
|
20
|
+
* teal pill gets white text, a pale turquoise pill gets dark text.
|
|
21
|
+
*/
|
|
22
|
+
export declare function contrastText(bg: string): string;
|
|
23
|
+
/**
|
|
24
|
+
* The **axis indicator pill** look — a *solid* filled tag in `color` with
|
|
25
|
+
* auto-contrast text (the ChartIQ / Yahoo price-tag). Distinct from
|
|
26
|
+
* {@link flagChipStyle} (a light in-plot value chip): an on-axis indicator reads
|
|
27
|
+
* as a saturated pill covering the tick, not a floating readout. Note: it does
|
|
28
|
+
* **not** set `lineHeight` — it inherits `normal`, matching a bare tick label, so
|
|
29
|
+
* a pill anchored at the same offset lines up with its tick-label neighbours (a
|
|
30
|
+
* forced lineHeight would shift the text off the tick baseline). Shared by
|
|
31
|
+
* {@link YAxisIndicator}, the crosshair axis pills, and the Baseline/Marker
|
|
32
|
+
* `indicator` pills.
|
|
33
|
+
*/
|
|
34
|
+
export declare function axisPillStyle(theme: ChartTheme, color: string): CSSProperties;
|
|
35
|
+
/**
|
|
36
|
+
* A small triangle on an axis pill's **plot-facing edge**, pointing into the
|
|
37
|
+
* plot at the value (the callout tab). For a `right`-side pill (extending right
|
|
38
|
+
* across the gutter) it sits on the pill's left edge pointing left; for a `left`
|
|
39
|
+
* pill, the mirror. Render as an absolutely-positioned child of the pill (the
|
|
40
|
+
* pill is itself absolute, so it's the containing block); colour matches the pill.
|
|
41
|
+
*/
|
|
42
|
+
export declare function pointerStyle(side: 'left' | 'right', color: string): CSSProperties;
|
|
43
|
+
/**
|
|
44
|
+
* CSS placing a value pill **on the axis gutter** at `side`: anchor its inner
|
|
45
|
+
* edge at the plot boundary (`plotWidth`) and let it overflow outward across the
|
|
46
|
+
* reserved gutter (the plot div doesn't clip), lifted with `zIndex` above the
|
|
47
|
+
* sibling axis column (rendered later in the row) so it covers the tick behind
|
|
48
|
+
* it. Shared by {@link YAxisIndicator}'s `placement='axis'` and the crosshair
|
|
49
|
+
* cursor's per-series value pills, so both sit identically on the axis.
|
|
50
|
+
*/
|
|
51
|
+
export declare function axisPillX(side: 'left' | 'right', plotWidth: number): CSSProperties;
|
|
16
52
|
/**
|
|
17
53
|
* Horizontal placement for a flag chip beside a vertical pole at plot-x `x`:
|
|
18
54
|
* `FLAG_GAP` to the right, flipping to the left near the right edge so it stays
|
package/dist/chip.js
CHANGED
|
@@ -14,7 +14,9 @@ export function flagChipStyle(theme) {
|
|
|
14
14
|
return {
|
|
15
15
|
position: 'absolute',
|
|
16
16
|
background: theme.chip?.background,
|
|
17
|
-
|
|
17
|
+
// Square corners — a flag is a filled panel behind the number, not a pill
|
|
18
|
+
// (the rounded pill is reserved for axis indicators, see `axisPillStyle`).
|
|
19
|
+
borderRadius: '0',
|
|
18
20
|
padding: '0 4px',
|
|
19
21
|
fontFamily: theme.font.family,
|
|
20
22
|
fontSize: `${theme.font.size}px`,
|
|
@@ -24,6 +26,88 @@ export function flagChipStyle(theme) {
|
|
|
24
26
|
lineHeight: 1.5,
|
|
25
27
|
};
|
|
26
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Pick a readable text colour (near-black or white) for text drawn **on top of**
|
|
31
|
+
* `bg`, by its sRGB relative luminance. Handles `#rgb`/`#rrggbb` (the theme
|
|
32
|
+
* palette); any other CSS colour falls back to white. So a saturated blue/red/
|
|
33
|
+
* teal pill gets white text, a pale turquoise pill gets dark text.
|
|
34
|
+
*/
|
|
35
|
+
export function contrastText(bg) {
|
|
36
|
+
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(bg.trim());
|
|
37
|
+
const raw = m?.[1];
|
|
38
|
+
if (raw === undefined)
|
|
39
|
+
return '#ffffff';
|
|
40
|
+
const h = raw.length === 3
|
|
41
|
+
? raw
|
|
42
|
+
.split('')
|
|
43
|
+
.map((c) => c + c)
|
|
44
|
+
.join('')
|
|
45
|
+
: raw;
|
|
46
|
+
const r = parseInt(h.slice(0, 2), 16) / 255;
|
|
47
|
+
const g = parseInt(h.slice(2, 4), 16) / 255;
|
|
48
|
+
const b = parseInt(h.slice(4, 6), 16) / 255;
|
|
49
|
+
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
50
|
+
return lum > 0.6 ? '#0b1220' : '#ffffff';
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The **axis indicator pill** look — a *solid* filled tag in `color` with
|
|
54
|
+
* auto-contrast text (the ChartIQ / Yahoo price-tag). Distinct from
|
|
55
|
+
* {@link flagChipStyle} (a light in-plot value chip): an on-axis indicator reads
|
|
56
|
+
* as a saturated pill covering the tick, not a floating readout. Note: it does
|
|
57
|
+
* **not** set `lineHeight` — it inherits `normal`, matching a bare tick label, so
|
|
58
|
+
* a pill anchored at the same offset lines up with its tick-label neighbours (a
|
|
59
|
+
* forced lineHeight would shift the text off the tick baseline). Shared by
|
|
60
|
+
* {@link YAxisIndicator}, the crosshair axis pills, and the Baseline/Marker
|
|
61
|
+
* `indicator` pills.
|
|
62
|
+
*/
|
|
63
|
+
export function axisPillStyle(theme, color) {
|
|
64
|
+
return {
|
|
65
|
+
position: 'absolute',
|
|
66
|
+
background: color,
|
|
67
|
+
color: contrastText(color),
|
|
68
|
+
borderRadius: '3px',
|
|
69
|
+
padding: '0 4px',
|
|
70
|
+
fontFamily: theme.font.family,
|
|
71
|
+
fontSize: `${theme.font.size}px`,
|
|
72
|
+
fontVariantNumeric: 'tabular-nums',
|
|
73
|
+
whiteSpace: 'nowrap',
|
|
74
|
+
pointerEvents: 'none',
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* A small triangle on an axis pill's **plot-facing edge**, pointing into the
|
|
79
|
+
* plot at the value (the callout tab). For a `right`-side pill (extending right
|
|
80
|
+
* across the gutter) it sits on the pill's left edge pointing left; for a `left`
|
|
81
|
+
* pill, the mirror. Render as an absolutely-positioned child of the pill (the
|
|
82
|
+
* pill is itself absolute, so it's the containing block); colour matches the pill.
|
|
83
|
+
*/
|
|
84
|
+
export function pointerStyle(side, color) {
|
|
85
|
+
return {
|
|
86
|
+
position: 'absolute',
|
|
87
|
+
top: '50%',
|
|
88
|
+
transform: 'translateY(-50%)',
|
|
89
|
+
width: 0,
|
|
90
|
+
height: 0,
|
|
91
|
+
borderTop: '4px solid transparent',
|
|
92
|
+
borderBottom: '4px solid transparent',
|
|
93
|
+
...(side === 'right'
|
|
94
|
+
? { left: '-5px', borderRight: `5px solid ${color}` }
|
|
95
|
+
: { right: '-5px', borderLeft: `5px solid ${color}` }),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* CSS placing a value pill **on the axis gutter** at `side`: anchor its inner
|
|
100
|
+
* edge at the plot boundary (`plotWidth`) and let it overflow outward across the
|
|
101
|
+
* reserved gutter (the plot div doesn't clip), lifted with `zIndex` above the
|
|
102
|
+
* sibling axis column (rendered later in the row) so it covers the tick behind
|
|
103
|
+
* it. Shared by {@link YAxisIndicator}'s `placement='axis'` and the crosshair
|
|
104
|
+
* cursor's per-series value pills, so both sit identically on the axis.
|
|
105
|
+
*/
|
|
106
|
+
export function axisPillX(side, plotWidth) {
|
|
107
|
+
return side === 'right'
|
|
108
|
+
? { left: `${plotWidth}px`, zIndex: 3 }
|
|
109
|
+
: { right: `${plotWidth}px`, zIndex: 3 };
|
|
110
|
+
}
|
|
27
111
|
/** Gap (px) between a flag chip and its pole — the cursor staff or an annotation's
|
|
28
112
|
* line — so the chip floats just beside the pole rather than sitting on it. */
|
|
29
113
|
const FLAG_GAP = 4;
|
package/dist/context.d.ts
CHANGED
|
@@ -233,6 +233,10 @@ export interface AnnotationSpec {
|
|
|
233
233
|
/** The mark's resolved label text — used to pack overlapping top-flag labels
|
|
234
234
|
* (markers + regions) into stacked vertical lanes. */
|
|
235
235
|
readonly label: string;
|
|
236
|
+
/** Whether this mark shows its value as an **axis-edge pill** — a marker on the
|
|
237
|
+
* shared x-axis (drawn by `<XAxis>` at its `at`), a baseline on its y-axis
|
|
238
|
+
* (drawn in place). Regions never set it. */
|
|
239
|
+
readonly indicator: boolean;
|
|
236
240
|
}
|
|
237
241
|
/**
|
|
238
242
|
* A row's per-slot axis widths each side, **slot 0 nearest the plot** (so the
|
|
@@ -368,8 +372,12 @@ export interface TrackerInfo {
|
|
|
368
372
|
* - `inline` — dots + a value chip beside each.
|
|
369
373
|
* - `flag` — dots + value flags (a staffed flag from each point; the staff
|
|
370
374
|
* geometry lands in a later phase — for now flags stack at the top).
|
|
375
|
+
* - `crosshair` — the synced vertical line + a dot on each series, with each
|
|
376
|
+
* series' value pinned to its y-axis edge (an on-axis pill) and the cursor
|
|
377
|
+
* time pinned to the x-axis. The ChartIQ / trading-terminal readout. Values
|
|
378
|
+
* snap to the series (the axis pills read like ticks), not the raw mouse Y.
|
|
371
379
|
*/
|
|
372
|
-
export type CursorMode = 'none' | 'line' | 'point' | 'inline' | 'flag';
|
|
380
|
+
export type CursorMode = 'none' | 'line' | 'point' | 'inline' | 'flag' | 'crosshair';
|
|
373
381
|
/** A registered layer plus the axis id it draws against. */
|
|
374
382
|
export interface LayerEntry {
|
|
375
383
|
readonly layer: RowLayer;
|
|
@@ -431,6 +439,9 @@ export interface RowFrame {
|
|
|
431
439
|
* that set `<YAxis ticks>` — so `Layers` draws gridlines at the same positions
|
|
432
440
|
* the axis labels. Absent id ⇒ that axis auto-picks. */
|
|
433
441
|
readonly tickValues: ReadonlyMap<string, readonly number[]>;
|
|
442
|
+
/** The side each axis sits on, keyed by id — so an axis-edge overlay (the
|
|
443
|
+
* crosshair value pills) hugs the correct gutter. */
|
|
444
|
+
readonly axisSides: ReadonlyMap<string, 'left' | 'right'>;
|
|
434
445
|
/** This row's cursor-mode override, or `undefined` to inherit the container's
|
|
435
446
|
* default ({@link ContainerFrame.cursor}). */
|
|
436
447
|
readonly cursor: CursorMode | undefined;
|
package/dist/index.d.ts
CHANGED
|
@@ -45,6 +45,8 @@ export type { BarChartProps } from './BarChart.js';
|
|
|
45
45
|
export { Region, Baseline, Marker } from './annotations.js';
|
|
46
46
|
export type { RegionProps, BaselineProps, MarkerProps } from './annotations.js';
|
|
47
47
|
export type { AnnotationKind, CreateSpec } from './context.js';
|
|
48
|
+
export { YAxisIndicator, createLiveValue } from './indicators.js';
|
|
49
|
+
export type { YAxisIndicatorProps, LiveValue } from './indicators.js';
|
|
48
50
|
export { fromTimeSeries, bandFromTimeSeries, boxFromTimeSeries, barsFromTimeSeries, } from './data.js';
|
|
49
51
|
export type { ChartSeries, BandSeries, BoxSeries, BoxColumns, BarSeries, } from './data.js';
|
|
50
52
|
export type { RadiusEncoding, ColorEncoding } from './encoding.js';
|
package/dist/index.js
CHANGED
|
@@ -32,6 +32,9 @@ export { BarChart } from './BarChart.js';
|
|
|
32
32
|
// Annotations — user-authored marks in the turquoise register (distinct from the
|
|
33
33
|
// data): a shaded span, a horizontal value line, a vertical x line.
|
|
34
34
|
export { Region, Baseline, Marker } from './annotations.js';
|
|
35
|
+
// Axis indicators — a value pill pinned to an axis edge (the ChartIQ live tag).
|
|
36
|
+
// `createLiveValue` is the high-frequency, isolated-repaint update path.
|
|
37
|
+
export { YAxisIndicator, createLiveValue } from './indicators.js';
|
|
35
38
|
export { fromTimeSeries, bandFromTimeSeries, boxFromTimeSeries, barsFromTimeSeries, } from './data.js';
|
|
36
39
|
export { defaultTheme, estelaTheme } from './theme.js';
|
|
37
40
|
// CSS-custom-property → ChartTheme bridge: build a theme from a design system's
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { type AxisFormat } from './format.js';
|
|
2
|
+
/**
|
|
3
|
+
* A **live scalar** an axis indicator subscribes to, pushed imperatively from
|
|
4
|
+
* outside React — a WebSocket `onmessage`, a `requestAnimationFrame` loop, a
|
|
5
|
+
* tick handler. Backed by `useSyncExternalStore` on the consuming indicator:
|
|
6
|
+
* calling {@link LiveValue.set} re-renders **only the indicators subscribed to
|
|
7
|
+
* this value** — never the chart tree, never a canvas repaint. This is the path
|
|
8
|
+
* for a value that ticks many times a second (a live last-price tag), set
|
|
9
|
+
* independently of the series' own last point.
|
|
10
|
+
*
|
|
11
|
+
* Create one with {@link createLiveValue} and pass it to
|
|
12
|
+
* `<YAxisIndicator source={…}>`.
|
|
13
|
+
*/
|
|
14
|
+
export interface LiveValue {
|
|
15
|
+
/** Push a new value. Re-renders subscribed indicators only; a no-op if the
|
|
16
|
+
* value is unchanged. Safe to call from outside React at any frequency. */
|
|
17
|
+
set(value: number): void;
|
|
18
|
+
/** @internal Store subscribe, for `useSyncExternalStore`. */
|
|
19
|
+
subscribe(onStoreChange: () => void): () => void;
|
|
20
|
+
/** @internal Current value snapshot, for `useSyncExternalStore`. */
|
|
21
|
+
getSnapshot(): number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Create a {@link LiveValue} seeded at `initial`. Hold the returned object,
|
|
25
|
+
* call `.set(v)` from your data source, and hand it to
|
|
26
|
+
* `<YAxisIndicator source={…}>` — the pill repositions and relabels on each
|
|
27
|
+
* `set` without re-rendering the chart.
|
|
28
|
+
*
|
|
29
|
+
* ```ts
|
|
30
|
+
* const price = createLiveValue(0);
|
|
31
|
+
* ws.onmessage = (e) => price.set(JSON.parse(e.data).last); // outside React
|
|
32
|
+
* // <YAxisIndicator source={price} color="#4af" format=",.2f" />
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export declare function createLiveValue(initial: number): LiveValue;
|
|
36
|
+
export interface YAxisIndicatorProps {
|
|
37
|
+
/**
|
|
38
|
+
* A static value to pin the pill at. Pass this **or** {@link source}. Updating
|
|
39
|
+
* `value` re-renders with its parent — fine for an occasional change; for a
|
|
40
|
+
* high-frequency tick use `source` so only the pill repaints.
|
|
41
|
+
*/
|
|
42
|
+
value?: number;
|
|
43
|
+
/**
|
|
44
|
+
* A {@link LiveValue} to subscribe to — the high-frequency path. `.set(v)`
|
|
45
|
+
* moves and relabels the pill **without re-rendering the chart**. Takes
|
|
46
|
+
* precedence over {@link value} if both are given.
|
|
47
|
+
*/
|
|
48
|
+
source?: LiveValue;
|
|
49
|
+
/** Which `<YAxis>` (by id) to position against; omit for the row's default axis. */
|
|
50
|
+
axis?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Which edge the pill hugs. Default `right` — the conventional side for a live
|
|
53
|
+
* value tag. (Independent of the linked axis's side; set it to match.)
|
|
54
|
+
*/
|
|
55
|
+
side?: 'left' | 'right';
|
|
56
|
+
/**
|
|
57
|
+
* Pill hue — the colour of the series / value it tracks. Defaults to the axis
|
|
58
|
+
* label colour (`theme.axis.label`).
|
|
59
|
+
*/
|
|
60
|
+
color?: string;
|
|
61
|
+
/**
|
|
62
|
+
* Value formatting: a d3 format specifier (e.g. `',.2f'`, `'.1%'`) or a
|
|
63
|
+
* `(value) => string`. Omit to use the linked axis's own formatter, so the pill
|
|
64
|
+
* reads exactly like a tick. Pass a specifier for finer precision than the
|
|
65
|
+
* tick-calibrated default (a live price usually wants `',.2f'`, not the
|
|
66
|
+
* coarser tick rounding). See {@link AxisFormat}.
|
|
67
|
+
*
|
|
68
|
+
* An indicator **always shows the axis value** — there is no label override. A
|
|
69
|
+
* name/annotation belongs on a `<Baseline label>`'s near-line chip, not on the
|
|
70
|
+
* axis pill (an axis pill reads like a tick).
|
|
71
|
+
*/
|
|
72
|
+
format?: AxisFormat;
|
|
73
|
+
/**
|
|
74
|
+
* Draw a thin dashed guide line from the pill across the plot (the ChartIQ
|
|
75
|
+
* "price line"). Default `false`.
|
|
76
|
+
*/
|
|
77
|
+
line?: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Add a small triangle on the pill's plot-facing edge, pointing **into** the
|
|
80
|
+
* plot at the value (a callout tab). Default `false`.
|
|
81
|
+
*/
|
|
82
|
+
pointer?: boolean;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* A **value pill pinned to a y-axis edge** — the ChartIQ / Yahoo-Finance live
|
|
86
|
+
* price tag. Positions at `yScale(value)` on the linked axis and renders a chip
|
|
87
|
+
* (the solid {@link axisPillStyle} pill) at the plot's `side` edge, optionally
|
|
88
|
+
* with a dashed guide line across the plot.
|
|
89
|
+
*
|
|
90
|
+
* Render it as a child of `<Layers>` (alongside the chart layers), so it shares
|
|
91
|
+
* the plot's coordinate space:
|
|
92
|
+
*
|
|
93
|
+
* ```tsx
|
|
94
|
+
* <Layers>
|
|
95
|
+
* <LineChart series={price} axis="usd" />
|
|
96
|
+
* <YAxisIndicator source={liveLast} axis="usd" color="#4af" format=",.2f" line />
|
|
97
|
+
* </Layers>
|
|
98
|
+
* ```
|
|
99
|
+
*
|
|
100
|
+
* The value is **decoupled from the series' last point** — feed it whatever the
|
|
101
|
+
* live feed reports. For high-frequency updates pass a {@link LiveValue}
|
|
102
|
+
* ({@link source}); `.set()` repaints only the pill.
|
|
103
|
+
*/
|
|
104
|
+
export declare function YAxisIndicator({ value, source, axis, side, color, format, line, pointer, }: YAxisIndicatorProps): import("react/jsx-runtime").JSX.Element | null;
|
|
105
|
+
//# sourceMappingURL=indicators.d.ts.map
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useContext, useSyncExternalStore } from 'react';
|
|
3
|
+
import { ContainerContext, RowContext } from './context.js';
|
|
4
|
+
import { axisPillStyle, axisPillX, pointerStyle } from './chip.js';
|
|
5
|
+
import { resolveAxisFormat } from './format.js';
|
|
6
|
+
/**
|
|
7
|
+
* Create a {@link LiveValue} seeded at `initial`. Hold the returned object,
|
|
8
|
+
* call `.set(v)` from your data source, and hand it to
|
|
9
|
+
* `<YAxisIndicator source={…}>` — the pill repositions and relabels on each
|
|
10
|
+
* `set` without re-rendering the chart.
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* const price = createLiveValue(0);
|
|
14
|
+
* ws.onmessage = (e) => price.set(JSON.parse(e.data).last); // outside React
|
|
15
|
+
* // <YAxisIndicator source={price} color="#4af" format=",.2f" />
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export function createLiveValue(initial) {
|
|
19
|
+
let value = initial;
|
|
20
|
+
const listeners = new Set();
|
|
21
|
+
return {
|
|
22
|
+
set(v) {
|
|
23
|
+
// Skip a redundant notify — a repeated identical tick shouldn't wake the
|
|
24
|
+
// subscriber (getSnapshot must be stable between real changes anyway).
|
|
25
|
+
if (v === value)
|
|
26
|
+
return;
|
|
27
|
+
value = v;
|
|
28
|
+
for (const listener of listeners)
|
|
29
|
+
listener();
|
|
30
|
+
},
|
|
31
|
+
subscribe(onStoreChange) {
|
|
32
|
+
listeners.add(onStoreChange);
|
|
33
|
+
return () => {
|
|
34
|
+
listeners.delete(onStoreChange);
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
getSnapshot() {
|
|
38
|
+
return value;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const noopSubscribe = () => () => { };
|
|
43
|
+
/** The full-plot overlay the guide line paints into — above the data canvas,
|
|
44
|
+
* inert to the pointer (matches the annotations' `overlayStyle`). */
|
|
45
|
+
const overlayStyle = {
|
|
46
|
+
position: 'absolute',
|
|
47
|
+
top: 0,
|
|
48
|
+
left: 0,
|
|
49
|
+
pointerEvents: 'none',
|
|
50
|
+
};
|
|
51
|
+
const TICK_COUNT = 5;
|
|
52
|
+
/**
|
|
53
|
+
* A **value pill pinned to a y-axis edge** — the ChartIQ / Yahoo-Finance live
|
|
54
|
+
* price tag. Positions at `yScale(value)` on the linked axis and renders a chip
|
|
55
|
+
* (the solid {@link axisPillStyle} pill) at the plot's `side` edge, optionally
|
|
56
|
+
* with a dashed guide line across the plot.
|
|
57
|
+
*
|
|
58
|
+
* Render it as a child of `<Layers>` (alongside the chart layers), so it shares
|
|
59
|
+
* the plot's coordinate space:
|
|
60
|
+
*
|
|
61
|
+
* ```tsx
|
|
62
|
+
* <Layers>
|
|
63
|
+
* <LineChart series={price} axis="usd" />
|
|
64
|
+
* <YAxisIndicator source={liveLast} axis="usd" color="#4af" format=",.2f" line />
|
|
65
|
+
* </Layers>
|
|
66
|
+
* ```
|
|
67
|
+
*
|
|
68
|
+
* The value is **decoupled from the series' last point** — feed it whatever the
|
|
69
|
+
* live feed reports. For high-frequency updates pass a {@link LiveValue}
|
|
70
|
+
* ({@link source}); `.set()` repaints only the pill.
|
|
71
|
+
*/
|
|
72
|
+
export function YAxisIndicator({ value, source, axis, side = 'right', color, format, line = false, pointer = false, }) {
|
|
73
|
+
const container = useContext(ContainerContext);
|
|
74
|
+
if (container === null) {
|
|
75
|
+
throw new Error('<YAxisIndicator> must be rendered inside a <ChartContainer>');
|
|
76
|
+
}
|
|
77
|
+
const row = useContext(RowContext);
|
|
78
|
+
if (row === null) {
|
|
79
|
+
throw new Error('<YAxisIndicator> must be rendered inside a <ChartRow>');
|
|
80
|
+
}
|
|
81
|
+
// One unconditional hook that covers both paths: with a `source`, subscribe to
|
|
82
|
+
// its store (a `set` re-renders only this component); without one, a stable
|
|
83
|
+
// no-op subscribe + a snapshot that reads the static `value` prop (which
|
|
84
|
+
// re-renders with the parent). Either way `v` is the current value.
|
|
85
|
+
const v = useSyncExternalStore(source ? source.subscribe : noopSubscribe, source ? source.getSnapshot : () => value ?? NaN);
|
|
86
|
+
const { theme } = container;
|
|
87
|
+
const axisId = axis ?? row.defaultAxisId;
|
|
88
|
+
const yScale = row.yScales.get(axisId);
|
|
89
|
+
// Axis not resolved yet (a layer mounts before its <YAxis>), or no value fed —
|
|
90
|
+
// draw nothing rather than guess.
|
|
91
|
+
if (yScale === undefined || !Number.isFinite(v))
|
|
92
|
+
return null;
|
|
93
|
+
const resolvedColor = color ?? theme.axis.label;
|
|
94
|
+
// A caller `format` resolves against the scale (string specifier or fn);
|
|
95
|
+
// otherwise reuse the axis's own formatter so the pill reads like a tick.
|
|
96
|
+
const fmt = format
|
|
97
|
+
? resolveAxisFormat(yScale, TICK_COUNT, format)
|
|
98
|
+
: row.formats.get(axisId);
|
|
99
|
+
// An indicator always shows the axis value (no label override — a name belongs
|
|
100
|
+
// on a Baseline's near-line chip, not the axis pill).
|
|
101
|
+
const text = fmt ? fmt(v) : String(v);
|
|
102
|
+
const rawY = yScale(v);
|
|
103
|
+
// Clamp the pill's centre so an off-scale value keeps it inside the row rather
|
|
104
|
+
// than half-overflowing the edge (matches the y-tick clamp, F-charts-6).
|
|
105
|
+
const half = theme.font.size / 2 + 1;
|
|
106
|
+
const top = Math.max(half, Math.min(row.height - half, rawY));
|
|
107
|
+
return (_jsxs(_Fragment, { children: [line && (_jsx("svg", { width: container.plotWidth, height: row.height, style: overlayStyle, children: _jsx("line", { x1: 0, y1: rawY, x2: container.plotWidth, y2: rawY, stroke: resolvedColor, strokeWidth: 1, opacity: 0.5, strokeDasharray: "3 3", shapeRendering: "crispEdges" }) })), _jsxs("div", { style: {
|
|
108
|
+
...axisPillStyle(theme, resolvedColor),
|
|
109
|
+
top: `${top}px`,
|
|
110
|
+
...axisPillX(side, container.plotWidth),
|
|
111
|
+
transform: 'translateY(-50%)',
|
|
112
|
+
}, children: [pointer && _jsx("span", { style: pointerStyle(side, resolvedColor) }), text] })] }));
|
|
113
|
+
}
|
|
114
|
+
//# sourceMappingURL=indicators.js.map
|
package/dist/tracker.d.ts
CHANGED
|
@@ -18,7 +18,7 @@ export declare const DEFAULT_CURSOR_MODE: CursorMode;
|
|
|
18
18
|
export declare function cursorParts(mode: CursorMode): {
|
|
19
19
|
readonly line: boolean;
|
|
20
20
|
readonly dots: boolean;
|
|
21
|
-
readonly chip: 'none' | 'inline' | 'flag';
|
|
21
|
+
readonly chip: 'none' | 'inline' | 'flag' | 'axis';
|
|
22
22
|
};
|
|
23
23
|
/**
|
|
24
24
|
* The crosshair's plot-pixel x from the tracker inputs. A controlled
|
package/dist/tracker.js
CHANGED
|
@@ -24,6 +24,10 @@ export function cursorParts(mode) {
|
|
|
24
24
|
return { line: false, dots: true, chip: 'inline' };
|
|
25
25
|
case 'flag':
|
|
26
26
|
return { line: false, dots: true, chip: 'flag' };
|
|
27
|
+
case 'crosshair':
|
|
28
|
+
// Vertical line + per-series dots, values pinned to the axes (y pills in
|
|
29
|
+
// `Layers`, the x-time pill on `<XAxis>`).
|
|
30
|
+
return { line: true, dots: true, chip: 'axis' };
|
|
27
31
|
case 'none':
|
|
28
32
|
return { line: false, dots: false, chip: 'none' };
|
|
29
33
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pond-ts/charts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Canvas-rendered, streaming-first time-series charts for pond-ts",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"perf": "PERF_BENCH=1 playwright test perf.spec.ts --workers=1"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
|
-
"@pond-ts/react": "^0.
|
|
42
|
-
"pond-ts": "^0.
|
|
41
|
+
"@pond-ts/react": "^0.38.0",
|
|
42
|
+
"pond-ts": "^0.38.0",
|
|
43
43
|
"react": "^18.0.0 || ^19.0.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|