@pond-ts/charts 0.54.0 → 0.56.2

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +451 -1
  2. package/dist/AreaChart.d.ts +47 -22
  3. package/dist/AreaChart.js +24 -1
  4. package/dist/BandChart.d.ts +29 -17
  5. package/dist/BarChart.d.ts +92 -49
  6. package/dist/BarChart.js +65 -8
  7. package/dist/BarList.d.ts +127 -0
  8. package/dist/BarList.js +84 -0
  9. package/dist/BoxList.d.ts +108 -0
  10. package/dist/BoxList.js +125 -0
  11. package/dist/BoxPlot.d.ts +63 -30
  12. package/dist/Candlestick.d.ts +5 -4
  13. package/dist/ChartRow.js +57 -6
  14. package/dist/Layers.js +22 -2
  15. package/dist/LineChart.d.ts +29 -26
  16. package/dist/ListTable.d.ts +51 -0
  17. package/dist/ListTable.js +143 -0
  18. package/dist/ScatterChart.d.ts +27 -17
  19. package/dist/YAxis.d.ts +29 -1
  20. package/dist/YAxis.js +19 -5
  21. package/dist/area.js +46 -15
  22. package/dist/band.js +13 -0
  23. package/dist/bars.d.ts +89 -18
  24. package/dist/bars.js +141 -30
  25. package/dist/column-names.d.ts +74 -0
  26. package/dist/column-names.js +2 -0
  27. package/dist/context.d.ts +40 -2
  28. package/dist/data.d.ts +19 -0
  29. package/dist/data.js +46 -0
  30. package/dist/dev.d.ts +2 -0
  31. package/dist/dev.js +2 -0
  32. package/dist/domain.d.ts +54 -1
  33. package/dist/domain.js +195 -2
  34. package/dist/format.d.ts +20 -0
  35. package/dist/format.js +23 -10
  36. package/dist/gaps.d.ts +33 -0
  37. package/dist/gaps.js +49 -0
  38. package/dist/index.d.ts +19 -13
  39. package/dist/index.js +21 -13
  40. package/dist/line.js +10 -1
  41. package/dist/list-source.d.ts +61 -0
  42. package/dist/list-source.js +5 -0
  43. package/dist/list.d.ts +205 -0
  44. package/dist/list.js +165 -0
  45. package/dist/theme.d.ts +42 -0
  46. package/dist/theme.js +24 -0
  47. package/dist/viewport.d.ts +9 -1
  48. package/dist/viewport.js +40 -4
  49. package/dist/yticks.d.ts +44 -0
  50. package/dist/yticks.js +55 -0
  51. package/package.json +3 -3
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;
@@ -344,6 +435,24 @@ export function stackBinExtent(ss) {
344
435
  return null;
345
436
  return [ss.begin[0], ss.end[ss.length - 1]];
346
437
  }
438
+ /**
439
+ * The value a stack's **first** segment rests on, in data units — the same
440
+ * `0`-clamped-into-the-domain rule {@link resolveBarBaseline} applies to a plain
441
+ * bar, read off whichever scale carries the stacked value (`yScale` when the
442
+ * bars grow up, `xScale` when they grow right).
443
+ *
444
+ * Both stack walks used to start at a literal `0`, which is right only while the
445
+ * domain contains zero — and a **log** domain never can. `yScale(0)` on a log
446
+ * scale is `NaN`, `fillRect` with a `NaN` argument is a silent canvas no-op, and
447
+ * the same rect feeds {@link stackAt} — so the bottom segment of every stack
448
+ * both vanished *and* became unhittable, with nothing to see but a stack that
449
+ * starts one segment up. The linear case is unaffected: the value extents pull
450
+ * `0` into the domain, so this returns exactly `0` and the geometry is
451
+ * unchanged.
452
+ */
453
+ export function stackBase(orientation, xScale, yScale) {
454
+ return resolveBarBaseline(orientation === 'vertical' ? yScale : xScale);
455
+ }
347
456
  /**
348
457
  * The pixel rect `[x0, x1, yTop, yBottom]` (ascending on both axes) of bin `b`'s
349
458
  * segment `g`, stacked so it sits atop `cumBefore` (the summed value of the
@@ -400,10 +509,11 @@ export function segmentRect(ss, b, g, orientation, xScale, yScale, cumBefore, ga
400
509
  */
401
510
  export function drawStacks(ctx, ss, orientation, xScale, yScale, style, gapPx, minSpanPx, seriesId, selection, hover) {
402
511
  const G = ss.groups.length;
512
+ const base = stackBase(orientation, xScale, yScale);
403
513
  ctx.save();
404
514
  ctx.globalAlpha = style.opacity;
405
515
  for (let b = 0; b < ss.length; b += 1) {
406
- let cum = 0;
516
+ let cum = base;
407
517
  for (let g = 0; g < G; g += 1) {
408
518
  const rect = segmentRect(ss, b, g, orientation, xScale, yScale, cum, gapPx, minSpanPx);
409
519
  const v = ss.values[b * G + g];
@@ -452,8 +562,9 @@ export function drawStacks(ctx, ss, orientation, xScale, yScale, style, gapPx, m
452
562
  */
453
563
  export function stackAt(ss, px, py, orientation, xScale, yScale, gapPx, minSpanPx) {
454
564
  const G = ss.groups.length;
565
+ const base = stackBase(orientation, xScale, yScale);
455
566
  for (let b = 0; b < ss.length; b += 1) {
456
- let cum = 0;
567
+ let cum = base;
457
568
  for (let g = 0; g < G; g += 1) {
458
569
  const rect = segmentRect(ss, b, g, orientation, xScale, yScale, cum, gapPx, minSpanPx);
459
570
  const v = ss.values[b * G + g];
@@ -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
@@ -1,4 +1,4 @@
1
- import type { ScaleLinear, ScaleTime } from 'd3-scale';
1
+ import type { ScaleContinuousNumeric, ScaleLinear, ScaleTime } from 'd3-scale';
2
2
  import type { ChartTheme } from './theme.js';
3
3
  import type { AxisFormat } from './format.js';
4
4
  import type { LegendItemSpec } from './swatch.js';
@@ -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
  }
@@ -739,11 +761,27 @@ export interface LayerEntry {
739
761
  readonly index: number;
740
762
  }
741
763
  /** A y-axis declared in a {@link ChartRow} via `<YAxis>`. */
764
+ /** Which scale a y axis maps its domain through. */
765
+ export type YScaleKind = 'linear' | 'log';
766
+ /**
767
+ * A row's resolved y scale — d3's `scaleLinear()`, or `scaleLog()` when the
768
+ * axis asks for `scale="log"`.
769
+ *
770
+ * Deliberately the **continuous-numeric** supertype rather than `ScaleLinear`:
771
+ * every consumer (the axis labels, the row's gridlines, the cursor readout, and
772
+ * every draw layer) only ever calls it, or reads `domain` / `range` / `ticks` /
773
+ * `tickFormat` / `invert` — the surface both scales share. Keeping the shared
774
+ * type here is what lets a log axis be transparent to the draw layers instead
775
+ * of every layer growing a branch.
776
+ */
777
+ export type YScale = ScaleContinuousNumeric<number, number>;
742
778
  export interface AxisSpec {
743
779
  readonly id: string;
744
780
  readonly side: 'left' | 'right';
745
781
  /** Gutter width in CSS pixels. */
746
782
  readonly width: number;
783
+ /** Which scale the axis maps its domain through ({@link YAxisProps.scale}). */
784
+ readonly scale: YScaleKind;
747
785
  /** Explicit domain bounds, or `undefined` to auto-fit linked layers. */
748
786
  readonly min: number | undefined;
749
787
  readonly max: number | undefined;
@@ -777,7 +815,7 @@ export interface AxisSpec {
777
815
  */
778
816
  export interface RowFrame {
779
817
  readonly height: number;
780
- readonly yScales: ReadonlyMap<string, ScaleLinear<number, number>>;
818
+ readonly yScales: ReadonlyMap<string, YScale>;
781
819
  /** Value formatter per axis id (resolved from the axis's {@link AxisSpec.format}
782
820
  * against its scale) — used by both the tick labels and the cursor readout, so
783
821
  * a value reads identically in both. */
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/dev.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export declare const isDev: boolean;
2
+ //# sourceMappingURL=dev.d.ts.map
package/dist/dev.js ADDED
@@ -0,0 +1,2 @@
1
+ export const isDev = typeof process === 'undefined' || process?.env?.NODE_ENV !== 'production';
2
+ //# sourceMappingURL=dev.js.map
package/dist/domain.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { YScaleKind } from './context.js';
1
2
  /**
2
3
  * Resolve a y-axis `[lo, hi]` domain from its explicit bounds and the extents of
3
4
  * the layers linked to it. An `undefined` bound auto-fits the data: with no
@@ -19,6 +20,58 @@
19
20
  * `pad × span` on each side — headroom without hand-computing bounds, useful to
20
21
  * lift a tight **explicit** domain off the plot edges. Applied last, to whatever
21
22
  * domain was resolved (explicit or auto); `0` is a no-op.
23
+ *
24
+ * `scale` selects the spacing. `'log'` delegates to {@link resolveLogDomain},
25
+ * which applies **every policy above** — verbatim explicit bounds, an auto-fit
26
+ * side that moves rather than a caller's bound being discarded, `.nice()` on a
27
+ * fully auto-fit domain — and differs only where a log axis forces it to: a
28
+ * non-positive bound has no position and is refused, and `pad` is a fraction of
29
+ * the *decades* spanned rather than of the difference.
30
+ */
31
+ export declare function resolveYDomain(min: number | undefined, max: number | undefined, extents: Iterable<readonly [number, number] | null>, pad?: number, scale?: YScaleKind): [number, number];
32
+ /**
33
+ * Does resolving this axis's domain need its layers' extents walked?
34
+ * `yExtent()` is O(points) per layer, so the caller only pays it when a side
35
+ * actually auto-fits.
36
+ *
37
+ * A log axis **refuses a non-positive bound** ({@link resolveLogDomain}), which
38
+ * means such a bound is not a bound: that side auto-fits and needs the data. The
39
+ * naive `min === undefined || max === undefined` test misses this, and the miss
40
+ * is silent — `<YAxis scale="log" min={0} max={1e6}>` looked fully explicit, so
41
+ * no extents were gathered, so the refused floor fell back to the empty-data
42
+ * placeholder instead of the data's own floor. (`resolveLogDomain`'s unit tests
43
+ * passed throughout: they hand it the extents directly, which is precisely what
44
+ * the component was not doing.)
45
+ */
46
+ export declare function needsExtents(axis: {
47
+ readonly scale: YScaleKind;
48
+ readonly min: number | undefined;
49
+ readonly max: number | undefined;
50
+ }): boolean;
51
+ /**
52
+ * The dev-mode complaint a `scale="log"` axis has about its own bounds and the
53
+ * data linked to it, or `null` when it has none. Pure, so the policy is unit
54
+ * tested directly rather than through a rendered console spy.
55
+ *
56
+ * **Every case here is unambiguous**, which is the whole design constraint. The
57
+ * previous version warned whenever a linked extent reached zero, and that fires
58
+ * on *every* `BarChart` — `barExtent` always widens its low end to `0` so a bar
59
+ * can reach its baseline, whether or not the data goes anywhere near it. So the
60
+ * `WithBars` story warned, on strictly positive data, with text asserting
61
+ * something false about it. A dev warning that cries wolf gets muted, and then
62
+ * the real ones are lost too.
63
+ *
64
+ * The cost of that precision is the one genuinely ambiguous shape: an extent of
65
+ * exactly `[0, hi]`, which is what a line touching zero *and* a bar layer on
66
+ * positive data both report. It is not warned about. That case is no longer
67
+ * silent, though — a sample with no position on the axis now renders as a
68
+ * **gap** rather than being bridged straight over, so the picture itself says
69
+ * the data is missing there.
22
70
  */
23
- export declare function resolveYDomain(min: number | undefined, max: number | undefined, extents: Iterable<readonly [number, number] | null>, pad?: number): [number, number];
71
+ export declare function logAxisWarning(axis: {
72
+ readonly id: string;
73
+ readonly scale: YScaleKind;
74
+ readonly min: number | undefined;
75
+ readonly max: number | undefined;
76
+ }, extents: readonly (readonly [number, number] | null)[]): string | null;
24
77
  //# sourceMappingURL=domain.d.ts.map