@pond-ts/charts 0.53.1 → 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,26 +50,62 @@ 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;
59
+ /**
60
+ * The narrowed selection / hover identity a **single-series** bar matches
61
+ * against: the layer's series `id`, the sample's `key` (its `begin`), and — when
62
+ * the series carries {@link BarSeries.marks} — the stable per-bar `mark`. The
63
+ * single-series sibling of {@link StackMark}, which additionally carries the
64
+ * stack's group `label` (a single-series bar has no group to disambiguate).
65
+ */
66
+ export interface BarMark {
67
+ readonly id: string;
68
+ readonly key: number;
69
+ readonly mark?: string;
70
+ }
57
71
  /**
58
72
  * Fill one rectangle per bar in `cs`, each spanning its key's `[begin, end]`
59
73
  * (inset by `gapPx`) from the resolved `baseline` to the value.
60
74
  *
61
75
  * A gap (non-finite value) is skipped — no bar, no zero-height sliver. A bar
62
- * matching the current `selection` (same sample `key` **and** the layer's own
63
- * series `id` — `seriesId`; a no-id layer passes `undefined` and never matches)
64
- * draws in the style's `highlight` colour **and outlined**, so a click reads back
65
- * on the canvas; a bar matching `hovered` draws in `highlight` **without** the
66
- * outline (a lighter "this bar is live" on pointer-over); all others use the flat
67
- * `fill`. `globalAlpha` carries the fill opacity and is restored so it doesn't
68
- * leak into later layers.
76
+ * matching the current `selection` (the layer's own series `id` `seriesId`; a
77
+ * no-id layer passes `undefined` and never matches — plus the bar's identity,
78
+ * see {@link barMatches}) draws in the style's `highlight` colour **and
79
+ * outlined**, so a click reads back on the canvas; a bar matching `hovered`
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**.
86
+ *
87
+ * **Which identity.** A selection carrying a `mark` matches against the series'
88
+ * stable per-bar name ({@link BarSeries.marks} — the sample's own axis key,
89
+ * which the readers always supply); one without falls back to the sample `key`
90
+ * (the bar's `begin`). The mark path is what lets a caller pin a bar on a
91
+ * **point-keyed** series without re-deriving the neighbour-spaced span, since
92
+ * there `begin` is not the sample's key but an edge computed from it.
69
93
  *
70
94
  * O(N) over the events, one fill (+ optional stroke) per bar, no per-bar
71
95
  * allocation beyond the rect tuple.
72
96
  *
97
+ * **Per-bar fills (`binFills`):** an optional colour array aligned
98
+ * index-for-index to the source bars — bar `i` fills with `binFills[i]`
99
+ * (an `undefined` entry falls back to the flat `fill`). This is the
100
+ * direction-coloured financial volume row (rising / falling) and the
101
+ * value-band case on a time axis. Highlight follows {@link drawStacks}'s
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]`.)
108
+ *
73
109
  * **M4 column decimation ([PND-MARKDEC]):** once the *visible* bars are denser
74
110
  * than ~2 per device pixel, they overplot into a solid silhouette, so
75
111
  * `decimate !== false` replaces them with one **envelope rect per pixel column**
@@ -79,15 +115,12 @@ export declare function barRect(cs: BarSeries, i: number, xScale: Scale, yScale:
79
115
  * columns aren't individually selectable, so per-bar selection/hover highlight is
80
116
  * suppressed (a <1px bar's ring wouldn't be visible anyway); interaction still
81
117
  * reads the **source** bars via {@link barAt} (§2.3). Pass `decimate={false}` to
82
- * always draw every bar. Returns {@link LayerDrawStats} for `onDrawStats`.
118
+ * always draw every bar. **`binFills` disables the envelope pass** — a single
119
+ * envelope rect spans many differently-coloured bars, so decimating would
120
+ * repaint them one flat colour; per-bar-coloured layers draw every visible bar.
121
+ * Returns {@link LayerDrawStats} for `onDrawStats`.
83
122
  */
84
- export declare function drawBars(ctx: CanvasRenderingContext2D, cs: BarSeries, xScale: Scale, yScale: Scale, style: BarStyle, baseline: number, gapPx: number, seriesId: string | undefined, selection: {
85
- key: number;
86
- id: string;
87
- } | null, hovered: {
88
- key: number;
89
- id: string;
90
- } | null, decimate?: DecimateOption): LayerDrawStats;
123
+ export declare function drawBars(ctx: CanvasRenderingContext2D, cs: BarSeries, xScale: Scale, yScale: Scale, style: BarStyle, baseline: number, gapPx: number, seriesId: string | undefined, selection: BarMark | null, hovered: BarMark | null, decimate?: DecimateOption, binFills?: readonly (string | undefined)[]): LayerDrawStats;
91
124
  /**
92
125
  * The index of the bar whose key span `[begin, end]` contains `time` — the bar
93
126
  * **under the cursor** — or `-1` if `time` falls in no bar's span. This is the
@@ -102,19 +135,68 @@ export declare function drawBars(ctx: CanvasRenderingContext2D, cs: BarSeries, x
102
135
  * cheap and allocation-free).
103
136
  */
104
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;
105
179
  /**
106
180
  * Hit-test plot-pixel `(px, py)` against `cs`'s bars — the **first** bar whose
107
- * rect contains the point, or `null`. The geometry is {@link barRect}, so the
108
- * hit rect is exactly the drawn rect (same `baseline`/`gapPx`/`minWidth`). The
109
- * returned tuple is `[index, begin, value]` for the chart to assemble a
110
- * `SelectInfo` (it owns the colour + label); keeping this layer free of the
111
- * 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`.
112
195
  *
113
196
  * O(N) over the events (no spatial index — bar counts are view-scale, hundreds
114
- * not millions; click is a rare event). Bars don't overlap in x for a sorted
115
- * series, so "first match" is unambiguous in practice.
197
+ * not millions; click is a rare event).
116
198
  */
117
- 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;
118
200
  /**
119
201
  * A resolved per-group stack style: `fills` aligned index-for-index to
120
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];
@@ -79,22 +81,70 @@ export function barRect(cs, i, xScale, yScale, baseline, gapPx, minWidthPx) {
79
81
  const yBase = yScale(baseline);
80
82
  return [x0, x1, Math.min(yValue, yBase), Math.max(yValue, yBase)];
81
83
  }
84
+ /**
85
+ * Does `m` identify the bar with stable identity `stable` and key `begin`?
86
+ * The mark decides **only when both sides have one** — `m.mark` (the selection
87
+ * names a bar) and `stable` (this series names its bars). Either missing falls
88
+ * back to `m.key === begin`, so both of these keep working unchanged:
89
+ *
90
+ * - a selection with **no `mark`** — every controlled `selected={{ id, key }}`
91
+ * that predates this channel, against a series that now carries marks;
92
+ * - a series with **no `marks`** — a hand-built {@link BarSeries} (tests, an
93
+ * outside caller assembling the view themselves).
94
+ *
95
+ * This is the `mark`-first rule {@link drawStacks} applies, with one deliberate
96
+ * difference: it falls back on the **selection** carrying no mark, where
97
+ * `drawStacks` falls back on the **series** carrying none. `drawStacks` can
98
+ * switch on the series alone because only `categoryStack` produces marks and it
99
+ * never had key-pinned consumers. Every reader-built bar series now carries
100
+ * marks, so that unconditional switch would silently stop matching each shipped
101
+ * key-pinned selection — key-pinning is the only selection bars ever had.
102
+ */
103
+ function barMatches(m, seriesId, stable, begin) {
104
+ if (m === null || m.id !== seriesId)
105
+ return false;
106
+ return m.mark !== undefined && stable !== undefined
107
+ ? m.mark === stable
108
+ : m.key === begin;
109
+ }
82
110
  /**
83
111
  * Fill one rectangle per bar in `cs`, each spanning its key's `[begin, end]`
84
112
  * (inset by `gapPx`) from the resolved `baseline` to the value.
85
113
  *
86
114
  * A gap (non-finite value) is skipped — no bar, no zero-height sliver. A bar
87
- * matching the current `selection` (same sample `key` **and** the layer's own
88
- * series `id` — `seriesId`; a no-id layer passes `undefined` and never matches)
89
- * draws in the style's `highlight` colour **and outlined**, so a click reads back
90
- * on the canvas; a bar matching `hovered` draws in `highlight` **without** the
91
- * outline (a lighter "this bar is live" on pointer-over); all others use the flat
92
- * `fill`. `globalAlpha` carries the fill opacity and is restored so it doesn't
93
- * leak into later layers.
115
+ * matching the current `selection` (the layer's own series `id` `seriesId`; a
116
+ * no-id layer passes `undefined` and never matches — plus the bar's identity,
117
+ * see {@link barMatches}) draws in the style's `highlight` colour **and
118
+ * outlined**, so a click reads back on the canvas; a bar matching `hovered`
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**.
125
+ *
126
+ * **Which identity.** A selection carrying a `mark` matches against the series'
127
+ * stable per-bar name ({@link BarSeries.marks} — the sample's own axis key,
128
+ * which the readers always supply); one without falls back to the sample `key`
129
+ * (the bar's `begin`). The mark path is what lets a caller pin a bar on a
130
+ * **point-keyed** series without re-deriving the neighbour-spaced span, since
131
+ * there `begin` is not the sample's key but an edge computed from it.
94
132
  *
95
133
  * O(N) over the events, one fill (+ optional stroke) per bar, no per-bar
96
134
  * allocation beyond the rect tuple.
97
135
  *
136
+ * **Per-bar fills (`binFills`):** an optional colour array aligned
137
+ * index-for-index to the source bars — bar `i` fills with `binFills[i]`
138
+ * (an `undefined` entry falls back to the flat `fill`). This is the
139
+ * direction-coloured financial volume row (rising / falling) and the
140
+ * value-band case on a time axis. Highlight follows {@link drawStacks}'s
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]`.)
147
+ *
98
148
  * **M4 column decimation ([PND-MARKDEC]):** once the *visible* bars are denser
99
149
  * than ~2 per device pixel, they overplot into a solid silhouette, so
100
150
  * `decimate !== false` replaces them with one **envelope rect per pixel column**
@@ -104,9 +154,12 @@ export function barRect(cs, i, xScale, yScale, baseline, gapPx, minWidthPx) {
104
154
  * columns aren't individually selectable, so per-bar selection/hover highlight is
105
155
  * suppressed (a <1px bar's ring wouldn't be visible anyway); interaction still
106
156
  * reads the **source** bars via {@link barAt} (§2.3). Pass `decimate={false}` to
107
- * always draw every bar. Returns {@link LayerDrawStats} for `onDrawStats`.
157
+ * always draw every bar. **`binFills` disables the envelope pass** — a single
158
+ * envelope rect spans many differently-coloured bars, so decimating would
159
+ * repaint them one flat colour; per-bar-coloured layers draw every visible bar.
160
+ * Returns {@link LayerDrawStats} for `onDrawStats`.
108
161
  */
109
- export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, seriesId, selection, hovered, decimate = true) {
162
+ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, seriesId, selection, hovered, decimate = true, binFills) {
110
163
  ctx.save();
111
164
  ctx.globalAlpha = style.opacity;
112
165
  const sourceCount = cs.length; // pre-cull, pre-decimation (for draw stats)
@@ -119,9 +172,13 @@ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, series
119
172
  // Decimate the visible bars to per-column envelope rects once dense (see the
120
173
  // header). `null` below the visible-density threshold ⇒ the full per-bar loop.
121
174
  // `{ threshold }` tunes the samples-per-pixel factor `k` (as line/area/band do);
122
- // `undefined` ⇒ decimateBars' default (2).
175
+ // `undefined` ⇒ decimateBars' default (2). Per-bar fills skip the envelope —
176
+ // one flat rect can't carry many bars' colours (see the header) — but an
177
+ // *empty* colour array is "no colours" (every bar would flat-fill anyway), so
178
+ // it stays on the legacy path end-to-end (L2 review, PR #542).
179
+ const fills = binFills !== undefined && binFills.length > 0 ? binFills : undefined;
123
180
  const k = typeof decimate === 'object' ? decimate.threshold : undefined;
124
- const envelope = decimate !== false
181
+ const envelope = decimate !== false && fills === undefined
125
182
  ? decimateBars(cs, xScale, ctx, baseline, k, vEnd - vStart)
126
183
  : null;
127
184
  if (envelope !== null) {
@@ -141,35 +198,74 @@ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, series
141
198
  ctx.restore();
142
199
  return { sourceCount, drawnCount: drawn, decimated: true };
143
200
  }
201
+ // The stable per-bar identity is consulted only when the live selection /
202
+ // hover actually carries a `mark` — `cs.marks` builds its strings lazily, so
203
+ // the *draw* never materializes them on its own. (The component's `hitTest`
204
+ // may already have: an interactive layer echoes the hovered bar's mark on
205
+ // every pointer move. See BarSeries.marks — this hoist keeps the draw path
206
+ // clean, it doesn't make the channel free.)
207
+ const marks = selection?.mark !== undefined || hovered?.mark !== undefined
208
+ ? cs.marks
209
+ : undefined;
144
210
  let drawn = 0;
145
211
  for (let i = vStart; i < vEnd; i += 1) {
146
212
  const rect = barRect(cs, i, xScale, yScale, baseline, gapPx, style.minWidth);
147
213
  if (rect === null)
148
214
  continue;
149
215
  const [x0, x1, yTop, yBottom] = rect;
150
- // Match by the series `id` **and** the sample `key` (begin), so two series
151
- // sharing a timestamp don't both light up (a no-id, non-selectable layer
152
- // passes `seriesId === undefined` and never matches). Both the committed
153
- // selection and the transient hover use the `highlight` fill; only the
154
- // selection adds the outline, so hover reads as a lighter "this bar is live"
155
- // and select as the committed pick.
156
- const selected = selection !== null &&
157
- selection.id === seriesId &&
158
- selection.key === cs.begin[i];
159
- const isHovered = hovered !== null &&
160
- hovered.id === seriesId &&
161
- hovered.key === cs.begin[i];
162
- ctx.fillStyle = selected || isHovered ? style.highlight : style.fill;
216
+ // Match by the series `id` **and** the bar's identity its stable `mark`
217
+ // when the selection carries one, else the sample `key` (begin) so two
218
+ // series sharing a timestamp don't both light up (a no-id, non-selectable
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.
223
+ const stable = marks?.[i];
224
+ const selected = barMatches(selection, seriesId, stable, cs.begin[i]);
225
+ const isHovered = barMatches(hovered, seriesId, stable, cs.begin[i]);
226
+ if (fills !== undefined) {
227
+ // Per-bar fills: the bar keeps its own colour under hover / selection —
228
+ // highlight pops the alpha to 1 and outlines the selection in the bar's
229
+ // own fill (the drawStacks binFills convention; see the header).
230
+ const fill = fills[i] ?? style.fill;
231
+ ctx.globalAlpha = selected || isHovered ? 1 : style.opacity;
232
+ ctx.fillStyle = fill;
233
+ ctx.fillRect(x0, yTop, x1 - x0, yBottom - yTop);
234
+ drawn += 1;
235
+ if (selected) {
236
+ ctx.lineWidth = style.outlineWidth;
237
+ ctx.strokeStyle = fill;
238
+ ctx.strokeRect(x0, yTop, x1 - x0, yBottom - yTop);
239
+ }
240
+ continue;
241
+ }
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;
163
257
  ctx.fillRect(x0, yTop, x1 - x0, yBottom - yTop);
164
258
  drawn += 1;
165
259
  if (selected) {
166
- // The selected bar gets an outline so it reads at full strength over the
167
- // (alpha'd) fills. Stroke at full opacity (reset within the save bracket).
168
- 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.
169
266
  ctx.lineWidth = style.outlineWidth;
170
267
  ctx.strokeStyle = style.highlight;
171
268
  ctx.strokeRect(x0, yTop, x1 - x0, yBottom - yTop);
172
- ctx.globalAlpha = style.opacity;
173
269
  }
174
270
  }
175
271
  ctx.restore();
@@ -195,21 +291,90 @@ export function barIndexAtTime(cs, time) {
195
291
  }
196
292
  return -1;
197
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
+ }
198
355
  /**
199
356
  * Hit-test plot-pixel `(px, py)` against `cs`'s bars — the **first** bar whose
200
- * rect contains the point, or `null`. The geometry is {@link barRect}, so the
201
- * hit rect is exactly the drawn rect (same `baseline`/`gapPx`/`minWidth`). The
202
- * returned tuple is `[index, begin, value]` for the chart to assemble a
203
- * `SelectInfo` (it owns the colour + label); keeping this layer free of the
204
- * 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`.
205
371
  *
206
372
  * O(N) over the events (no spatial index — bar counts are view-scale, hundreds
207
- * not millions; click is a rare event). Bars don't overlap in x for a sorted
208
- * series, so "first match" is unambiguous in practice.
373
+ * not millions; click is a rare event).
209
374
  */
210
- export function barAt(cs, px, py, xScale, yScale, baseline, gapPx, minWidthPx) {
375
+ export function barAt(cs, px, py, xScale, yScale, baseline, minWidthPx) {
211
376
  for (let i = 0; i < cs.length; i += 1) {
212
- const rect = barRect(cs, i, xScale, yScale, baseline, gapPx, minWidthPx);
377
+ const rect = barSlotRect(cs, i, xScale, yScale, baseline, minWidthPx);
213
378
  if (rect === null)
214
379
  continue;
215
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