@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.
- package/CHANGELOG.md +451 -1
- package/dist/AreaChart.d.ts +47 -22
- package/dist/AreaChart.js +24 -1
- package/dist/BandChart.d.ts +29 -17
- package/dist/BarChart.d.ts +92 -49
- package/dist/BarChart.js +65 -8
- package/dist/BarList.d.ts +127 -0
- package/dist/BarList.js +84 -0
- package/dist/BoxList.d.ts +108 -0
- package/dist/BoxList.js +125 -0
- package/dist/BoxPlot.d.ts +63 -30
- package/dist/Candlestick.d.ts +5 -4
- package/dist/ChartRow.js +57 -6
- package/dist/Layers.js +22 -2
- package/dist/LineChart.d.ts +29 -26
- package/dist/ListTable.d.ts +51 -0
- package/dist/ListTable.js +143 -0
- package/dist/ScatterChart.d.ts +27 -17
- package/dist/YAxis.d.ts +29 -1
- package/dist/YAxis.js +19 -5
- package/dist/area.js +46 -15
- package/dist/band.js +13 -0
- package/dist/bars.d.ts +89 -18
- package/dist/bars.js +141 -30
- package/dist/column-names.d.ts +74 -0
- package/dist/column-names.js +2 -0
- package/dist/context.d.ts +40 -2
- package/dist/data.d.ts +19 -0
- package/dist/data.js +46 -0
- package/dist/dev.d.ts +2 -0
- package/dist/dev.js +2 -0
- package/dist/domain.d.ts +54 -1
- package/dist/domain.js +195 -2
- package/dist/format.d.ts +20 -0
- package/dist/format.js +23 -10
- package/dist/gaps.d.ts +33 -0
- package/dist/gaps.js +49 -0
- package/dist/index.d.ts +19 -13
- package/dist/index.js +21 -13
- package/dist/line.js +10 -1
- package/dist/list-source.d.ts +61 -0
- package/dist/list-source.js +5 -0
- package/dist/list.d.ts +205 -0
- package/dist/list.js +165 -0
- package/dist/theme.d.ts +42 -0
- package/dist/theme.js +24 -0
- package/dist/viewport.d.ts +9 -1
- package/dist/viewport.js +40 -4
- package/dist/yticks.d.ts +44 -0
- package/dist/yticks.js +55 -0
- 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.
|
|
71
|
-
*
|
|
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
|
|
118
|
-
*
|
|
119
|
-
*
|
|
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
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
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).
|
|
214
|
-
//
|
|
215
|
-
//
|
|
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
|
-
|
|
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
|
|
241
|
-
//
|
|
242
|
-
|
|
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
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
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).
|
|
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,
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
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,
|
|
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
package/dist/dev.js
ADDED
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
|
|
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
|