@pond-ts/charts 0.31.2 → 0.32.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/dist/context.d.ts CHANGED
@@ -75,6 +75,13 @@ export interface ContainerFrame {
75
75
  /** Show the cursor's time atop the in-chart readout (when a row's cursor draws
76
76
  * one), formatted by {@link formatTime} to match the time axis. */
77
77
  readonly cursorTime: boolean;
78
+ /**
79
+ * Whether the chart is in **annotation-edit mode** — suppresses the data cursor
80
+ * and makes editable annotations interactive (hovering reveals their handles +
81
+ * highlights them, dragging edits them). Set by the container's
82
+ * `editAnnotations` prop; annotations read it to switch from inert to interactive.
83
+ */
84
+ readonly editAnnotations: boolean;
78
85
  /** Format an epoch-ms instant the same way the time axis labels its ticks —
79
86
  * shared by `<TimeAxis>` and the cursor-time readout. */
80
87
  readonly formatTime: (epochMs: number) => string;
@@ -127,6 +134,105 @@ export interface ContainerFrame {
127
134
  registerRow(key: symbol): () => void;
128
135
  /** The first (topmost) row's key, or `null` before any row has registered. */
129
136
  readonly firstRowKey: symbol | null;
137
+ /**
138
+ * Register an annotation (`<Region>`/`<Marker>`/`<Baseline>`) so the container
139
+ * can coordinate what a mark can't do in isolation: draw each mark's **guide
140
+ * line** across the *other* rows, resolve cross-region z-order, and serve
141
+ * **snap targets** to a drag. Keyed by the mark's per-instance slot key;
142
+ * unregister on unmount.
143
+ */
144
+ registerAnnotation(key: symbol, spec: AnnotationSpec): void;
145
+ unregisterAnnotation(key: symbol): void;
146
+ /** Every registered annotation — read by each row to draw the *other* rows'
147
+ * guides, and by a drag to find snap targets. */
148
+ readonly annotations: readonly AnnotationSpec[];
149
+ /** Vertical lane (0 = top) for each top-flag label, by slot key — overlapping
150
+ * marker/region labels stack into lower lanes instead of colliding. A key absent
151
+ * from the map (or mapping to 0) sits in the top lane. */
152
+ readonly labelLanes: ReadonlyMap<symbol, number>;
153
+ /**
154
+ * The armed creation tool, or `null` (idle). Set by the consumer's toolbar;
155
+ * when non-null the plot captures a **create gesture** (draw a new mark) instead
156
+ * of panning, and fires {@link onCreate} on release. While armed, existing
157
+ * marks' edit handles stand down so the draw owns the surface.
158
+ */
159
+ readonly creating: AnnotationKind | null;
160
+ /**
161
+ * Snap mode — the toolbar's "Snap". When on, a dragged mark snaps to other
162
+ * marks' **guidelines** (their x-positions) so spans align; off = free
163
+ * placement. Read by {@link snapToGuides}. (Data-sample snapping — landing on a
164
+ * clean `5:12` rather than `5:11:47` — is a future extension, not yet wired.)
165
+ */
166
+ readonly snap: boolean;
167
+ /** Fired when a create gesture completes (on release). The consumer adds the
168
+ * mark, disarms ({@link creating} → `null`), and selects it. `undefined` ⇒
169
+ * creation is a no-op (the gesture still previews but commits nothing). */
170
+ readonly onCreate: ((spec: CreateSpec) => void) | undefined;
171
+ /** Fired when a mark is clicked (reports its `id`) or the plot is clicked empty
172
+ * (`null`) — the consumer updates its selection. A double-click on a region
173
+ * fires it too (the shortcut into a focused edit). */
174
+ readonly onSelectAnnotation: ((id: string | null) => void) | undefined;
175
+ /** Fired when the pointer enters a mark (reports its `id`) or leaves it (`null`),
176
+ * so the consumer can mirror hover out-of-band (e.g. a legend row). Pairs with a
177
+ * mark's controlled `hovered` prop to sync hover both ways. Works in any mode. */
178
+ readonly onHoverAnnotation: ((id: string | null) => void) | undefined;
179
+ /** Fired when a mark is **double-clicked** — the request to edit just that one.
180
+ * The consumer flips it into single-annotation edit (sets its `editing` prop),
181
+ * while the rest stay static. Distinct from {@link onSelectAnnotation} (single
182
+ * click = inspect-select). Works in any mode. */
183
+ readonly onEditAnnotation: ((id: string) => void) | undefined;
184
+ }
185
+ /** The kind of an annotation, and of a creation tool. */
186
+ export type AnnotationKind = 'region' | 'marker' | 'baseline';
187
+ /** What a completed create gesture reports to {@link ContainerFrame.onCreate} —
188
+ * the new mark's kind + position in axis units (+ the y-axis id for a baseline).
189
+ * (Which row a mark lands on is the consumer's call for now; multi-row routing is
190
+ * a follow-up.) */
191
+ export type CreateSpec = {
192
+ readonly kind: 'marker';
193
+ readonly at: number;
194
+ } | {
195
+ readonly kind: 'baseline';
196
+ readonly value: number;
197
+ readonly axis: string;
198
+ } | {
199
+ readonly kind: 'region';
200
+ readonly from: number;
201
+ readonly to: number;
202
+ };
203
+ /**
204
+ * A registered annotation as the container sees it — enough to draw its guide
205
+ * line on other rows, order it against other marks, and offer it as a snap target.
206
+ */
207
+ export interface AnnotationSpec {
208
+ /** The mark's per-instance slot key — its identity in the registry. */
209
+ readonly key: symbol;
210
+ /** The consumer's stable id (its `id` prop), if any — what a click /
211
+ * double-click reports via {@link ContainerFrame.onSelectAnnotation}, so the
212
+ * consumer knows which mark to select. */
213
+ readonly id: string | undefined;
214
+ readonly kind: AnnotationKind;
215
+ /** The row it lives on (its `<ChartRow>`'s key), so a row skips its own marks
216
+ * when drawing guides. */
217
+ readonly rowKey: symbol;
218
+ /**
219
+ * Its vertical-guide x-position(s) in **axis units** (the shared x): a marker's
220
+ * `[at]`, a region's `[from, to]`. Empty for a baseline — a horizontal line
221
+ * casts no vertical guide.
222
+ */
223
+ readonly xs: readonly number[];
224
+ /** Whether it's currently selected (controlled by the consumer). */
225
+ readonly selected: boolean;
226
+ /** Whether it's in single-annotation edit (the double-click target). The plot
227
+ * suppresses the data cursor while any mark is editing, as it does in global
228
+ * edit mode. */
229
+ readonly editing: boolean;
230
+ /** Whether it accepts hover + selection. A non-selectable region is skipped by
231
+ * the double-click hit-test (it's inert background context). */
232
+ readonly selectable: boolean;
233
+ /** The mark's resolved label text — used to pack overlapping top-flag labels
234
+ * (markers + regions) into stacked vertical lanes. */
235
+ readonly label: string;
130
236
  }
131
237
  /**
132
238
  * A row's per-slot axis widths each side, **slot 0 nearest the plot** (so the
@@ -293,6 +399,9 @@ export interface AxisSpec {
293
399
  /** Value formatting for the tick labels + the cursor readout ({@link AxisFormat}),
294
400
  * or `undefined` for the scale's d3 default. */
295
401
  readonly format: AxisFormat | undefined;
402
+ /** Explicit tick values (from `<YAxis ticks>`), driving BOTH the axis labels
403
+ * and the row's gridlines so they align; `undefined` auto-picks from the scale. */
404
+ readonly tickValues: readonly number[] | undefined;
296
405
  /**
297
406
  * Declaration position among the row's children, injected by `ChartRow`. The
298
407
  * row sorts axes by this, so the **first declared** axis is the default
@@ -314,12 +423,19 @@ export interface RowFrame {
314
423
  * against its scale) — used by both the tick labels and the cursor readout, so
315
424
  * a value reads identically in both. */
316
425
  readonly formats: ReadonlyMap<string, (value: number) => string>;
426
+ /** Explicit tick values per axis id (from {@link AxisSpec.tickValues}), for axes
427
+ * that set `<YAxis ticks>` — so `Layers` draws gridlines at the same positions
428
+ * the axis labels. Absent id ⇒ that axis auto-picks. */
429
+ readonly tickValues: ReadonlyMap<string, readonly number[]>;
317
430
  /** This row's cursor-mode override, or `undefined` to inherit the container's
318
431
  * default ({@link ContainerFrame.cursor}). */
319
432
  readonly cursor: CursorMode | undefined;
320
433
  /** Whether this is the first (topmost) row — the shared cursor-time chip shows
321
434
  * here only, not repeated on every row. Derived from {@link ContainerFrame.firstRowKey}. */
322
435
  readonly isFirstRow: boolean;
436
+ /** This row's per-instance key — annotations register it so the container can
437
+ * draw a mark's guide on the *other* rows (a row skips its own marks). */
438
+ readonly rowKey: symbol;
323
439
  /** The axis a layer uses when it names none (the first declared, or implicit). */
324
440
  readonly defaultAxisId: string;
325
441
  /**
package/dist/data.d.ts CHANGED
@@ -118,6 +118,20 @@ export declare function fromValueSeries<VS extends ValueSeriesSchema>(series: Va
118
118
  * @throws TypeError if `lower` or `upper` is not a numeric column.
119
119
  */
120
120
  export declare function bandFromTimeSeries<S extends SeriesSchema>(series: TimeSeries<S>, lower: string, upper: string): BandSeries;
121
+ /**
122
+ * Build a {@link BandSeries} from a pond `ValueSeries` — the value-axis sibling
123
+ * of {@link bandFromTimeSeries}. The x axis is the series' monotonic value axis
124
+ * (`axisValues()`, e.g. cumulative distance) instead of time; `lower`/`upper`
125
+ * name the two numeric edge columns (typically `rollingByColumn` percentiles).
126
+ * The resulting `BandSeries` is identical in shape — the chart draws it exactly
127
+ * as a time band, only the x scale differs (a value scale rather than
128
+ * `scaleTime`). A sample with either edge missing reads as a gap in the fill
129
+ * (same contract as {@link bandFromTimeSeries}).
130
+ *
131
+ * @throws RangeError if `lower` or `upper` does not exist.
132
+ * @throws TypeError if `lower` or `upper` is not a numeric column.
133
+ */
134
+ export declare function bandFromValueSeries<VS extends ValueSeriesSchema>(series: ValueSeries<VS>, lower: string, upper: string): BandSeries;
121
135
  /**
122
136
  * Build a {@link BoxSeries} from a pond `TimeSeries` — five numeric quantile
123
137
  * columns (`lower`/`q1`/`median`/`q3`/`upper`) sharing the series' interval time
@@ -151,4 +165,24 @@ export declare function boxFromTimeSeries<S extends SeriesSchema>(series: TimeSe
151
165
  * @throws TypeError if `column` is not a numeric column.
152
166
  */
153
167
  export declare function barsFromTimeSeries<S extends SeriesSchema>(series: TimeSeries<S>, column: string): BarSeries;
168
+ /**
169
+ * Build a {@link BarSeries} from a pond `ValueSeries` — the value-axis sibling
170
+ * of {@link barsFromTimeSeries}. A `ValueSeries` is **point-keyed** on its value
171
+ * axis (one axis value per row, no per-row span), so — exactly like the
172
+ * point-keyed `time` case of {@link barsFromTimeSeries} — each bar is centred on
173
+ * its axis value and synthesises a span from **neighbour spacing**: it reaches
174
+ * halfway to each neighbour (a Voronoi cell on the value axis). The first / last
175
+ * bars mirror their single adjacent gap; a lone point (length 1) keeps zero
176
+ * width and falls back to the renderer's `minWidth`.
177
+ *
178
+ * For evenly-spaced contiguous keys (e.g. uniform splits centred on their
179
+ * midpoints) the cell boundaries land exactly on the segment boundaries; for
180
+ * unevenly-spaced keys a boundary sits at the midpoint between adjacent centres
181
+ * (a slight drift from a true segment edge — fine for the bar look; key an
182
+ * interval/timeRange `TimeSeries` instead if exact edges matter).
183
+ *
184
+ * @throws RangeError if `column` does not exist.
185
+ * @throws TypeError if `column` is not a numeric column.
186
+ */
187
+ export declare function barsFromValueSeries<VS extends ValueSeriesSchema>(series: ValueSeries<VS>, column: string): BarSeries;
154
188
  //# sourceMappingURL=data.d.ts.map
package/dist/data.js CHANGED
@@ -33,6 +33,31 @@ function readNumericColumn(series, column) {
33
33
  }
34
34
  return out;
35
35
  }
36
+ /**
37
+ * Read a numeric column from a `ValueSeries` into a `Float64Array`, missing
38
+ * cells as `NaN` — the value-axis sibling of {@link readNumericColumn}, sharing
39
+ * its per-element `read(i)` rationale (the bulk reader is tree-shaken away in
40
+ * bundled browser builds).
41
+ *
42
+ * @throws RangeError if `column` does not exist.
43
+ * @throws TypeError if `column` is not a numeric column.
44
+ */
45
+ function readValueColumn(series, column) {
46
+ const col = series.column(column);
47
+ if (col === undefined) {
48
+ throw new RangeError(`unknown column '${column}'`);
49
+ }
50
+ if (col.kind !== 'number') {
51
+ throw new TypeError(`column '${column}' must be numeric (got '${col.kind}')`);
52
+ }
53
+ const length = series.length;
54
+ const out = new Float64Array(length);
55
+ for (let i = 0; i < length; i += 1) {
56
+ const v = col.read(i);
57
+ out[i] = v === undefined ? NaN : v;
58
+ }
59
+ return out;
60
+ }
36
61
  /** The key column's `begin` buffer aligned to the logical length (zero-copy). */
37
62
  function timeAxis(series) {
38
63
  // `begin` may carry trailing capacity beyond the logical length; subarray so
@@ -78,21 +103,12 @@ export function fromTimeSeries(series, column) {
78
103
  * @throws TypeError if `column` is not a numeric column.
79
104
  */
80
105
  export function fromValueSeries(series, column) {
81
- const col = series.column(column);
82
- if (col === undefined) {
83
- throw new RangeError(`unknown column '${column}'`);
84
- }
85
- if (col.kind !== 'number') {
86
- throw new TypeError(`column '${column}' must be numeric (got '${col.kind}')`);
87
- }
88
- const length = series.length;
89
- const y = new Float64Array(length);
90
- for (let i = 0; i < length; i += 1) {
91
- const v = col.read(i);
92
- y[i] = v === undefined ? NaN : v;
93
- }
94
106
  // axisValues() is the key buffer already trimmed to length (zero-copy).
95
- return { x: series.axisValues(), y, length };
107
+ return {
108
+ x: series.axisValues(),
109
+ y: readValueColumn(series, column),
110
+ length: series.length,
111
+ };
96
112
  }
97
113
  /**
98
114
  * Build a {@link BandSeries} from a pond `TimeSeries` — two numeric columns for
@@ -111,6 +127,27 @@ export function bandFromTimeSeries(series, lower, upper) {
111
127
  length: series.length,
112
128
  };
113
129
  }
130
+ /**
131
+ * Build a {@link BandSeries} from a pond `ValueSeries` — the value-axis sibling
132
+ * of {@link bandFromTimeSeries}. The x axis is the series' monotonic value axis
133
+ * (`axisValues()`, e.g. cumulative distance) instead of time; `lower`/`upper`
134
+ * name the two numeric edge columns (typically `rollingByColumn` percentiles).
135
+ * The resulting `BandSeries` is identical in shape — the chart draws it exactly
136
+ * as a time band, only the x scale differs (a value scale rather than
137
+ * `scaleTime`). A sample with either edge missing reads as a gap in the fill
138
+ * (same contract as {@link bandFromTimeSeries}).
139
+ *
140
+ * @throws RangeError if `lower` or `upper` does not exist.
141
+ * @throws TypeError if `lower` or `upper` is not a numeric column.
142
+ */
143
+ export function bandFromValueSeries(series, lower, upper) {
144
+ return {
145
+ x: series.axisValues(),
146
+ lower: readValueColumn(series, lower),
147
+ upper: readValueColumn(series, upper),
148
+ length: series.length,
149
+ };
150
+ }
114
151
  /**
115
152
  * Build a {@link BoxSeries} from a pond `TimeSeries` — five numeric quantile
116
153
  * columns (`lower`/`q1`/`median`/`q3`/`upper`) sharing the series' interval time
@@ -194,4 +231,42 @@ export function barsFromTimeSeries(series, column) {
194
231
  }
195
232
  return { begin, end, y, length: n };
196
233
  }
234
+ /**
235
+ * Build a {@link BarSeries} from a pond `ValueSeries` — the value-axis sibling
236
+ * of {@link barsFromTimeSeries}. A `ValueSeries` is **point-keyed** on its value
237
+ * axis (one axis value per row, no per-row span), so — exactly like the
238
+ * point-keyed `time` case of {@link barsFromTimeSeries} — each bar is centred on
239
+ * its axis value and synthesises a span from **neighbour spacing**: it reaches
240
+ * halfway to each neighbour (a Voronoi cell on the value axis). The first / last
241
+ * bars mirror their single adjacent gap; a lone point (length 1) keeps zero
242
+ * width and falls back to the renderer's `minWidth`.
243
+ *
244
+ * For evenly-spaced contiguous keys (e.g. uniform splits centred on their
245
+ * midpoints) the cell boundaries land exactly on the segment boundaries; for
246
+ * unevenly-spaced keys a boundary sits at the midpoint between adjacent centres
247
+ * (a slight drift from a true segment edge — fine for the bar look; key an
248
+ * interval/timeRange `TimeSeries` instead if exact edges matter).
249
+ *
250
+ * @throws RangeError if `column` does not exist.
251
+ * @throws TypeError if `column` is not a numeric column.
252
+ */
253
+ export function barsFromValueSeries(series, column) {
254
+ const y = readValueColumn(series, column);
255
+ const n = series.length;
256
+ // axisValues() is the monotonic key buffer (zero-copy) — must not be mutated,
257
+ // so synthesise the spans into fresh buffers.
258
+ const ax = series.axisValues();
259
+ const begin = new Float64Array(n);
260
+ const end = new Float64Array(n);
261
+ for (let i = 0; i < n; i += 1) {
262
+ const x = ax[i];
263
+ // Half-gap to the previous neighbour (mirror the next gap at the left edge).
264
+ const prevGap = i > 0 ? x - ax[i - 1] : i + 1 < n ? ax[i + 1] - x : 0;
265
+ // Half-gap to the next neighbour (mirror the previous gap at the right edge).
266
+ const nextGap = i + 1 < n ? ax[i + 1] - x : i > 0 ? x - ax[i - 1] : 0;
267
+ begin[i] = x - prevGap / 2;
268
+ end[i] = x + nextGap / 2;
269
+ }
270
+ return { begin, end, y, length: n };
271
+ }
197
272
  //# sourceMappingURL=data.js.map
package/dist/index.d.ts CHANGED
@@ -42,6 +42,9 @@ export { BoxPlot } from './BoxPlot.js';
42
42
  export type { BoxPlotProps } from './BoxPlot.js';
43
43
  export { BarChart } from './BarChart.js';
44
44
  export type { BarChartProps } from './BarChart.js';
45
+ export { Region, Baseline, Marker } from './annotations.js';
46
+ export type { RegionProps, BaselineProps, MarkerProps } from './annotations.js';
47
+ export type { AnnotationKind, CreateSpec } from './context.js';
45
48
  export { fromTimeSeries, bandFromTimeSeries, boxFromTimeSeries, barsFromTimeSeries, } from './data.js';
46
49
  export type { ChartSeries, BandSeries, BoxSeries, BoxColumns, BarSeries, } from './data.js';
47
50
  export type { RadiusEncoding, ColorEncoding } from './encoding.js';
package/dist/index.js CHANGED
@@ -29,6 +29,9 @@ export { AreaChart } from './AreaChart.js';
29
29
  export { ScatterChart } from './ScatterChart.js';
30
30
  export { BoxPlot } from './BoxPlot.js';
31
31
  export { BarChart } from './BarChart.js';
32
+ // Annotations — user-authored marks in the turquoise register (distinct from the
33
+ // data): a shaded span, a horizontal value line, a vertical x line.
34
+ export { Region, Baseline, Marker } from './annotations.js';
32
35
  export { fromTimeSeries, bandFromTimeSeries, boxFromTimeSeries, barsFromTimeSeries, } from './data.js';
33
36
  export { defaultTheme, estelaTheme } from './theme.js';
34
37
  //# sourceMappingURL=index.js.map
package/dist/theme.d.ts CHANGED
@@ -115,6 +115,24 @@ export interface ChartTheme {
115
115
  readonly gap?: {
116
116
  readonly connectorOpacity: number;
117
117
  };
118
+ /**
119
+ * The **annotation register** — the styling for *user-authored* marks
120
+ * (`<Region>` / `<Baseline>` / `<Marker>`), deliberately a distinct hue from
121
+ * the data's `line`/`area`/… so a mark you place never reads as data (the
122
+ * "data stays foam, marks are turquoise" rule). `color` is the shared register
123
+ * hue (lines, region edges + fill, handles, label text); the region fill draws
124
+ * at `fillOpacity`. **Luminosity encodes depth** — brighter reads as forward,
125
+ * dimmer as further back. `depth` is the three-level ramp: `[0]` = level 1
126
+ * (forward / brightest — a selected mark, or an edit-mode line), `[1]` = level 2
127
+ * (mid — a hovered mark, or an edit-mode region body), `[2]` = level 3 (back —
128
+ * the resting, backgrounded state). Cross-row guides draw fainter still (a
129
+ * notional level 4, set in `Layers`). Falls back to a built-in turquoise.
130
+ */
131
+ readonly annotation?: {
132
+ readonly color: string;
133
+ readonly fillOpacity: number;
134
+ readonly depth: readonly [number, number, number];
135
+ };
118
136
  }
119
137
  /** A resolved line style: stroke colour + width (px). */
120
138
  export interface LineStyle {
package/dist/theme.js CHANGED
@@ -107,6 +107,12 @@ export const defaultTheme = {
107
107
  cursor: '#64748b',
108
108
  chip: { background: '#ffffff' },
109
109
  gap: { connectorOpacity: 0.5 },
110
+ // Teal marks register — distinct from the blue data, reads on the light ground.
111
+ annotation: {
112
+ color: '#0d9488',
113
+ fillOpacity: 0.1,
114
+ depth: [1, 0.7, 0.4],
115
+ },
110
116
  };
111
117
  /**
112
118
  * The estela theme — estela's real `@estela/ui` palette as *one theme*, on its
@@ -228,5 +234,12 @@ export const estelaTheme = {
228
234
  cursor: '#7FE2D2', // --es-reef (bright tracker on the dark ground)
229
235
  chip: { background: '#0B4E58' }, // --es-deep (panel behind readout text)
230
236
  gap: { connectorOpacity: 0.5 },
237
+ // Marks register: --es-reef, estela's bright attention turquoise (the same hue
238
+ // as the cursor / selected highlight) — distinct from the foam (white) data.
239
+ annotation: {
240
+ color: '#7FE2D2', // --es-reef
241
+ fillOpacity: 0.1,
242
+ depth: [1, 0.7, 0.4],
243
+ },
231
244
  };
232
245
  //# sourceMappingURL=theme.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pond-ts/charts",
3
- "version": "0.31.2",
3
+ "version": "0.32.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.31.0",
42
- "pond-ts": "^0.31.0",
41
+ "@pond-ts/react": "^0.32.0",
42
+ "pond-ts": "^0.32.0",
43
43
  "react": "^18.0.0 || ^19.0.0"
44
44
  },
45
45
  "devDependencies": {