@pond-ts/charts 0.54.0 → 0.55.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/bars.d.ts CHANGED
@@ -50,8 +50,10 @@ export declare function resolveBarBaseline(yScale: Scale): number;
50
50
  * from {@link barSpanPx} (the key's `[begin, end]`, inset by `gapPx`, floored at
51
51
  * `minWidthPx`); the y-span runs between the value and the `baseline` pixel,
52
52
  * normalized so a value above *or* below the baseline both yield an ascending
53
- * rect. Shared by {@link drawBars} and {@link barAt} so the drawn rect and the
54
- * hit rect are the same geometry.
53
+ * rect. This is the **ink** what {@link drawBars} paints. Hit-testing uses
54
+ * {@link barSlotRect} instead (the bar's whole slot), so the drawn rect and the
55
+ * hit region are deliberately *not* the same geometry: the `gapPx` inset
56
+ * separates columns visually without carving a dead channel out of the target.
55
57
  */
56
58
  export declare function barRect(cs: BarSeries, i: number, xScale: Scale, yScale: Scale, baseline: number, gapPx: number, minWidthPx: number): [x0: number, x1: number, yTop: number, yBottom: number] | null;
57
59
  /**
@@ -75,9 +77,12 @@ export interface BarMark {
75
77
  * no-id layer passes `undefined` and never matches — plus the bar's identity,
76
78
  * see {@link barMatches}) draws in the style's `highlight` colour **and
77
79
  * outlined**, so a click reads back on the canvas; a bar matching `hovered`
78
- * draws in `highlight` **without** the outline (a lighter "this bar is live" on
79
- * pointer-over); all others use the flat `fill`. `globalAlpha` carries the fill
80
- * opacity and is restored so it doesn't leak into later layers.
80
+ * draws **without** the outline (a lighter "this bar is live" on pointer-over)
81
+ * in the style's optional `hover` colour, or in `highlight` when the theme
82
+ * doesn't set one; all others use the flat `fill`. Either live state fills at
83
+ * **full opacity** — the resting `opacity` applies to resting bars only, and is
84
+ * restored so it doesn't leak into later layers. A bar that is both selected
85
+ * and hovered reads as **selected**.
81
86
  *
82
87
  * **Which identity.** A selection carrying a `mark` matches against the series'
83
88
  * stable per-bar name ({@link BarSeries.marks} — the sample's own axis key,
@@ -94,11 +99,12 @@ export interface BarMark {
94
99
  * (an `undefined` entry falls back to the flat `fill`). This is the
95
100
  * direction-coloured financial volume row (rising / falling) and the
96
101
  * value-band case on a time axis. Highlight follows {@link drawStacks}'s
97
- * binFills convention rather than the flat path's: the bar **keeps its own
98
- * colour** under hover / selection — the highlight pops `globalAlpha` to 1
99
- * (and outlines the selection in the bar's own fill) — so a red / green bar
100
- * stays red / green while live, instead of swapping to the single
101
- * `highlight` colour and losing its meaning.
102
+ * binFills convention: the bar **keeps its own colour** under hover /
103
+ * selection — the highlight pops `globalAlpha` to 1 (and outlines the
104
+ * selection in the bar's own fill) — so a red / green bar stays red / green
105
+ * while live, instead of swapping to the single `highlight` colour and losing
106
+ * its meaning. (Both paths now pop to 1; what still differs is the *colour* —
107
+ * the flat path swaps to `highlight`, this one keeps `binFills[i]`.)
102
108
  *
103
109
  * **M4 column decimation ([PND-MARKDEC]):** once the *visible* bars are denser
104
110
  * than ~2 per device pixel, they overplot into a solid silhouette, so
@@ -129,19 +135,68 @@ export declare function drawBars(ctx: CanvasRenderingContext2D, cs: BarSeries, x
129
135
  * cheap and allocation-free).
130
136
  */
131
137
  export declare function barIndexAtTime(cs: BarSeries, time: number): number;
138
+ /**
139
+ * The pixel rect of bar `i`'s **slot** — the region that *belongs* to the bar,
140
+ * as opposed to the ink {@link barRect} puts on the canvas. It spans the key's
141
+ * full `[begin, end]` in x (**no `gapPx` inset**) and the **whole plot height**
142
+ * in y. `null` for a gap (non-finite value), which owns no slot to select.
143
+ *
144
+ * The distinction is the point: a bar *is* the full width of its interval, and
145
+ * the drawing gap is a display affordance so adjacent columns read as discrete.
146
+ * Hit-testing the drawn rect made that affordance interactive — the gap became
147
+ * a dead channel you could point at and select nothing, and the empty plot
148
+ * space above a short bar likewise. Slots tile the axis, so every x inside the
149
+ * data range belongs to exactly one bar, which is what a column chart's hover
150
+ * should feel like and what {@link barIndexAtTime} (the x-scrub cursor) has
151
+ * always done.
152
+ *
153
+ * The plot's y extent is read from the `yScale`'s own domain, the same
154
+ * localized shape {@link resolveBarBaseline} uses. When it isn't readable (a
155
+ * bare test stub with no `.domain()`), this falls back to {@link barRect}'s
156
+ * value→baseline span, so a scale-less caller keeps the old behaviour rather
157
+ * than getting an unbounded hit region.
158
+ *
159
+ * `minWidthPx` still floors the span, so a lone point-keyed bar (zero-width
160
+ * key) stays selectable.
161
+ *
162
+ * **Two consequences worth knowing before you compose with it.**
163
+ *
164
+ * 1. **It reaches across the whole plot height, so it can shadow layers below
165
+ * it.** `resolveSelection` returns the topmost hit, so a `<BarChart>`
166
+ * declared *after* a `<ScatterChart>` / `<BoxPlot>` / another `<BarChart>`
167
+ * in the same row now claims every hit inside its x-range, at any y — where
168
+ * the drawn-rect target only claimed the bar's own ink. Declare a bar layer
169
+ * **below** the marks you want to stay clickable (which is also the usual
170
+ * z-order for bars-as-context). No shipped story composes that way, so this
171
+ * is latent rather than a live regression.
172
+ * 2. **Only the single-series vertical path uses it.** A stacked, `bins`,
173
+ * `categories` or horizontal `<BarChart>` hit-tests through
174
+ * {@link stackAt}, which still targets the drawn segment — a stack has to,
175
+ * since segments share a bin's x-range and only y tells them apart. So
176
+ * `<BarChart>` has two hit models; this is the one for a plain bar.
177
+ */
178
+ export declare function barSlotRect(cs: BarSeries, i: number, xScale: Scale, yScale: Scale, baseline: number, minWidthPx: number): [x0: number, x1: number, yTop: number, yBottom: number] | null;
132
179
  /**
133
180
  * Hit-test plot-pixel `(px, py)` against `cs`'s bars — the **first** bar whose
134
- * rect contains the point, or `null`. The geometry is {@link barRect}, so the
135
- * hit rect is exactly the drawn rect (same `baseline`/`gapPx`/`minWidth`). The
136
- * returned tuple is `[index, begin, value]` for the chart to assemble a
137
- * `SelectInfo` (it owns the colour + label); keeping this layer free of the
138
- * theme keeps it unit-testable without a `ChartTheme`.
181
+ * **slot** contains the point, or `null`. The geometry is {@link barSlotRect}:
182
+ * the bar's full interval width and the full plot height, *not* the drawn rect.
183
+ * Pointing at the gap between two columns, or above a short one, selects the
184
+ * bar whose slot you are in. The returned tuple is `[index, begin, value]` for
185
+ * the chart to assemble a `SelectInfo` (it owns the colour + label); keeping
186
+ * this layer free of the theme keeps it unit-testable without a `ChartTheme`.
187
+ *
188
+ * **Shared edges.** Contiguous bars meet exactly (`end[i] === begin[i+1]`) once
189
+ * the gap is gone, and both ends are inclusive, so a point landing precisely on
190
+ * the boundary matches **the left bar** — first match wins, the same rule
191
+ * {@link barIndexAtTime} documents, so hover and the x-scrub cursor agree.
192
+ *
193
+ * A **gap** bar (non-finite value) owns no slot and is skipped, so hovering
194
+ * where the data is missing selects nothing rather than a `NaN`.
139
195
  *
140
196
  * O(N) over the events (no spatial index — bar counts are view-scale, hundreds
141
- * not millions; click is a rare event). Bars don't overlap in x for a sorted
142
- * series, so "first match" is unambiguous in practice.
197
+ * not millions; click is a rare event).
143
198
  */
144
- export declare function barAt(cs: BarSeries, px: number, py: number, xScale: Scale, yScale: Scale, baseline: number, gapPx: number, minWidthPx: number): [index: number, begin: number, value: number] | null;
199
+ export declare function barAt(cs: BarSeries, px: number, py: number, xScale: Scale, yScale: Scale, baseline: number, minWidthPx: number): [index: number, begin: number, value: number] | null;
145
200
  /**
146
201
  * A resolved per-group stack style: `fills` aligned index-for-index to
147
202
  * {@link StackedBarSeries.groups} (segment `g` uses `fills[g]`), plus the shared
package/dist/bars.js CHANGED
@@ -67,8 +67,10 @@ export function resolveBarBaseline(yScale) {
67
67
  * from {@link barSpanPx} (the key's `[begin, end]`, inset by `gapPx`, floored at
68
68
  * `minWidthPx`); the y-span runs between the value and the `baseline` pixel,
69
69
  * normalized so a value above *or* below the baseline both yield an ascending
70
- * rect. Shared by {@link drawBars} and {@link barAt} so the drawn rect and the
71
- * hit rect are the same geometry.
70
+ * rect. This is the **ink** what {@link drawBars} paints. Hit-testing uses
71
+ * {@link barSlotRect} instead (the bar's whole slot), so the drawn rect and the
72
+ * hit region are deliberately *not* the same geometry: the `gapPx` inset
73
+ * separates columns visually without carving a dead channel out of the target.
72
74
  */
73
75
  export function barRect(cs, i, xScale, yScale, baseline, gapPx, minWidthPx) {
74
76
  const v = cs.y[i];
@@ -114,9 +116,12 @@ function barMatches(m, seriesId, stable, begin) {
114
116
  * no-id layer passes `undefined` and never matches — plus the bar's identity,
115
117
  * see {@link barMatches}) draws in the style's `highlight` colour **and
116
118
  * outlined**, so a click reads back on the canvas; a bar matching `hovered`
117
- * draws in `highlight` **without** the outline (a lighter "this bar is live" on
118
- * pointer-over); all others use the flat `fill`. `globalAlpha` carries the fill
119
- * opacity and is restored so it doesn't leak into later layers.
119
+ * draws **without** the outline (a lighter "this bar is live" on pointer-over)
120
+ * in the style's optional `hover` colour, or in `highlight` when the theme
121
+ * doesn't set one; all others use the flat `fill`. Either live state fills at
122
+ * **full opacity** — the resting `opacity` applies to resting bars only, and is
123
+ * restored so it doesn't leak into later layers. A bar that is both selected
124
+ * and hovered reads as **selected**.
120
125
  *
121
126
  * **Which identity.** A selection carrying a `mark` matches against the series'
122
127
  * stable per-bar name ({@link BarSeries.marks} — the sample's own axis key,
@@ -133,11 +138,12 @@ function barMatches(m, seriesId, stable, begin) {
133
138
  * (an `undefined` entry falls back to the flat `fill`). This is the
134
139
  * direction-coloured financial volume row (rising / falling) and the
135
140
  * value-band case on a time axis. Highlight follows {@link drawStacks}'s
136
- * binFills convention rather than the flat path's: the bar **keeps its own
137
- * colour** under hover / selection — the highlight pops `globalAlpha` to 1
138
- * (and outlines the selection in the bar's own fill) — so a red / green bar
139
- * stays red / green while live, instead of swapping to the single
140
- * `highlight` colour and losing its meaning.
141
+ * binFills convention: the bar **keeps its own colour** under hover /
142
+ * selection — the highlight pops `globalAlpha` to 1 (and outlines the
143
+ * selection in the bar's own fill) — so a red / green bar stays red / green
144
+ * while live, instead of swapping to the single `highlight` colour and losing
145
+ * its meaning. (Both paths now pop to 1; what still differs is the *colour* —
146
+ * the flat path swaps to `highlight`, this one keeps `binFills[i]`.)
141
147
  *
142
148
  * **M4 column decimation ([PND-MARKDEC]):** once the *visible* bars are denser
143
149
  * than ~2 per device pixel, they overplot into a solid silhouette, so
@@ -210,10 +216,10 @@ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, series
210
216
  // Match by the series `id` **and** the bar's identity — its stable `mark`
211
217
  // when the selection carries one, else the sample `key` (begin) — so two
212
218
  // series sharing a timestamp don't both light up (a no-id, non-selectable
213
- // layer passes `seriesId === undefined` and never matches). Both the
214
- // committed selection and the transient hover use the `highlight` fill; only
215
- // the selection adds the outline, so hover reads as a lighter "this bar is
216
- // live" and select as the committed pick.
219
+ // layer passes `seriesId === undefined` and never matches). The selection
220
+ // takes `highlight` + the outline; the hover takes `hover` when the theme
221
+ // sets one and `highlight` otherwise, always without the outline so hover
222
+ // reads as a lighter "this bar is live" and select as the committed pick.
217
223
  const stable = marks?.[i];
218
224
  const selected = barMatches(selection, seriesId, stable, cs.begin[i]);
219
225
  const isHovered = barMatches(hovered, seriesId, stable, cs.begin[i]);
@@ -233,17 +239,33 @@ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, series
233
239
  }
234
240
  continue;
235
241
  }
236
- ctx.fillStyle = selected || isHovered ? style.highlight : style.fill;
242
+ // A hovered / selected bar pops to full opacity, as the binFills branch
243
+ // above and `drawStacks` both do — without this the highlight *fill* drew
244
+ // at the resting `style.opacity`, so on an alpha'd theme a hovered bar
245
+ // (which has no outline) barely changed at all, and a selected one read
246
+ // only by its outline (#576).
247
+ ctx.globalAlpha = selected || isHovered ? 1 : style.opacity;
248
+ // Three-step emphasis when the theme opts in with `hover`: rest → hover →
249
+ // selected. Selection outranks hover on a bar that is both (as the outline
250
+ // already did). With no `hover` colour this is the shipped two-step —
251
+ // `highlight` for either state (see BarStyle.hover).
252
+ ctx.fillStyle = selected
253
+ ? style.highlight
254
+ : isHovered
255
+ ? (style.hover ?? style.highlight)
256
+ : style.fill;
237
257
  ctx.fillRect(x0, yTop, x1 - x0, yBottom - yTop);
238
258
  drawn += 1;
239
259
  if (selected) {
240
- // The selected bar gets an outline so it reads at full strength over the
241
- // (alpha'd) fills. Stroke at full opacity (reset within the save bracket).
242
- ctx.globalAlpha = 1;
260
+ // The selected bar's outline. Already at alpha 1 from the fill above
261
+ // which also means it no longer separates select from hover the way it
262
+ // used to: the stroke is `highlight` over a now-`highlight`, now-alpha-1
263
+ // fill, so only the half-stroke falling outside the rect reads. A theme
264
+ // that needs the two states clearly apart sets `BarStyle.hover` (#577);
265
+ // the outline is the shape cue, not the whole signal.
243
266
  ctx.lineWidth = style.outlineWidth;
244
267
  ctx.strokeStyle = style.highlight;
245
268
  ctx.strokeRect(x0, yTop, x1 - x0, yBottom - yTop);
246
- ctx.globalAlpha = style.opacity;
247
269
  }
248
270
  }
249
271
  ctx.restore();
@@ -269,21 +291,90 @@ export function barIndexAtTime(cs, time) {
269
291
  }
270
292
  return -1;
271
293
  }
294
+ /**
295
+ * The pixel rect of bar `i`'s **slot** — the region that *belongs* to the bar,
296
+ * as opposed to the ink {@link barRect} puts on the canvas. It spans the key's
297
+ * full `[begin, end]` in x (**no `gapPx` inset**) and the **whole plot height**
298
+ * in y. `null` for a gap (non-finite value), which owns no slot to select.
299
+ *
300
+ * The distinction is the point: a bar *is* the full width of its interval, and
301
+ * the drawing gap is a display affordance so adjacent columns read as discrete.
302
+ * Hit-testing the drawn rect made that affordance interactive — the gap became
303
+ * a dead channel you could point at and select nothing, and the empty plot
304
+ * space above a short bar likewise. Slots tile the axis, so every x inside the
305
+ * data range belongs to exactly one bar, which is what a column chart's hover
306
+ * should feel like and what {@link barIndexAtTime} (the x-scrub cursor) has
307
+ * always done.
308
+ *
309
+ * The plot's y extent is read from the `yScale`'s own domain, the same
310
+ * localized shape {@link resolveBarBaseline} uses. When it isn't readable (a
311
+ * bare test stub with no `.domain()`), this falls back to {@link barRect}'s
312
+ * value→baseline span, so a scale-less caller keeps the old behaviour rather
313
+ * than getting an unbounded hit region.
314
+ *
315
+ * `minWidthPx` still floors the span, so a lone point-keyed bar (zero-width
316
+ * key) stays selectable.
317
+ *
318
+ * **Two consequences worth knowing before you compose with it.**
319
+ *
320
+ * 1. **It reaches across the whole plot height, so it can shadow layers below
321
+ * it.** `resolveSelection` returns the topmost hit, so a `<BarChart>`
322
+ * declared *after* a `<ScatterChart>` / `<BoxPlot>` / another `<BarChart>`
323
+ * in the same row now claims every hit inside its x-range, at any y — where
324
+ * the drawn-rect target only claimed the bar's own ink. Declare a bar layer
325
+ * **below** the marks you want to stay clickable (which is also the usual
326
+ * z-order for bars-as-context). No shipped story composes that way, so this
327
+ * is latent rather than a live regression.
328
+ * 2. **Only the single-series vertical path uses it.** A stacked, `bins`,
329
+ * `categories` or horizontal `<BarChart>` hit-tests through
330
+ * {@link stackAt}, which still targets the drawn segment — a stack has to,
331
+ * since segments share a bin's x-range and only y tells them apart. So
332
+ * `<BarChart>` has two hit models; this is the one for a plain bar.
333
+ */
334
+ export function barSlotRect(cs, i, xScale, yScale, baseline, minWidthPx) {
335
+ const v = cs.y[i];
336
+ if (!Number.isFinite(v))
337
+ return null;
338
+ // Gap-free: the slot is the key's own span.
339
+ const [x0, x1] = barSpanPx(cs.begin[i], cs.end[i], xScale, 0, minWidthPx);
340
+ const d = yScale.domain?.();
341
+ // `< 2`, not `=== 0`: a one-element domain would make both endpoints the same
342
+ // value, collapsing the slot to zero height and making the bar unhittable —
343
+ // worse than the fallback it was meant to skip. (`resolveBarBaseline`'s
344
+ // `=== 0` is fine because it clamps against min/max of the same endpoints.)
345
+ if (!d || d.length < 2) {
346
+ // No usable domain (a bare test stub): keep the drawn rect's y span.
347
+ const yValue = yScale(v);
348
+ const yBase = yScale(baseline);
349
+ return [x0, x1, Math.min(yValue, yBase), Math.max(yValue, yBase)];
350
+ }
351
+ const yA = yScale(d[0]);
352
+ const yB = yScale(d[d.length - 1]);
353
+ return [x0, x1, Math.min(yA, yB), Math.max(yA, yB)];
354
+ }
272
355
  /**
273
356
  * Hit-test plot-pixel `(px, py)` against `cs`'s bars — the **first** bar whose
274
- * rect contains the point, or `null`. The geometry is {@link barRect}, so the
275
- * hit rect is exactly the drawn rect (same `baseline`/`gapPx`/`minWidth`). The
276
- * returned tuple is `[index, begin, value]` for the chart to assemble a
277
- * `SelectInfo` (it owns the colour + label); keeping this layer free of the
278
- * theme keeps it unit-testable without a `ChartTheme`.
357
+ * **slot** contains the point, or `null`. The geometry is {@link barSlotRect}:
358
+ * the bar's full interval width and the full plot height, *not* the drawn rect.
359
+ * Pointing at the gap between two columns, or above a short one, selects the
360
+ * bar whose slot you are in. The returned tuple is `[index, begin, value]` for
361
+ * the chart to assemble a `SelectInfo` (it owns the colour + label); keeping
362
+ * this layer free of the theme keeps it unit-testable without a `ChartTheme`.
363
+ *
364
+ * **Shared edges.** Contiguous bars meet exactly (`end[i] === begin[i+1]`) once
365
+ * the gap is gone, and both ends are inclusive, so a point landing precisely on
366
+ * the boundary matches **the left bar** — first match wins, the same rule
367
+ * {@link barIndexAtTime} documents, so hover and the x-scrub cursor agree.
368
+ *
369
+ * A **gap** bar (non-finite value) owns no slot and is skipped, so hovering
370
+ * where the data is missing selects nothing rather than a `NaN`.
279
371
  *
280
372
  * O(N) over the events (no spatial index — bar counts are view-scale, hundreds
281
- * not millions; click is a rare event). Bars don't overlap in x for a sorted
282
- * series, so "first match" is unambiguous in practice.
373
+ * not millions; click is a rare event).
283
374
  */
284
- export function barAt(cs, px, py, xScale, yScale, baseline, gapPx, minWidthPx) {
375
+ export function barAt(cs, px, py, xScale, yScale, baseline, minWidthPx) {
285
376
  for (let i = 0; i < cs.length; i += 1) {
286
- const rect = barRect(cs, i, xScale, yScale, baseline, gapPx, minWidthPx);
377
+ const rect = barSlotRect(cs, i, xScale, yScale, baseline, minWidthPx);
287
378
  if (rect === null)
288
379
  continue;
289
380
  const [x0, x1, yTop, yBottom] = rect;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Schema-derived column-name types for the draw layers' column props —
3
+ * [PND-CHARTAPI]'s foundation.
4
+ *
5
+ * The 2026-08 API review's finding: the layers are generic over the schema,
6
+ * but their column props are bare `string`, so `<LineChart series={cpu}
7
+ * column="cpuu" />` compiles and fails at runtime — out of character for a
8
+ * library whose core exports `NumericColumnNameForSchema<S>`. These aliases
9
+ * close that gap in one place; **no prop position should reference core's
10
+ * helper directly.**
11
+ *
12
+ * ## Why the `never` guard is load-bearing
13
+ *
14
+ * `NumericColumnNameForSchema<SeriesSchema>` resolves to **`never`** — an
15
+ * unparameterized schema names no columns. Constraining a prop to it
16
+ * directly would therefore make `column` accept *nothing at all* for every
17
+ * consumer holding a loosely-typed series: a helper returning
18
+ * `TimeSeries<SeriesSchema>`, a React prop typed that way, or a component's
19
+ * own defaulted `S`. That is a large, silent breakage class — and one this
20
+ * repo's own suite cannot see, because its fixtures use `as const` schemas
21
+ * throughout (measured in `spikes/charts-type-seam/REPORT.md`, finding 5).
22
+ *
23
+ * So each alias falls back to `string` when the derived union is empty:
24
+ * a **narrow** schema gets precise names and a one-line error on a typo, a
25
+ * **loose** one keeps compiling exactly as before. The bracketed
26
+ * `[T] extends [never]` form is required — a bare `T extends never`
27
+ * distributes over unions and answers the wrong question.
28
+ */
29
+ import type { NumericColumnNameForSchema, SeriesSchema, ValueColumnsForSchema, ValueSeriesSchema } from 'pond-ts';
30
+ /**
31
+ * A **numeric** value column of `S` — the constraint for every prop naming a
32
+ * column the layer reads as a number (a line's `column`, a band's
33
+ * `lower`/`upper`, a box's quantiles, an OHLC price).
34
+ *
35
+ * The test is **"is the schema loose?"**, not "did it yield no numeric
36
+ * columns" — those are different questions with different right answers, and
37
+ * conflating them was a real bug. A schema that names columns but has no
38
+ * numeric one (all-string) should reject *every* name, because there is
39
+ * genuinely nothing numeric to plot; only an **unparameterized** schema, whose
40
+ * column names are unbounded, should fall back to `string`. `string extends
41
+ * AnyColumn<S>` distinguishes them: it is true only when the name union is
42
+ * open.
43
+ */
44
+ export type NumericColumn<S extends SeriesSchema> = string extends AnyColumn<S> ? string : NumericColumnNameForSchema<S>;
45
+ /**
46
+ * **Any** value column name of `S`, of any kind — the looseness probe for
47
+ * {@link NumericColumn}. On an unparameterized schema this is `string` (an
48
+ * open union), which is exactly what distinguishes "cannot check" from
49
+ * "checked, and nothing matches". Internal: no prop is typed with it, so it
50
+ * stays off the public surface until one is.
51
+ */
52
+ type AnyColumn<S extends SeriesSchema> = ValueColumnsForSchema<S>[number]['name'];
53
+ /**
54
+ * The `ValueSeries` sibling of {@link NumericColumn} — a different schema type
55
+ * family, so it needs its own derivation; the loose-schema rule is identical.
56
+ *
57
+ * **Layers do not union the two.** A prop typed `NumericColumn<S> |
58
+ * ValueNumericColumn<VS>` is inert: only one of the two generics is ever
59
+ * inferred at a call site, so the other falls back to `string` and widens the
60
+ * union away. Each layer's props are instead a union **per series kind**, so
61
+ * the names check against the schema that was actually passed. Measured in
62
+ * `spikes/charts-type-seam/REPORT.md`.
63
+ */
64
+ export type ValueNumericColumn<VS extends ValueSeriesSchema> = string extends VS[number]['name'] ? string : NumericColumnNameForValueSchema<VS>;
65
+ /**
66
+ * Names of `'number'`-kind value columns on a `ValueSeries` schema. Core
67
+ * exports the `TimeSeries` equivalent but not this one; derived locally
68
+ * rather than widening core's surface for a charts-only need.
69
+ */
70
+ type NumericColumnNameForValueSchema<VS extends ValueSeriesSchema> = Extract<VS[number], {
71
+ readonly kind: 'number';
72
+ }>['name'];
73
+ export {};
74
+ //# sourceMappingURL=column-names.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=column-names.js.map
package/dist/context.d.ts CHANGED
@@ -541,6 +541,17 @@ export interface RowLayer {
541
541
  * must agree on this list (a mix is an error), the same way {@link xKind} must.
542
542
  */
543
543
  xCategories?(): readonly string[] | null;
544
+ /**
545
+ * A **horizontal** categorical source's ordered category names — the same
546
+ * list {@link xCategories} carries for a vertical one, but for the axis it
547
+ * lands on when the bars grow right: the **y** axis ([PND-HCAT]).
548
+ *
549
+ * The y axis stays a linear scale over the layer's unit slots (`[i, i+1]`),
550
+ * so this only supplies *labels*: a `<YAxis>` in the row with no explicit
551
+ * `ticks` derives one tick per category at the slot centre (`i + 0.5`).
552
+ * That hand-built tick list was the friction the gallery funnel documented.
553
+ */
554
+ binCategories?(): readonly string[] | null;
544
555
  /**
545
556
  * A bar/histogram layer's bar `[begin, end)` spans, as pond `Interval`s — the
546
557
  * **region cursor's snap buckets**. When present (and no `cursorSequence` is
@@ -642,6 +653,17 @@ export interface TrackerSource {
642
653
  xExtent(): readonly [number, number] | null;
643
654
  /** A `'category'` source's ordered category names (see {@link RowLayer.xCategories}). */
644
655
  xCategories?(): readonly string[] | null;
656
+ /**
657
+ * A **horizontal** categorical source's ordered category names — the same
658
+ * list {@link xCategories} carries for a vertical one, but for the axis it
659
+ * lands on when the bars grow right: the **y** axis ([PND-HCAT]).
660
+ *
661
+ * The y axis stays a linear scale over the layer's unit slots (`[i, i+1]`),
662
+ * so this only supplies *labels*: a `<YAxis>` in the row with no explicit
663
+ * `ticks` derives one tick per category at the slot centre (`i + 0.5`).
664
+ * That hand-built tick list was the friction the gallery funnel documented.
665
+ */
666
+ binCategories?(): readonly string[] | null;
645
667
  /** A bar/histogram source's bar `[begin, end)` spans (see {@link RowLayer.binIntervals}). */
646
668
  binIntervals?(): readonly Interval[] | null;
647
669
  }
package/dist/data.d.ts CHANGED
@@ -447,6 +447,25 @@ export interface StacksFromBinsOptions {
447
447
  */
448
448
  readonly ordinal?: boolean;
449
449
  }
450
+ /**
451
+ * Build a {@link BarSeries} from **`byColumn` bin records** — the single-series
452
+ * sibling of {@link stacksFromBins}, for a histogram drawing **one** aggregate
453
+ * field.
454
+ *
455
+ * Why it exists ([PND-BARSEM]): a one-column histogram *is* a single-series
456
+ * bar chart — same mark, same geometry — but routing it through the stacked
457
+ * reader made its capabilities depend on which prop produced it (whole-slot
458
+ * hit-testing, the hover colour, the cursor readout and per-bar decimation all
459
+ * live on the single path). Reading it as a `BarSeries` lets the chart decide
460
+ * by what it *draws* rather than by how it was fed.
461
+ *
462
+ * `column` names the aggregate field; slots are the bins' numeric
463
+ * `[start, end]` edges, or uniform unit slots under `{ ordinal: true }` —
464
+ * identical to {@link stacksFromBins}. A missing / non-finite aggregate reads
465
+ * as a gap (`NaN`), and each bar carries its slot index as a stable
466
+ * {@link BarSeries.marks} identity (bin records have no key of their own).
467
+ */
468
+ export declare function barsFromBins(bins: readonly BinRecord[], column: string, options?: StacksFromBinsOptions): BarSeries;
450
469
  /**
451
470
  * Build a {@link StackedBarSeries} from **`byColumn` bin records** — the array of
452
471
  * `{ start, end, …aggregates }` a value-band aggregation returns
package/dist/data.js CHANGED
@@ -535,6 +535,52 @@ export function stacksFromColumns(series, columns) {
535
535
  }
536
536
  return { begin, end, groups: columns, values, length: n };
537
537
  }
538
+ /**
539
+ * Build a {@link BarSeries} from **`byColumn` bin records** — the single-series
540
+ * sibling of {@link stacksFromBins}, for a histogram drawing **one** aggregate
541
+ * field.
542
+ *
543
+ * Why it exists ([PND-BARSEM]): a one-column histogram *is* a single-series
544
+ * bar chart — same mark, same geometry — but routing it through the stacked
545
+ * reader made its capabilities depend on which prop produced it (whole-slot
546
+ * hit-testing, the hover colour, the cursor readout and per-bar decimation all
547
+ * live on the single path). Reading it as a `BarSeries` lets the chart decide
548
+ * by what it *draws* rather than by how it was fed.
549
+ *
550
+ * `column` names the aggregate field; slots are the bins' numeric
551
+ * `[start, end]` edges, or uniform unit slots under `{ ordinal: true }` —
552
+ * identical to {@link stacksFromBins}. A missing / non-finite aggregate reads
553
+ * as a gap (`NaN`), and each bar carries its slot index as a stable
554
+ * {@link BarSeries.marks} identity (bin records have no key of their own).
555
+ */
556
+ export function barsFromBins(bins, column, options = {}) {
557
+ const n = bins.length;
558
+ const begin = new Float64Array(n);
559
+ const end = new Float64Array(n);
560
+ const y = new Float64Array(n);
561
+ // The bin's own **start value** is its identity — the same axis-key
562
+ // convention every other reader uses, and stable under `ordinal` (which only
563
+ // changes the drawn slot, not which bin a row is). A slot *index* would be
564
+ // positional and would renumber if the bin set changed. Materialized through
565
+ // the shared lazy getter, so a non-interactive layer never pays for the
566
+ // strings (see {@link BarSeries.marks}).
567
+ const keys = new Float64Array(n);
568
+ for (let i = 0; i < n; i += 1) {
569
+ const bin = bins[i];
570
+ if (options.ordinal) {
571
+ begin[i] = i;
572
+ end[i] = i + 1;
573
+ }
574
+ else {
575
+ begin[i] = bin.start;
576
+ end[i] = bin.end;
577
+ }
578
+ keys[i] = bin.start;
579
+ const v = bin[column];
580
+ y[i] = typeof v === 'number' && Number.isFinite(v) ? v : NaN;
581
+ }
582
+ return withKeyMarks({ begin, end, y, length: n }, keys);
583
+ }
538
584
  /**
539
585
  * Build a {@link StackedBarSeries} from **`byColumn` bin records** — the array of
540
586
  * `{ start, end, …aggregates }` a value-band aggregation returns
package/dist/index.d.ts CHANGED
@@ -2,19 +2,19 @@
2
2
  * `@pond-ts/charts` — the visualization end of pond.
3
3
  *
4
4
  * Canvas-rendered, streaming-first time-series charts with a
5
- * react-timeseries-charts-style declarative layout. The architecture (hard
6
- * layers: adapter typed-array store decimatorcanvas renderer → React
7
- * shell) is documented in the charts RFC at `docs/rfcs/charts.md`; the
8
- * milestone plan lives in `PLAN.md`. (A Path2D path cache was explored and
9
- * deferredit doesn't help the pan case, which re-decimates every frame; see
10
- * the [PND-DECIM] floor decision.)
5
+ * react-timeseries-charts-style declarative layout: compose
6
+ * `<ChartContainer>``<ChartRow>``<Layers>`draw layers
7
+ * (line/area/band/scatter/bar/box/candle), plus the standalone DOM row lists
8
+ * ({@link BarList} / {@link BoxList}). **The data contract is the pond series
9
+ * itself**every layer takes a `TimeSeries` / `ValueSeries` (or a
10
+ * partition `Map`, `byColumn` bins, category records) directly and shapes
11
+ * internally; an adapter you must call when starting from a series is an API
12
+ * failure (`docs/notes/charts-api-review-2026-08.md`). The exported `from*`
13
+ * view builders are **interop escape hatches** for non-pond data only.
11
14
  *
12
- * **M1 rendering spine.** The layout shell + the first draw layer:
13
- * `<ChartContainer>` (time axis) `<ChartRow>` (y-axis + canvas) →
14
- * `<LineChart>` (a gap-aware line), fed from a pond `TimeSeries` via
15
- * {@link fromTimeSeries}. Axes, themes, the variance band, and interactions
16
- * land in M2–M4. {@link Canvas} is the low-level DPR-aware primitive the rows
17
- * sit on.
15
+ * Architecture (typed-array store decimator canvas renderer React
16
+ * shell): `docs/rfcs/charts.md`; roadmap: `PLAN.md`. {@link Canvas} is the
17
+ * low-level DPR-aware primitive the rows sit on.
18
18
  *
19
19
  * @packageDocumentation
20
20
  */
@@ -49,6 +49,12 @@ export type { BarChartProps } from './BarChart.js';
49
49
  export { Candlestick } from './Candlestick.js';
50
50
  export type { CandlestickProps } from './Candlestick.js';
51
51
  export type { CandleVariant, ColorBy } from './ohlc.js';
52
+ export { BarList } from './BarList.js';
53
+ export type { BarListProps } from './BarList.js';
54
+ export { BoxList } from './BoxList.js';
55
+ export type { BoxListProps } from './BoxList.js';
56
+ export { listRowsFromTimeSeries, listRowsFromValueSeries } from './list.js';
57
+ export type { ListRow, ListValue, ListCellSpec, ListMarker, ListSortDirection, ListRowsOptions, BarListColumn, BoxListColumn, } from './list.js';
52
58
  export { Legend } from './Legend.js';
53
59
  export type { LegendProps, LegendPlacement } from './Legend.js';
54
60
  export type { SwatchSpec, LegendItemInput } from './swatch.js';
@@ -63,7 +69,7 @@ export type { RegionProps, BaselineProps, MarkerProps } from './annotations.js';
63
69
  export type { AnnotationKind, CreateSpec } from './context.js';
64
70
  export { YAxisIndicator, createLiveValue } from './indicators.js';
65
71
  export type { YAxisIndicatorProps, LiveValue } from './indicators.js';
66
- export { fromTimeSeries, bandFromTimeSeries, boxFromTimeSeries, barsFromTimeSeries, ohlcFromTimeSeries, stacksFromGroups, stacksFromColumns, stacksFromBins, categoryStack, transposeRow, } from './data.js';
72
+ export { fromTimeSeries, bandFromTimeSeries, boxFromTimeSeries, barsFromTimeSeries, barsFromBins, ohlcFromTimeSeries, stacksFromGroups, stacksFromColumns, stacksFromBins, categoryStack, transposeRow, } from './data.js';
67
73
  export type { ChartSeries, BandSeries, BoxSeries, BoxColumns, BarSeries, OhlcSeries, OhlcColumns, StackedBarSeries, BinRecord, StacksFromBinsOptions, CategoryDatum, RowAt, TransposeRowOptions, } from './data.js';
68
74
  export type { Orientation } from './bars.js';
69
75
  export type { RadiusEncoding, ColorEncoding } from './encoding.js';