@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
@@ -0,0 +1,143 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * @internal The shared row-table shell behind {@link BarList} / {@link BoxList}.
4
+ *
5
+ * Renders a real `<table>` — the point of the list family is table semantics
6
+ * (label cells that can be links, aligned data cells, a `colSpan` detail row
7
+ * for the expander, screen-reader-legible rows), which a canvas plot can't
8
+ * carry and which hand-rolled flex rows re-implement badly (per-row cell
9
+ * alignment is exactly what table layout solves). The glyph cell takes
10
+ * `width: 100%` so it absorbs the free width; every text cell shrinks to fit.
11
+ *
12
+ * Not exported from the package: the public surface is the two sisters, so the
13
+ * shared shell can evolve without a compatibility contract.
14
+ */
15
+ import { Fragment, useState } from 'react';
16
+ /** The turquoise the selected-row edge falls back to when the theme has no
17
+ * annotation register — the same built-in the annotation layer uses. */
18
+ const FALLBACK_ACCENT = '#0d9488';
19
+ /** The shared text ink: the band-label tone when the theme has one (stronger
20
+ * than tick labels — these cells are primary content), else the tick ink. */
21
+ export function listInk(theme) {
22
+ return theme.axis.band?.label ?? theme.axis.label;
23
+ }
24
+ export function ListTable({ rows, kind, renderGlyphs, before = [], after = [], renderExpanded, defaultExpanded, onExpandToggle, selected, onRowClick, divided = true, baseline = false, markers = [], theme, }) {
25
+ // Uncontrolled expansion, keyed on row identity so it survives a re-sort.
26
+ const [expanded, setExpanded] = useState(() => new Set(defaultExpanded ?? []));
27
+ const [hovered, setHovered] = useState(null);
28
+ const interactive = onRowClick !== undefined;
29
+ const toggle = (key) => {
30
+ const open = !expanded.has(key);
31
+ setExpanded((prev) => {
32
+ const next = new Set(prev);
33
+ if (open)
34
+ next.add(key);
35
+ else
36
+ next.delete(key);
37
+ return next;
38
+ });
39
+ onExpandToggle?.(key, open);
40
+ };
41
+ const ink = listInk(theme);
42
+ const accent = theme.annotation?.color ?? FALLBACK_ACCENT;
43
+ const divider = divided ? `1px solid ${theme.axis.grid}` : undefined;
44
+ // Label + before + glyph + after (+ expander) — the detail row spans them all.
45
+ const span = 2 + before.length + after.length + (renderExpanded ? 1 : 0);
46
+ const textCell = (align) => ({
47
+ padding: '6px 12px',
48
+ whiteSpace: 'nowrap',
49
+ textAlign: align ?? 'left',
50
+ verticalAlign: 'middle',
51
+ });
52
+ // The glyph cell's shared horizontal geometry — the label strip must use
53
+ // the SAME left/right padding (and baseline border) as the data rows, or
54
+ // its percentages would resolve against a different content width and the
55
+ // labels would sit off their rules.
56
+ const glyphCellStyle = (vertical) => ({
57
+ width: '100%',
58
+ padding: baseline ? `${vertical} 8px ${vertical} 5px` : `${vertical} 8px`,
59
+ verticalAlign: 'middle',
60
+ borderLeft: baseline ? `1px solid ${theme.axis.grid}` : undefined,
61
+ });
62
+ const drawnMarkers = markers.filter((m) => m.frac !== null);
63
+ return (_jsx("table", { "data-list": kind, style: {
64
+ width: '100%',
65
+ borderCollapse: 'collapse',
66
+ font: `${theme.font.size}px/${1.5} ${theme.font.family}`,
67
+ color: ink,
68
+ background: theme.background,
69
+ }, children: _jsxs("tbody", { children: [drawnMarkers.some((m) => m.label !== undefined) && (
70
+ // The marker label strip: one synthetic row above the data, its
71
+ // glyph cell sharing the data rows' horizontal geometry so each
72
+ // label centres exactly on its rule below.
73
+ _jsxs("tr", { "data-list-marker-labels": "", children: [_jsx("td", { style: textCell() }), before.map((cell) => (_jsx("td", { style: textCell(cell.align) }, cell.key))), _jsx("td", { style: glyphCellStyle('0px'), children: _jsx("div", { style: {
74
+ position: 'relative',
75
+ height: theme.font.size + 6,
76
+ }, children: drawnMarkers.map((m, mi) => m.label !== undefined && (_jsx("span", { "data-list-marker-label": "", style: {
77
+ position: 'absolute',
78
+ left: `${m.frac * 100}%`,
79
+ bottom: 0,
80
+ transform: 'translateX(-50%)',
81
+ whiteSpace: 'nowrap',
82
+ color: accent,
83
+ }, children: m.label }, mi))) }) }), after.map((cell) => (_jsx("td", { style: textCell(cell.align) }, cell.key))), renderExpanded !== undefined && _jsx("td", {})] })), rows.map((row, i) => {
84
+ const isSelected = selected != null && selected === row.key;
85
+ const isOpen = renderExpanded !== undefined && expanded.has(row.key);
86
+ return (_jsxs(Fragment, { children: [_jsxs("tr", { "data-list-row": row.key, ...(isSelected ? { 'data-selected': '' } : {}), onClick: onRowClick === undefined ? undefined : () => onRowClick(row),
87
+ // A clickable row is keyboard-reachable too: focusable, and
88
+ // Enter / Space activate it (Space's default scroll is eaten).
89
+ tabIndex: interactive ? 0 : undefined, onKeyDown: onRowClick === undefined
90
+ ? undefined
91
+ : (e) => {
92
+ if (e.key === 'Enter' || e.key === ' ') {
93
+ e.preventDefault();
94
+ onRowClick(row);
95
+ }
96
+ }, onPointerEnter: interactive ? () => setHovered(row.key) : undefined, onPointerLeave: interactive ? () => setHovered(null) : undefined, style: {
97
+ borderTop: i > 0 ? divider : undefined,
98
+ cursor: interactive ? 'pointer' : undefined,
99
+ background: interactive && hovered === row.key
100
+ ? (theme.legend?.border ?? theme.axis.grid)
101
+ : undefined,
102
+ // The selection accent: an inset edge in the annotation
103
+ // register (a *user's* mark, so it takes the marks colour,
104
+ // not a data hue) — reads on any ground, moves no layout.
105
+ boxShadow: isSelected ? `inset 3px 0 0 ${accent}` : undefined,
106
+ }, children: [_jsx("td", { "data-list-cell": "label", style: textCell(), children: row.label ?? row.key }), before.map((cell) => (_jsx("td", { "data-list-cell": cell.key, style: textCell(cell.align), children: cell.render(row) }, cell.key))), _jsx("td", { "data-list-cell": "glyphs",
107
+ // 100% absorbs the table's free width; every text cell
108
+ // shrinks to its content, staying aligned down the list.
109
+ // The baseline rule marks the scale origin: left padding
110
+ // narrows to a 5px breath so the glyphs start just off the
111
+ // rule, and border-collapse joins the rows' rules into one
112
+ // continuous vertical — the same thin `axis.grid` ink as
113
+ // the row dividers, so the two read as one quiet grid.
114
+ style: glyphCellStyle('6px'), children: _jsxs("div", { style: { position: 'relative' }, children: [renderGlyphs(row), drawnMarkers.map((m, mi) => (
115
+ // One dotted segment per row, bleeding through the
116
+ // row's vertical padding (+ divider) so adjacent rows'
117
+ // segments join into one continuous rule. Annotation
118
+ // register — a reference is a user's mark, not data.
119
+ _jsx("div", { "data-list-marker": "", style: {
120
+ position: 'absolute',
121
+ top: -7,
122
+ bottom: -7,
123
+ left: `calc(${m.frac * 100}% - 0.5px)`,
124
+ width: 0,
125
+ borderLeft: `1px dotted ${accent}`,
126
+ pointerEvents: 'none',
127
+ } }, mi)))] }) }), after.map((cell) => (_jsx("td", { "data-list-cell": cell.key, style: textCell(cell.align), children: cell.render(row) }, cell.key))), renderExpanded !== undefined && (_jsx("td", { style: { padding: '0 4px', verticalAlign: 'middle' }, children: _jsx("button", { type: "button", "data-list-expander": "", "aria-expanded": isOpen, "aria-label": isOpen ? 'Collapse row' : 'Expand row', onClick: (e) => {
128
+ // The chevron toggles; it must not double as a row click.
129
+ e.stopPropagation();
130
+ toggle(row.key);
131
+ }, style: {
132
+ background: 'none',
133
+ border: 'none',
134
+ cursor: 'pointer',
135
+ color: theme.axis.label,
136
+ font: 'inherit',
137
+ padding: '2px 6px',
138
+ transform: isOpen ? 'rotate(90deg)' : undefined,
139
+ transition: 'transform 120ms',
140
+ }, children: "\u25B8" }) }))] }), isOpen && (_jsx("tr", { "data-list-detail": row.key, children: _jsx("td", { colSpan: span, style: { padding: '2px 12px 12px' }, children: renderExpanded(row) }) }))] }, row.key));
141
+ })] }) }));
142
+ }
143
+ //# sourceMappingURL=ListTable.js.map
@@ -1,24 +1,9 @@
1
1
  import { ValueSeries } from 'pond-ts';
2
2
  import type { SeriesSchema, TimeSeries, ValueSeriesSchema } from 'pond-ts';
3
+ import type { NumericColumn, ValueNumericColumn } from './column-names.js';
3
4
  import { type ColorEncoding, type RadiusEncoding } from './encoding.js';
4
5
  import type { DecimateOption } from './decimate.js';
5
- export interface ScatterChartProps<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema> {
6
- /**
7
- * The source series. A `TimeSeries` scatters against the time axis; a
8
- * `ValueSeries` (`series.byValue('cumDist')`, or `ValueSeries.fromColumns`
9
- * for natively value-keyed data — IV marks keyed by strike) against its
10
- * value axis — the container infers which from the data, no axis-type prop
11
- * (mirrors `<LineChart>`). Either way the key / axis column supplies each
12
- * point's x and `column` supplies y.
13
- *
14
- * **Live charts:** `series.byValue(…)` mints a *fresh* projection each call,
15
- * so passing `series={s.byValue('dist')}` inline re-registers this layer
16
- * every render — memoize the projection (`useMemo`) on a frequently
17
- * re-rendering chart.
18
- */
19
- series: TimeSeries<S> | ValueSeries<VS>;
20
- /** Name of the numeric value column — each point's y. */
21
- column: string;
6
+ export interface ScatterChartCommon<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema> {
22
7
  /**
23
8
  * The scatter's semantic identifier — what the marks _are_ / how they should
24
9
  * read. The theme maps it to a {@link ScatterStyle} (`theme.scatter[as] ??
@@ -109,6 +94,30 @@ export interface ScatterChartProps<S extends SeriesSchema = SeriesSchema, VS ext
109
94
  */
110
95
  index?: number;
111
96
  }
97
+ /**
98
+ * ScatterChart's source + column props, a **union over the series kind** so the
99
+ * column names are checked against the schema that was actually passed
100
+ * ([PND-CHARTAPI]). A single member carrying `NumericColumn<S> |
101
+ * ValueNumericColumn<VS>` would silently widen to `string`: only one of the
102
+ * two generics is ever inferred, and the other falls back (measured in
103
+ * `spikes/charts-type-seam/`). Loosely-typed series still accept any name.
104
+ */
105
+ type ScatterChartSource<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema> = {
106
+ /**
107
+ * The source series. **Live charts:** `series.byValue(…)` mints a
108
+ * *fresh* projection each call, so an inline `series={s.byValue('d')}`
109
+ * re-registers this layer every render — on a frequently re-rendering
110
+ * (e.g. scrub-driven) chart, memoize the projection (`useMemo`) so the
111
+ * layer isn't rebuilt each frame.
112
+ */
113
+ series: TimeSeries<S>;
114
+ column: NumericColumn<S>;
115
+ } | {
116
+ series: ValueSeries<VS>;
117
+ column: ValueNumericColumn<VS>;
118
+ };
119
+ /** `<ScatterChart>`'s props: the shared knobs plus one series-kind source shape. */
120
+ export type ScatterChartProps<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema> = ScatterChartCommon<S, VS> & ScatterChartSource<S, VS>;
112
121
  /**
113
122
  * A scatter draw layer: one mark per finite point at `(x, column-value)`
114
123
  * — x from the series' key / axis column (time or value axis) —
@@ -138,4 +147,5 @@ export interface ScatterChartProps<S extends SeriesSchema = SeriesSchema, VS ext
138
147
  * ```
139
148
  */
140
149
  export declare function ScatterChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, as: semantic, id, axis, radius, color, label, offset, decimate, legend, index, }: ScatterChartProps<S, VS>): null;
150
+ export {};
141
151
  //# sourceMappingURL=ScatterChart.d.ts.map
package/dist/YAxis.d.ts CHANGED
@@ -20,6 +20,34 @@ export interface YAxisProps {
20
20
  * that has headroom (auto-fit / padded) so it doesn't crowd the top tick.
21
21
  */
22
22
  labelPlacement?: 'rotated' | 'top';
23
+ /**
24
+ * Which scale the axis maps its domain through. **Default `'linear'`.**
25
+ *
26
+ * `'log'` gives a base-10 logarithmic axis — for data spanning orders of
27
+ * magnitude, where a linear axis flattens everything below the top decade
28
+ * onto the baseline. Ticks land on the decades, and `format` still formats
29
+ * the **value**, so a readout says `1.2 PB`, not its logarithm.
30
+ *
31
+ * A log domain cannot contain zero or negative numbers — d3 maps them to
32
+ * `NaN`, which has no position on the plot. So:
33
+ *
34
+ * - **Auto-fit ignores non-positive extents** when picking the low end (a
35
+ * `BarChart`, whose extent always reaches zero so its bars can meet their
36
+ * baseline, can therefore share the axis), and rounds the domain out to
37
+ * whole powers of ten.
38
+ * - **An explicit `min`/`max` that is not positive is refused**, and that
39
+ * side auto-fits instead. A positive bound is always honoured exactly; when
40
+ * only one side is given and the domain would invert, the *auto* side moves
41
+ * — the same policy a linear axis follows.
42
+ * - **Layers that fill to a baseline** (`AreaChart`, `BarChart`, a stacked
43
+ * histogram) rest it on the bottom of the domain rather than on zero.
44
+ * - **A value with no position gaps the line**, rather than its neighbours
45
+ * being joined straight across it.
46
+ *
47
+ * A dev-mode warning fires for the cases that are unambiguously a mistake: a
48
+ * refused bound, negative data, or an axis with no positive data at all.
49
+ */
50
+ scale?: 'linear' | 'log';
23
51
  /** Explicit domain bounds; omit to auto-fit the charts linked to this axis. */
24
52
  min?: number;
25
53
  max?: number;
@@ -100,5 +128,5 @@ export interface YAxisProps {
100
128
  * tick marks + labels from that scale. Charts attach via `<LineChart axis="id">`
101
129
  * (default: the first axis).
102
130
  */
103
- export declare function YAxis({ id, side, label, min, max, format, ticks, tickCount, pad, boundaryLabels, width, labelPlacement, color, index, }: YAxisProps): import("react/jsx-runtime").JSX.Element;
131
+ export declare function YAxis({ id, side, label, scale, min, max, format, ticks, tickCount, pad, boundaryLabels, width, labelPlacement, color, index, }: YAxisProps): import("react/jsx-runtime").JSX.Element;
104
132
  //# sourceMappingURL=YAxis.d.ts.map
package/dist/YAxis.js CHANGED
@@ -3,6 +3,7 @@ import { useContext, useEffect, useMemo } from 'react';
3
3
  import { ContainerContext, RowContext } from './context.js';
4
4
  import { resolveAxisFormat } from './format.js';
5
5
  import { useSlotKey } from './use-slot-key.js';
6
+ import { yTickValues } from './yticks.js';
6
7
  const DEFAULT_WIDTH = 50;
7
8
  /** Fallback tick count before the row has published its resolved count (the
8
9
  * first render, pre-registration). The row's height-derived value takes over
@@ -16,7 +17,7 @@ const DEFAULT_TICK_COUNT = 5;
16
17
  * tick marks + labels from that scale. Charts attach via `<LineChart axis="id">`
17
18
  * (default: the first axis).
18
19
  */
19
- export function YAxis({ id, side = 'left', label, min, max, format, ticks, tickCount, pad = 0, boundaryLabels = true, width = DEFAULT_WIDTH, labelPlacement = 'rotated', color, index = 0, }) {
20
+ export function YAxis({ id, side = 'left', label, scale = 'linear', min, max, format, ticks, tickCount, pad = 0, boundaryLabels = true, width = DEFAULT_WIDTH, labelPlacement = 'rotated', color, index = 0, }) {
20
21
  const container = useContext(ContainerContext);
21
22
  if (container === null) {
22
23
  throw new Error('<YAxis> must be rendered inside a <ChartContainer>');
@@ -29,6 +30,7 @@ export function YAxis({ id, side = 'left', label, min, max, format, ticks, tickC
29
30
  id,
30
31
  side,
31
32
  width,
33
+ scale,
32
34
  min,
33
35
  max,
34
36
  pad,
@@ -41,6 +43,7 @@ export function YAxis({ id, side = 'left', label, min, max, format, ticks, tickC
41
43
  id,
42
44
  side,
43
45
  width,
46
+ scale,
44
47
  min,
45
48
  max,
46
49
  pad,
@@ -72,14 +75,25 @@ export function YAxis({ id, side = 'left', label, min, max, format, ticks, tickC
72
75
  // a cursor value read identically. `count` calibrates the default formatter's
73
76
  // precision to the tick density, exactly as the axis is.
74
77
  const fmt = yScale ? resolveAxisFormat(yScale, count, format) : String;
78
+ // A **horizontal categorical** layer on this axis supplies its category
79
+ // names ([PND-HCAT]); with no explicit `ticks`, label one per unit slot at
80
+ // its centre (`i + 0.5`) instead of the scale's numeric ticks — a slot index
81
+ // is not a number anyone wants to read. Explicit `ticks` still win, and a
82
+ // non-categorical row is unaffected (no layer answers, so this is `null`).
83
+ const layerCategories = row.layers
84
+ .filter((e) => (e.axisId ?? row.defaultAxisId) === id)
85
+ .map((e) => e.layer.binCategories?.() ?? null)
86
+ .find((c) => c !== null) ?? null;
75
87
  // Explicit `{ at, label }` ticks render verbatim (each label at its `at`),
76
88
  // overriding the auto-picked d3 ticks; otherwise label the scale's ticks via `fmt`.
77
89
  const tickList = ticks
78
90
  ? ticks.map((t) => ({ value: t.at, label: t.label }))
79
- : (yScale ? yScale.ticks(count) : []).map((t) => ({
80
- value: t,
81
- label: fmt(t),
82
- }));
91
+ : layerCategories !== null
92
+ ? layerCategories.map((label, i) => ({ value: i + 0.5, label }))
93
+ : (yScale ? yTickValues(yScale, count) : []).map((t) => ({
94
+ value: t,
95
+ label: fmt(t),
96
+ }));
83
97
  // The row reserves a slot per axis column (the widest in that column across
84
98
  // rows). Size the box to the slot and align this axis's own (narrower)
85
99
  // content toward the plot — left axes flush right, right axes flush left — so
package/dist/area.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { area as d3area, curveLinear } from 'd3-shape';
2
2
  import { strokeAffinePolyline } from './line.js';
3
- import { bridgeGaps, collectGapEdges, drawGapBridges, drawGapFades, drawGapSteps, withAlpha, DEFAULT_GAP_MODE, DEFAULT_GAP_CONNECTOR_OPACITY, } from './gaps.js';
3
+ import { bridgeGaps, collectGapEdges, drawGapBridges, drawGapFades, drawGapSteps, gapUnscalable, withAlpha, DEFAULT_GAP_MODE, DEFAULT_GAP_CONNECTOR_OPACITY, } from './gaps.js';
4
4
  import { cullChartSeries } from './culling.js';
5
5
  import { decimateM4Cached } from './decimate.js';
6
6
  import { affineOf } from './affine.js';
@@ -191,6 +191,15 @@ export function drawArea(ctx, cs, xScale, yScale, style, baselineValue, curve =
191
191
  else {
192
192
  cs = cullChartSeries(source, xScale);
193
193
  }
194
+ // Values with no position on the y scale (zero / negative on a log axis)
195
+ // become ordinary NaN gaps, so the fill and outline break at them rather than
196
+ // bridging over a dropped `lineTo(x, NaN)`. Deliberately **after** the
197
+ // gradient above: that reads the pre-cull buffer, whose finite extent is
198
+ // memoized per `Float64Array` ([PND-GRADX]), and a fresh array here would miss
199
+ // that cache on every frame. A no-op on an affine (linear) y scale.
200
+ const scaledY = gapUnscalable(cs.y, cs.length, yScale);
201
+ if (scaledY !== cs.y)
202
+ cs = { ...cs, y: scaledY };
194
203
  // `none` interpolates interior gaps so the fill + outline bridge them; every
195
204
  // other mode keeps NaN so d3 breaks both (the inferred line bridge, if any, is
196
205
  // a separate overlay pass below).
@@ -277,20 +286,42 @@ export function drawArea(ctx, cs, xScale, yScale, style, baselineValue, curve =
277
286
  function buildGradient(ctx, valueExtent, yScale, baselinePx, style) {
278
287
  if (valueExtent === null)
279
288
  return style.fill; // no finite values (caller no-ops)
280
- // The pixel extent is the two value extremes mapped through the (monotonic,
281
- // always-`scaleLinear`) y scale; min/max them so the result is flip-agnostic,
282
- // exactly as the former per-point pixel scan produced. [PND-GRADX] moved the
283
- // O(N) walk into the memoized {@link columnFiniteExtent}.
284
- const pa = yScale(valueExtent[0]);
285
- const pb = yScale(valueExtent[1]);
286
- const topPx = Math.min(pa, pb); // smallest pixel y (highest on screen)
287
- const bottomPx = Math.max(pa, pb); // largest pixel y (lowest on screen)
288
- // The drawn region runs from the topmost of {values, baseline} to the
289
- // bottommost the fill reaches the baseline, so include it.
290
- const regionTop = Math.min(topPx, baselinePx);
291
- const regionBottom = Math.max(bottomPx, baselinePx);
292
- if (regionBottom - regionTop < 1e-6)
293
- return style.fill; // degenerate height
289
+ // Stacked areas opt out of the grade entirely: a band that fades to
290
+ // transparent at the baseline shows every band beneath it (see AreaStyle).
291
+ if (style.flatFill === true)
292
+ return style.fill;
293
+ // The pixel extent is the two value extremes mapped through the (monotonic)
294
+ // y scale; min/max them so the result is flip-agnostic, exactly as the former
295
+ // per-point pixel scan produced. [PND-GRADX] moved the O(N) walk into the
296
+ // memoized {@link columnFiniteExtent}.
297
+ //
298
+ // **An extreme with no position on the scale is dropped**, not min/maxed in.
299
+ // `valueExtent` is the data's own `[min, max]`, and on a **log** axis a
300
+ // non-positive extreme — a series that touches zero, which is the ordinary
301
+ // shape of traffic or storage data — maps to `NaN`. `Math.min(NaN, pb)` is
302
+ // `NaN`, `NaN` propagates to the height, and `NaN < 1e-6` is **false**, so the
303
+ // degenerate guard below waved it through to `createLinearGradient(0, NaN, 0,
304
+ // NaN)` — which throws `IndexSizeError` on a real canvas and takes the whole
305
+ // chart down. The region is seeded from the baseline pixel (always in-domain,
306
+ // via `resolveAreaBaseline`) and widened only by extremes that have a
307
+ // position, so the grade still spans the part of the series that draws.
308
+ let regionTop = baselinePx;
309
+ let regionBottom = baselinePx;
310
+ const widen = (px) => {
311
+ if (!Number.isFinite(px))
312
+ return;
313
+ if (px < regionTop)
314
+ regionTop = px;
315
+ if (px > regionBottom)
316
+ regionBottom = px;
317
+ };
318
+ widen(yScale(valueExtent[0]));
319
+ widen(yScale(valueExtent[1]));
320
+ // `!(… >= 1e-6)` rather than `< 1e-6`, so a non-finite height — a baseline
321
+ // that somehow has no position either, leaving nothing finite to anchor on —
322
+ // falls back to the flat fill instead of reaching the gradient calls.
323
+ if (!(regionBottom - regionTop >= 1e-6))
324
+ return style.fill; // degenerate
294
325
  const opaque = style.fill;
295
326
  const transparent = withAlpha(style.fill, 0);
296
327
  const grad = ctx.createLinearGradient(0, regionTop, 0, regionBottom);
package/dist/band.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { area as d3area, curveLinear } from 'd3-shape';
2
2
  import { cullBandSeries } from './culling.js';
3
3
  import { decimateBand } from './decimate.js';
4
+ import { gapUnscalable } from './gaps.js';
4
5
  /**
5
6
  * The `[min, max]` vertical extent of the **drawn** band — the lowest `lower`
6
7
  * and highest `upper` over samples where both edges are finite — or `null` if
@@ -60,6 +61,18 @@ export function drawBand(ctx, band, xScale, yScale, style, curve = curveLinear,
60
61
  band = decimateBand(band, xScale, ctx, k);
61
62
  decimated = band !== before;
62
63
  }
64
+ // An edge with no position on the y scale becomes an ordinary NaN gap, so the
65
+ // envelope breaks there rather than emitting dropped path ops that stitch the
66
+ // neighbouring samples together. A `lower` of `0` is the common shape — a band
67
+ // measured from nothing — and on a log axis zero has no position, so without
68
+ // this the fill silently spanned the samples it could not draw. Gapping either
69
+ // edge gaps the sample, which is already the band's contract: a sample counts
70
+ // only where **both** edges do. A no-op on an affine (linear) y scale.
71
+ const gapLower = gapUnscalable(band.lower, band.length, yScale);
72
+ const gapUpper = gapUnscalable(band.upper, band.length, yScale);
73
+ if (gapLower !== band.lower || gapUpper !== band.upper) {
74
+ band = { ...band, lower: gapLower, upper: gapUpper };
75
+ }
63
76
  const gen = d3area()
64
77
  .defined((_, i) => Number.isFinite(band.lower[i]) && Number.isFinite(band.upper[i]))
65
78
  .x((_, i) => xScale(band.x[i]))
package/dist/bars.d.ts CHANGED
@@ -50,8 +50,10 @@ export declare function resolveBarBaseline(yScale: Scale): number;
50
50
  * from {@link barSpanPx} (the key's `[begin, end]`, inset by `gapPx`, floored at
51
51
  * `minWidthPx`); the y-span runs between the value and the `baseline` pixel,
52
52
  * normalized so a value above *or* below the baseline both yield an ascending
53
- * rect. Shared by {@link drawBars} and {@link barAt} so the drawn rect and the
54
- * hit rect are the same geometry.
53
+ * rect. This is the **ink** what {@link drawBars} paints. Hit-testing uses
54
+ * {@link barSlotRect} instead (the bar's whole slot), so the drawn rect and the
55
+ * hit region are deliberately *not* the same geometry: the `gapPx` inset
56
+ * separates columns visually without carving a dead channel out of the target.
55
57
  */
56
58
  export declare function barRect(cs: BarSeries, i: number, xScale: Scale, yScale: Scale, baseline: number, gapPx: number, minWidthPx: number): [x0: number, x1: number, yTop: number, yBottom: number] | null;
57
59
  /**
@@ -75,9 +77,12 @@ export interface BarMark {
75
77
  * no-id layer passes `undefined` and never matches — plus the bar's identity,
76
78
  * see {@link barMatches}) draws in the style's `highlight` colour **and
77
79
  * outlined**, so a click reads back on the canvas; a bar matching `hovered`
78
- * draws in `highlight` **without** the outline (a lighter "this bar is live" on
79
- * pointer-over); all others use the flat `fill`. `globalAlpha` carries the fill
80
- * opacity and is restored so it doesn't leak into later layers.
80
+ * draws **without** the outline (a lighter "this bar is live" on pointer-over)
81
+ * in the style's optional `hover` colour, or in `highlight` when the theme
82
+ * doesn't set one; all others use the flat `fill`. Either live state fills at
83
+ * **full opacity** — the resting `opacity` applies to resting bars only, and is
84
+ * restored so it doesn't leak into later layers. A bar that is both selected
85
+ * and hovered reads as **selected**.
81
86
  *
82
87
  * **Which identity.** A selection carrying a `mark` matches against the series'
83
88
  * stable per-bar name ({@link BarSeries.marks} — the sample's own axis key,
@@ -94,11 +99,12 @@ export interface BarMark {
94
99
  * (an `undefined` entry falls back to the flat `fill`). This is the
95
100
  * direction-coloured financial volume row (rising / falling) and the
96
101
  * value-band case on a time axis. Highlight follows {@link drawStacks}'s
97
- * binFills convention rather than the flat path's: the bar **keeps its own
98
- * colour** under hover / selection — the highlight pops `globalAlpha` to 1
99
- * (and outlines the selection in the bar's own fill) — so a red / green bar
100
- * stays red / green while live, instead of swapping to the single
101
- * `highlight` colour and losing its meaning.
102
+ * binFills convention: the bar **keeps its own colour** under hover /
103
+ * selection — the highlight pops `globalAlpha` to 1 (and outlines the
104
+ * selection in the bar's own fill) — so a red / green bar stays red / green
105
+ * while live, instead of swapping to the single `highlight` colour and losing
106
+ * its meaning. (Both paths now pop to 1; what still differs is the *colour* —
107
+ * the flat path swaps to `highlight`, this one keeps `binFills[i]`.)
102
108
  *
103
109
  * **M4 column decimation ([PND-MARKDEC]):** once the *visible* bars are denser
104
110
  * than ~2 per device pixel, they overplot into a solid silhouette, so
@@ -129,19 +135,68 @@ export declare function drawBars(ctx: CanvasRenderingContext2D, cs: BarSeries, x
129
135
  * cheap and allocation-free).
130
136
  */
131
137
  export declare function barIndexAtTime(cs: BarSeries, time: number): number;
138
+ /**
139
+ * The pixel rect of bar `i`'s **slot** — the region that *belongs* to the bar,
140
+ * as opposed to the ink {@link barRect} puts on the canvas. It spans the key's
141
+ * full `[begin, end]` in x (**no `gapPx` inset**) and the **whole plot height**
142
+ * in y. `null` for a gap (non-finite value), which owns no slot to select.
143
+ *
144
+ * The distinction is the point: a bar *is* the full width of its interval, and
145
+ * the drawing gap is a display affordance so adjacent columns read as discrete.
146
+ * Hit-testing the drawn rect made that affordance interactive — the gap became
147
+ * a dead channel you could point at and select nothing, and the empty plot
148
+ * space above a short bar likewise. Slots tile the axis, so every x inside the
149
+ * data range belongs to exactly one bar, which is what a column chart's hover
150
+ * should feel like and what {@link barIndexAtTime} (the x-scrub cursor) has
151
+ * always done.
152
+ *
153
+ * The plot's y extent is read from the `yScale`'s own domain, the same
154
+ * localized shape {@link resolveBarBaseline} uses. When it isn't readable (a
155
+ * bare test stub with no `.domain()`), this falls back to {@link barRect}'s
156
+ * value→baseline span, so a scale-less caller keeps the old behaviour rather
157
+ * than getting an unbounded hit region.
158
+ *
159
+ * `minWidthPx` still floors the span, so a lone point-keyed bar (zero-width
160
+ * key) stays selectable.
161
+ *
162
+ * **Two consequences worth knowing before you compose with it.**
163
+ *
164
+ * 1. **It reaches across the whole plot height, so it can shadow layers below
165
+ * it.** `resolveSelection` returns the topmost hit, so a `<BarChart>`
166
+ * declared *after* a `<ScatterChart>` / `<BoxPlot>` / another `<BarChart>`
167
+ * in the same row now claims every hit inside its x-range, at any y — where
168
+ * the drawn-rect target only claimed the bar's own ink. Declare a bar layer
169
+ * **below** the marks you want to stay clickable (which is also the usual
170
+ * z-order for bars-as-context). No shipped story composes that way, so this
171
+ * is latent rather than a live regression.
172
+ * 2. **Only the single-series vertical path uses it.** A stacked, `bins`,
173
+ * `categories` or horizontal `<BarChart>` hit-tests through
174
+ * {@link stackAt}, which still targets the drawn segment — a stack has to,
175
+ * since segments share a bin's x-range and only y tells them apart. So
176
+ * `<BarChart>` has two hit models; this is the one for a plain bar.
177
+ */
178
+ export declare function barSlotRect(cs: BarSeries, i: number, xScale: Scale, yScale: Scale, baseline: number, minWidthPx: number): [x0: number, x1: number, yTop: number, yBottom: number] | null;
132
179
  /**
133
180
  * Hit-test plot-pixel `(px, py)` against `cs`'s bars — the **first** bar whose
134
- * rect contains the point, or `null`. The geometry is {@link barRect}, so the
135
- * hit rect is exactly the drawn rect (same `baseline`/`gapPx`/`minWidth`). The
136
- * returned tuple is `[index, begin, value]` for the chart to assemble a
137
- * `SelectInfo` (it owns the colour + label); keeping this layer free of the
138
- * theme keeps it unit-testable without a `ChartTheme`.
181
+ * **slot** contains the point, or `null`. The geometry is {@link barSlotRect}:
182
+ * the bar's full interval width and the full plot height, *not* the drawn rect.
183
+ * Pointing at the gap between two columns, or above a short one, selects the
184
+ * bar whose slot you are in. The returned tuple is `[index, begin, value]` for
185
+ * the chart to assemble a `SelectInfo` (it owns the colour + label); keeping
186
+ * this layer free of the theme keeps it unit-testable without a `ChartTheme`.
187
+ *
188
+ * **Shared edges.** Contiguous bars meet exactly (`end[i] === begin[i+1]`) once
189
+ * the gap is gone, and both ends are inclusive, so a point landing precisely on
190
+ * the boundary matches **the left bar** — first match wins, the same rule
191
+ * {@link barIndexAtTime} documents, so hover and the x-scrub cursor agree.
192
+ *
193
+ * A **gap** bar (non-finite value) owns no slot and is skipped, so hovering
194
+ * where the data is missing selects nothing rather than a `NaN`.
139
195
  *
140
196
  * O(N) over the events (no spatial index — bar counts are view-scale, hundreds
141
- * not millions; click is a rare event). Bars don't overlap in x for a sorted
142
- * series, so "first match" is unambiguous in practice.
197
+ * not millions; click is a rare event).
143
198
  */
144
- export declare function barAt(cs: BarSeries, px: number, py: number, xScale: Scale, yScale: Scale, baseline: number, gapPx: number, minWidthPx: number): [index: number, begin: number, value: number] | null;
199
+ export declare function barAt(cs: BarSeries, px: number, py: number, xScale: Scale, yScale: Scale, baseline: number, minWidthPx: number): [index: number, begin: number, value: number] | null;
145
200
  /**
146
201
  * A resolved per-group stack style: `fills` aligned index-for-index to
147
202
  * {@link StackedBarSeries.groups} (segment `g` uses `fills[g]`), plus the shared
@@ -196,6 +251,22 @@ export declare function stackValueExtent(ss: StackedBarSeries): [number, number]
196
251
  * the x auto-fit for a vertical histogram, the y auto-fit for a horizontal one.
197
252
  */
198
253
  export declare function stackBinExtent(ss: StackedBarSeries): [number, number] | null;
254
+ /**
255
+ * The value a stack's **first** segment rests on, in data units — the same
256
+ * `0`-clamped-into-the-domain rule {@link resolveBarBaseline} applies to a plain
257
+ * bar, read off whichever scale carries the stacked value (`yScale` when the
258
+ * bars grow up, `xScale` when they grow right).
259
+ *
260
+ * Both stack walks used to start at a literal `0`, which is right only while the
261
+ * domain contains zero — and a **log** domain never can. `yScale(0)` on a log
262
+ * scale is `NaN`, `fillRect` with a `NaN` argument is a silent canvas no-op, and
263
+ * the same rect feeds {@link stackAt} — so the bottom segment of every stack
264
+ * both vanished *and* became unhittable, with nothing to see but a stack that
265
+ * starts one segment up. The linear case is unaffected: the value extents pull
266
+ * `0` into the domain, so this returns exactly `0` and the geometry is
267
+ * unchanged.
268
+ */
269
+ export declare function stackBase(orientation: Orientation, xScale: Scale, yScale: Scale): number;
199
270
  /**
200
271
  * The pixel rect `[x0, x1, yTop, yBottom]` (ascending on both axes) of bin `b`'s
201
272
  * segment `g`, stacked so it sits atop `cumBefore` (the summed value of the