@pond-ts/charts 0.53.1 → 0.55.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1175 -4
- package/dist/AreaChart.d.ts +30 -15
- package/dist/AreaChart.js +46 -5
- package/dist/BandChart.d.ts +29 -17
- package/dist/BarChart.d.ts +109 -57
- package/dist/BarChart.js +82 -12
- 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/Layers.js +8 -1
- package/dist/LineChart.d.ts +30 -16
- package/dist/LineChart.js +46 -5
- package/dist/ListTable.d.ts +51 -0
- package/dist/ListTable.js +143 -0
- package/dist/ScatterChart.d.ts +27 -17
- package/dist/YAxis.js +15 -4
- package/dist/affine.d.ts +35 -14
- package/dist/affine.js +34 -17
- package/dist/area.js +4 -4
- package/dist/bars.d.ts +107 -25
- package/dist/bars.js +204 -39
- package/dist/column-names.d.ts +74 -0
- package/dist/column-names.js +2 -0
- package/dist/context.d.ts +50 -7
- package/dist/data.d.ts +77 -0
- package/dist/data.js +131 -22
- package/dist/decimate.js +7 -7
- package/dist/index.d.ts +19 -13
- package/dist/index.js +21 -13
- package/dist/line.js +2 -2
- 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 +30 -0
- package/dist/theme.js +24 -0
- package/package.json +3 -3
package/dist/bars.d.ts
CHANGED
|
@@ -50,26 +50,62 @@ export declare function resolveBarBaseline(yScale: Scale): number;
|
|
|
50
50
|
* from {@link barSpanPx} (the key's `[begin, end]`, inset by `gapPx`, floored at
|
|
51
51
|
* `minWidthPx`); the y-span runs between the value and the `baseline` pixel,
|
|
52
52
|
* normalized so a value above *or* below the baseline both yield an ascending
|
|
53
|
-
* rect.
|
|
54
|
-
*
|
|
53
|
+
* rect. This is the **ink** — what {@link drawBars} paints. Hit-testing uses
|
|
54
|
+
* {@link barSlotRect} instead (the bar's whole slot), so the drawn rect and the
|
|
55
|
+
* hit region are deliberately *not* the same geometry: the `gapPx` inset
|
|
56
|
+
* separates columns visually without carving a dead channel out of the target.
|
|
55
57
|
*/
|
|
56
58
|
export declare function barRect(cs: BarSeries, i: number, xScale: Scale, yScale: Scale, baseline: number, gapPx: number, minWidthPx: number): [x0: number, x1: number, yTop: number, yBottom: number] | null;
|
|
59
|
+
/**
|
|
60
|
+
* The narrowed selection / hover identity a **single-series** bar matches
|
|
61
|
+
* against: the layer's series `id`, the sample's `key` (its `begin`), and — when
|
|
62
|
+
* the series carries {@link BarSeries.marks} — the stable per-bar `mark`. The
|
|
63
|
+
* single-series sibling of {@link StackMark}, which additionally carries the
|
|
64
|
+
* stack's group `label` (a single-series bar has no group to disambiguate).
|
|
65
|
+
*/
|
|
66
|
+
export interface BarMark {
|
|
67
|
+
readonly id: string;
|
|
68
|
+
readonly key: number;
|
|
69
|
+
readonly mark?: string;
|
|
70
|
+
}
|
|
57
71
|
/**
|
|
58
72
|
* Fill one rectangle per bar in `cs`, each spanning its key's `[begin, end]`
|
|
59
73
|
* (inset by `gapPx`) from the resolved `baseline` to the value.
|
|
60
74
|
*
|
|
61
75
|
* A gap (non-finite value) is skipped — no bar, no zero-height sliver. A bar
|
|
62
|
-
* matching the current `selection` (
|
|
63
|
-
*
|
|
64
|
-
* draws in the style's `highlight` colour **and
|
|
65
|
-
* on the canvas; a bar matching `hovered`
|
|
66
|
-
* outline (a lighter "this bar is live" on pointer-over)
|
|
67
|
-
*
|
|
68
|
-
*
|
|
76
|
+
* matching the current `selection` (the layer's own series `id` — `seriesId`; a
|
|
77
|
+
* no-id layer passes `undefined` and never matches — plus the bar's identity,
|
|
78
|
+
* see {@link barMatches}) draws in the style's `highlight` colour **and
|
|
79
|
+
* outlined**, so a click reads back on the canvas; a bar matching `hovered`
|
|
80
|
+
* draws **without** the outline (a lighter "this bar is live" on pointer-over)
|
|
81
|
+
* in the style's optional `hover` colour, or in `highlight` when the theme
|
|
82
|
+
* doesn't set one; all others use the flat `fill`. Either live state fills at
|
|
83
|
+
* **full opacity** — the resting `opacity` applies to resting bars only, and is
|
|
84
|
+
* restored so it doesn't leak into later layers. A bar that is both selected
|
|
85
|
+
* and hovered reads as **selected**.
|
|
86
|
+
*
|
|
87
|
+
* **Which identity.** A selection carrying a `mark` matches against the series'
|
|
88
|
+
* stable per-bar name ({@link BarSeries.marks} — the sample's own axis key,
|
|
89
|
+
* which the readers always supply); one without falls back to the sample `key`
|
|
90
|
+
* (the bar's `begin`). The mark path is what lets a caller pin a bar on a
|
|
91
|
+
* **point-keyed** series without re-deriving the neighbour-spaced span, since
|
|
92
|
+
* there `begin` is not the sample's key but an edge computed from it.
|
|
69
93
|
*
|
|
70
94
|
* O(N) over the events, one fill (+ optional stroke) per bar, no per-bar
|
|
71
95
|
* allocation beyond the rect tuple.
|
|
72
96
|
*
|
|
97
|
+
* **Per-bar fills (`binFills`):** an optional colour array aligned
|
|
98
|
+
* index-for-index to the source bars — bar `i` fills with `binFills[i]`
|
|
99
|
+
* (an `undefined` entry falls back to the flat `fill`). This is the
|
|
100
|
+
* direction-coloured financial volume row (rising / falling) and the
|
|
101
|
+
* value-band case on a time axis. Highlight follows {@link drawStacks}'s
|
|
102
|
+
* binFills convention: the bar **keeps its own colour** under hover /
|
|
103
|
+
* selection — the highlight pops `globalAlpha` to 1 (and outlines the
|
|
104
|
+
* selection in the bar's own fill) — so a red / green bar stays red / green
|
|
105
|
+
* while live, instead of swapping to the single `highlight` colour and losing
|
|
106
|
+
* its meaning. (Both paths now pop to 1; what still differs is the *colour* —
|
|
107
|
+
* the flat path swaps to `highlight`, this one keeps `binFills[i]`.)
|
|
108
|
+
*
|
|
73
109
|
* **M4 column decimation ([PND-MARKDEC]):** once the *visible* bars are denser
|
|
74
110
|
* than ~2 per device pixel, they overplot into a solid silhouette, so
|
|
75
111
|
* `decimate !== false` replaces them with one **envelope rect per pixel column**
|
|
@@ -79,15 +115,12 @@ export declare function barRect(cs: BarSeries, i: number, xScale: Scale, yScale:
|
|
|
79
115
|
* columns aren't individually selectable, so per-bar selection/hover highlight is
|
|
80
116
|
* suppressed (a <1px bar's ring wouldn't be visible anyway); interaction still
|
|
81
117
|
* reads the **source** bars via {@link barAt} (§2.3). Pass `decimate={false}` to
|
|
82
|
-
* always draw every bar.
|
|
118
|
+
* always draw every bar. **`binFills` disables the envelope pass** — a single
|
|
119
|
+
* envelope rect spans many differently-coloured bars, so decimating would
|
|
120
|
+
* repaint them one flat colour; per-bar-coloured layers draw every visible bar.
|
|
121
|
+
* Returns {@link LayerDrawStats} for `onDrawStats`.
|
|
83
122
|
*/
|
|
84
|
-
export declare function drawBars(ctx: CanvasRenderingContext2D, cs: BarSeries, xScale: Scale, yScale: Scale, style: BarStyle, baseline: number, gapPx: number, seriesId: string | undefined, selection:
|
|
85
|
-
key: number;
|
|
86
|
-
id: string;
|
|
87
|
-
} | null, hovered: {
|
|
88
|
-
key: number;
|
|
89
|
-
id: string;
|
|
90
|
-
} | null, decimate?: DecimateOption): LayerDrawStats;
|
|
123
|
+
export declare function drawBars(ctx: CanvasRenderingContext2D, cs: BarSeries, xScale: Scale, yScale: Scale, style: BarStyle, baseline: number, gapPx: number, seriesId: string | undefined, selection: BarMark | null, hovered: BarMark | null, decimate?: DecimateOption, binFills?: readonly (string | undefined)[]): LayerDrawStats;
|
|
91
124
|
/**
|
|
92
125
|
* The index of the bar whose key span `[begin, end]` contains `time` — the bar
|
|
93
126
|
* **under the cursor** — or `-1` if `time` falls in no bar's span. This is the
|
|
@@ -102,19 +135,68 @@ export declare function drawBars(ctx: CanvasRenderingContext2D, cs: BarSeries, x
|
|
|
102
135
|
* cheap and allocation-free).
|
|
103
136
|
*/
|
|
104
137
|
export declare function barIndexAtTime(cs: BarSeries, time: number): number;
|
|
138
|
+
/**
|
|
139
|
+
* The pixel rect of bar `i`'s **slot** — the region that *belongs* to the bar,
|
|
140
|
+
* as opposed to the ink {@link barRect} puts on the canvas. It spans the key's
|
|
141
|
+
* full `[begin, end]` in x (**no `gapPx` inset**) and the **whole plot height**
|
|
142
|
+
* in y. `null` for a gap (non-finite value), which owns no slot to select.
|
|
143
|
+
*
|
|
144
|
+
* The distinction is the point: a bar *is* the full width of its interval, and
|
|
145
|
+
* the drawing gap is a display affordance so adjacent columns read as discrete.
|
|
146
|
+
* Hit-testing the drawn rect made that affordance interactive — the gap became
|
|
147
|
+
* a dead channel you could point at and select nothing, and the empty plot
|
|
148
|
+
* space above a short bar likewise. Slots tile the axis, so every x inside the
|
|
149
|
+
* data range belongs to exactly one bar, which is what a column chart's hover
|
|
150
|
+
* should feel like and what {@link barIndexAtTime} (the x-scrub cursor) has
|
|
151
|
+
* always done.
|
|
152
|
+
*
|
|
153
|
+
* The plot's y extent is read from the `yScale`'s own domain, the same
|
|
154
|
+
* localized shape {@link resolveBarBaseline} uses. When it isn't readable (a
|
|
155
|
+
* bare test stub with no `.domain()`), this falls back to {@link barRect}'s
|
|
156
|
+
* value→baseline span, so a scale-less caller keeps the old behaviour rather
|
|
157
|
+
* than getting an unbounded hit region.
|
|
158
|
+
*
|
|
159
|
+
* `minWidthPx` still floors the span, so a lone point-keyed bar (zero-width
|
|
160
|
+
* key) stays selectable.
|
|
161
|
+
*
|
|
162
|
+
* **Two consequences worth knowing before you compose with it.**
|
|
163
|
+
*
|
|
164
|
+
* 1. **It reaches across the whole plot height, so it can shadow layers below
|
|
165
|
+
* it.** `resolveSelection` returns the topmost hit, so a `<BarChart>`
|
|
166
|
+
* declared *after* a `<ScatterChart>` / `<BoxPlot>` / another `<BarChart>`
|
|
167
|
+
* in the same row now claims every hit inside its x-range, at any y — where
|
|
168
|
+
* the drawn-rect target only claimed the bar's own ink. Declare a bar layer
|
|
169
|
+
* **below** the marks you want to stay clickable (which is also the usual
|
|
170
|
+
* z-order for bars-as-context). No shipped story composes that way, so this
|
|
171
|
+
* is latent rather than a live regression.
|
|
172
|
+
* 2. **Only the single-series vertical path uses it.** A stacked, `bins`,
|
|
173
|
+
* `categories` or horizontal `<BarChart>` hit-tests through
|
|
174
|
+
* {@link stackAt}, which still targets the drawn segment — a stack has to,
|
|
175
|
+
* since segments share a bin's x-range and only y tells them apart. So
|
|
176
|
+
* `<BarChart>` has two hit models; this is the one for a plain bar.
|
|
177
|
+
*/
|
|
178
|
+
export declare function barSlotRect(cs: BarSeries, i: number, xScale: Scale, yScale: Scale, baseline: number, minWidthPx: number): [x0: number, x1: number, yTop: number, yBottom: number] | null;
|
|
105
179
|
/**
|
|
106
180
|
* Hit-test plot-pixel `(px, py)` against `cs`'s bars — the **first** bar whose
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
181
|
+
* **slot** contains the point, or `null`. The geometry is {@link barSlotRect}:
|
|
182
|
+
* the bar's full interval width and the full plot height, *not* the drawn rect.
|
|
183
|
+
* Pointing at the gap between two columns, or above a short one, selects the
|
|
184
|
+
* bar whose slot you are in. The returned tuple is `[index, begin, value]` for
|
|
185
|
+
* the chart to assemble a `SelectInfo` (it owns the colour + label); keeping
|
|
186
|
+
* this layer free of the theme keeps it unit-testable without a `ChartTheme`.
|
|
187
|
+
*
|
|
188
|
+
* **Shared edges.** Contiguous bars meet exactly (`end[i] === begin[i+1]`) once
|
|
189
|
+
* the gap is gone, and both ends are inclusive, so a point landing precisely on
|
|
190
|
+
* the boundary matches **the left bar** — first match wins, the same rule
|
|
191
|
+
* {@link barIndexAtTime} documents, so hover and the x-scrub cursor agree.
|
|
192
|
+
*
|
|
193
|
+
* A **gap** bar (non-finite value) owns no slot and is skipped, so hovering
|
|
194
|
+
* where the data is missing selects nothing rather than a `NaN`.
|
|
112
195
|
*
|
|
113
196
|
* O(N) over the events (no spatial index — bar counts are view-scale, hundreds
|
|
114
|
-
* not millions; click is a rare event).
|
|
115
|
-
* series, so "first match" is unambiguous in practice.
|
|
197
|
+
* not millions; click is a rare event).
|
|
116
198
|
*/
|
|
117
|
-
export declare function barAt(cs: BarSeries, px: number, py: number, xScale: Scale, yScale: Scale, baseline: number,
|
|
199
|
+
export declare function barAt(cs: BarSeries, px: number, py: number, xScale: Scale, yScale: Scale, baseline: number, minWidthPx: number): [index: number, begin: number, value: number] | null;
|
|
118
200
|
/**
|
|
119
201
|
* A resolved per-group stack style: `fills` aligned index-for-index to
|
|
120
202
|
* {@link StackedBarSeries.groups} (segment `g` uses `fills[g]`), plus the shared
|
package/dist/bars.js
CHANGED
|
@@ -67,8 +67,10 @@ export function resolveBarBaseline(yScale) {
|
|
|
67
67
|
* from {@link barSpanPx} (the key's `[begin, end]`, inset by `gapPx`, floored at
|
|
68
68
|
* `minWidthPx`); the y-span runs between the value and the `baseline` pixel,
|
|
69
69
|
* normalized so a value above *or* below the baseline both yield an ascending
|
|
70
|
-
* rect.
|
|
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];
|
|
@@ -79,22 +81,70 @@ export function barRect(cs, i, xScale, yScale, baseline, gapPx, minWidthPx) {
|
|
|
79
81
|
const yBase = yScale(baseline);
|
|
80
82
|
return [x0, x1, Math.min(yValue, yBase), Math.max(yValue, yBase)];
|
|
81
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Does `m` identify the bar with stable identity `stable` and key `begin`?
|
|
86
|
+
* The mark decides **only when both sides have one** — `m.mark` (the selection
|
|
87
|
+
* names a bar) and `stable` (this series names its bars). Either missing falls
|
|
88
|
+
* back to `m.key === begin`, so both of these keep working unchanged:
|
|
89
|
+
*
|
|
90
|
+
* - a selection with **no `mark`** — every controlled `selected={{ id, key }}`
|
|
91
|
+
* that predates this channel, against a series that now carries marks;
|
|
92
|
+
* - a series with **no `marks`** — a hand-built {@link BarSeries} (tests, an
|
|
93
|
+
* outside caller assembling the view themselves).
|
|
94
|
+
*
|
|
95
|
+
* This is the `mark`-first rule {@link drawStacks} applies, with one deliberate
|
|
96
|
+
* difference: it falls back on the **selection** carrying no mark, where
|
|
97
|
+
* `drawStacks` falls back on the **series** carrying none. `drawStacks` can
|
|
98
|
+
* switch on the series alone because only `categoryStack` produces marks and it
|
|
99
|
+
* never had key-pinned consumers. Every reader-built bar series now carries
|
|
100
|
+
* marks, so that unconditional switch would silently stop matching each shipped
|
|
101
|
+
* key-pinned selection — key-pinning is the only selection bars ever had.
|
|
102
|
+
*/
|
|
103
|
+
function barMatches(m, seriesId, stable, begin) {
|
|
104
|
+
if (m === null || m.id !== seriesId)
|
|
105
|
+
return false;
|
|
106
|
+
return m.mark !== undefined && stable !== undefined
|
|
107
|
+
? m.mark === stable
|
|
108
|
+
: m.key === begin;
|
|
109
|
+
}
|
|
82
110
|
/**
|
|
83
111
|
* Fill one rectangle per bar in `cs`, each spanning its key's `[begin, end]`
|
|
84
112
|
* (inset by `gapPx`) from the resolved `baseline` to the value.
|
|
85
113
|
*
|
|
86
114
|
* A gap (non-finite value) is skipped — no bar, no zero-height sliver. A bar
|
|
87
|
-
* matching the current `selection` (
|
|
88
|
-
*
|
|
89
|
-
* draws in the style's `highlight` colour **and
|
|
90
|
-
* on the canvas; a bar matching `hovered`
|
|
91
|
-
* outline (a lighter "this bar is live" on pointer-over)
|
|
92
|
-
*
|
|
93
|
-
*
|
|
115
|
+
* matching the current `selection` (the layer's own series `id` — `seriesId`; a
|
|
116
|
+
* no-id layer passes `undefined` and never matches — plus the bar's identity,
|
|
117
|
+
* see {@link barMatches}) draws in the style's `highlight` colour **and
|
|
118
|
+
* outlined**, so a click reads back on the canvas; a bar matching `hovered`
|
|
119
|
+
* draws **without** the outline (a lighter "this bar is live" on pointer-over)
|
|
120
|
+
* in the style's optional `hover` colour, or in `highlight` when the theme
|
|
121
|
+
* doesn't set one; all others use the flat `fill`. Either live state fills at
|
|
122
|
+
* **full opacity** — the resting `opacity` applies to resting bars only, and is
|
|
123
|
+
* restored so it doesn't leak into later layers. A bar that is both selected
|
|
124
|
+
* and hovered reads as **selected**.
|
|
125
|
+
*
|
|
126
|
+
* **Which identity.** A selection carrying a `mark` matches against the series'
|
|
127
|
+
* stable per-bar name ({@link BarSeries.marks} — the sample's own axis key,
|
|
128
|
+
* which the readers always supply); one without falls back to the sample `key`
|
|
129
|
+
* (the bar's `begin`). The mark path is what lets a caller pin a bar on a
|
|
130
|
+
* **point-keyed** series without re-deriving the neighbour-spaced span, since
|
|
131
|
+
* there `begin` is not the sample's key but an edge computed from it.
|
|
94
132
|
*
|
|
95
133
|
* O(N) over the events, one fill (+ optional stroke) per bar, no per-bar
|
|
96
134
|
* allocation beyond the rect tuple.
|
|
97
135
|
*
|
|
136
|
+
* **Per-bar fills (`binFills`):** an optional colour array aligned
|
|
137
|
+
* index-for-index to the source bars — bar `i` fills with `binFills[i]`
|
|
138
|
+
* (an `undefined` entry falls back to the flat `fill`). This is the
|
|
139
|
+
* direction-coloured financial volume row (rising / falling) and the
|
|
140
|
+
* value-band case on a time axis. Highlight follows {@link drawStacks}'s
|
|
141
|
+
* binFills convention: the bar **keeps its own colour** under hover /
|
|
142
|
+
* selection — the highlight pops `globalAlpha` to 1 (and outlines the
|
|
143
|
+
* selection in the bar's own fill) — so a red / green bar stays red / green
|
|
144
|
+
* while live, instead of swapping to the single `highlight` colour and losing
|
|
145
|
+
* its meaning. (Both paths now pop to 1; what still differs is the *colour* —
|
|
146
|
+
* the flat path swaps to `highlight`, this one keeps `binFills[i]`.)
|
|
147
|
+
*
|
|
98
148
|
* **M4 column decimation ([PND-MARKDEC]):** once the *visible* bars are denser
|
|
99
149
|
* than ~2 per device pixel, they overplot into a solid silhouette, so
|
|
100
150
|
* `decimate !== false` replaces them with one **envelope rect per pixel column**
|
|
@@ -104,9 +154,12 @@ export function barRect(cs, i, xScale, yScale, baseline, gapPx, minWidthPx) {
|
|
|
104
154
|
* columns aren't individually selectable, so per-bar selection/hover highlight is
|
|
105
155
|
* suppressed (a <1px bar's ring wouldn't be visible anyway); interaction still
|
|
106
156
|
* reads the **source** bars via {@link barAt} (§2.3). Pass `decimate={false}` to
|
|
107
|
-
* always draw every bar.
|
|
157
|
+
* always draw every bar. **`binFills` disables the envelope pass** — a single
|
|
158
|
+
* envelope rect spans many differently-coloured bars, so decimating would
|
|
159
|
+
* repaint them one flat colour; per-bar-coloured layers draw every visible bar.
|
|
160
|
+
* Returns {@link LayerDrawStats} for `onDrawStats`.
|
|
108
161
|
*/
|
|
109
|
-
export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, seriesId, selection, hovered, decimate = true) {
|
|
162
|
+
export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, seriesId, selection, hovered, decimate = true, binFills) {
|
|
110
163
|
ctx.save();
|
|
111
164
|
ctx.globalAlpha = style.opacity;
|
|
112
165
|
const sourceCount = cs.length; // pre-cull, pre-decimation (for draw stats)
|
|
@@ -119,9 +172,13 @@ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, series
|
|
|
119
172
|
// Decimate the visible bars to per-column envelope rects once dense (see the
|
|
120
173
|
// header). `null` below the visible-density threshold ⇒ the full per-bar loop.
|
|
121
174
|
// `{ threshold }` tunes the samples-per-pixel factor `k` (as line/area/band do);
|
|
122
|
-
// `undefined` ⇒ decimateBars' default (2).
|
|
175
|
+
// `undefined` ⇒ decimateBars' default (2). Per-bar fills skip the envelope —
|
|
176
|
+
// one flat rect can't carry many bars' colours (see the header) — but an
|
|
177
|
+
// *empty* colour array is "no colours" (every bar would flat-fill anyway), so
|
|
178
|
+
// it stays on the legacy path end-to-end (L2 review, PR #542).
|
|
179
|
+
const fills = binFills !== undefined && binFills.length > 0 ? binFills : undefined;
|
|
123
180
|
const k = typeof decimate === 'object' ? decimate.threshold : undefined;
|
|
124
|
-
const envelope = decimate !== false
|
|
181
|
+
const envelope = decimate !== false && fills === undefined
|
|
125
182
|
? decimateBars(cs, xScale, ctx, baseline, k, vEnd - vStart)
|
|
126
183
|
: null;
|
|
127
184
|
if (envelope !== null) {
|
|
@@ -141,35 +198,74 @@ export function drawBars(ctx, cs, xScale, yScale, style, baseline, gapPx, series
|
|
|
141
198
|
ctx.restore();
|
|
142
199
|
return { sourceCount, drawnCount: drawn, decimated: true };
|
|
143
200
|
}
|
|
201
|
+
// The stable per-bar identity is consulted only when the live selection /
|
|
202
|
+
// hover actually carries a `mark` — `cs.marks` builds its strings lazily, so
|
|
203
|
+
// the *draw* never materializes them on its own. (The component's `hitTest`
|
|
204
|
+
// may already have: an interactive layer echoes the hovered bar's mark on
|
|
205
|
+
// every pointer move. See BarSeries.marks — this hoist keeps the draw path
|
|
206
|
+
// clean, it doesn't make the channel free.)
|
|
207
|
+
const marks = selection?.mark !== undefined || hovered?.mark !== undefined
|
|
208
|
+
? cs.marks
|
|
209
|
+
: undefined;
|
|
144
210
|
let drawn = 0;
|
|
145
211
|
for (let i = vStart; i < vEnd; i += 1) {
|
|
146
212
|
const rect = barRect(cs, i, xScale, yScale, baseline, gapPx, style.minWidth);
|
|
147
213
|
if (rect === null)
|
|
148
214
|
continue;
|
|
149
215
|
const [x0, x1, yTop, yBottom] = rect;
|
|
150
|
-
// Match by the series `id` **and** the
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
// and
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const isHovered = hovered
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
216
|
+
// Match by the series `id` **and** the bar's identity — its stable `mark`
|
|
217
|
+
// when the selection carries one, else the sample `key` (begin) — so two
|
|
218
|
+
// series sharing a timestamp don't both light up (a no-id, non-selectable
|
|
219
|
+
// layer passes `seriesId === undefined` and never matches). The selection
|
|
220
|
+
// takes `highlight` + the outline; the hover takes `hover` when the theme
|
|
221
|
+
// sets one and `highlight` otherwise, always without the outline — so hover
|
|
222
|
+
// reads as a lighter "this bar is live" and select as the committed pick.
|
|
223
|
+
const stable = marks?.[i];
|
|
224
|
+
const selected = barMatches(selection, seriesId, stable, cs.begin[i]);
|
|
225
|
+
const isHovered = barMatches(hovered, seriesId, stable, cs.begin[i]);
|
|
226
|
+
if (fills !== undefined) {
|
|
227
|
+
// Per-bar fills: the bar keeps its own colour under hover / selection —
|
|
228
|
+
// highlight pops the alpha to 1 and outlines the selection in the bar's
|
|
229
|
+
// own fill (the drawStacks binFills convention; see the header).
|
|
230
|
+
const fill = fills[i] ?? style.fill;
|
|
231
|
+
ctx.globalAlpha = selected || isHovered ? 1 : style.opacity;
|
|
232
|
+
ctx.fillStyle = fill;
|
|
233
|
+
ctx.fillRect(x0, yTop, x1 - x0, yBottom - yTop);
|
|
234
|
+
drawn += 1;
|
|
235
|
+
if (selected) {
|
|
236
|
+
ctx.lineWidth = style.outlineWidth;
|
|
237
|
+
ctx.strokeStyle = fill;
|
|
238
|
+
ctx.strokeRect(x0, yTop, x1 - x0, yBottom - yTop);
|
|
239
|
+
}
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
// A hovered / selected bar pops to full opacity, as the binFills branch
|
|
243
|
+
// above and `drawStacks` both do — without this the highlight *fill* drew
|
|
244
|
+
// at the resting `style.opacity`, so on an alpha'd theme a hovered bar
|
|
245
|
+
// (which has no outline) barely changed at all, and a selected one read
|
|
246
|
+
// only by its outline (#576).
|
|
247
|
+
ctx.globalAlpha = selected || isHovered ? 1 : style.opacity;
|
|
248
|
+
// Three-step emphasis when the theme opts in with `hover`: rest → hover →
|
|
249
|
+
// selected. Selection outranks hover on a bar that is both (as the outline
|
|
250
|
+
// already did). With no `hover` colour this is the shipped two-step —
|
|
251
|
+
// `highlight` for either state (see BarStyle.hover).
|
|
252
|
+
ctx.fillStyle = selected
|
|
253
|
+
? style.highlight
|
|
254
|
+
: isHovered
|
|
255
|
+
? (style.hover ?? style.highlight)
|
|
256
|
+
: style.fill;
|
|
163
257
|
ctx.fillRect(x0, yTop, x1 - x0, yBottom - yTop);
|
|
164
258
|
drawn += 1;
|
|
165
259
|
if (selected) {
|
|
166
|
-
// The selected bar
|
|
167
|
-
//
|
|
168
|
-
|
|
260
|
+
// The selected bar's outline. Already at alpha 1 from the fill above —
|
|
261
|
+
// which also means it no longer separates select from hover the way it
|
|
262
|
+
// used to: the stroke is `highlight` over a now-`highlight`, now-alpha-1
|
|
263
|
+
// fill, so only the half-stroke falling outside the rect reads. A theme
|
|
264
|
+
// that needs the two states clearly apart sets `BarStyle.hover` (#577);
|
|
265
|
+
// the outline is the shape cue, not the whole signal.
|
|
169
266
|
ctx.lineWidth = style.outlineWidth;
|
|
170
267
|
ctx.strokeStyle = style.highlight;
|
|
171
268
|
ctx.strokeRect(x0, yTop, x1 - x0, yBottom - yTop);
|
|
172
|
-
ctx.globalAlpha = style.opacity;
|
|
173
269
|
}
|
|
174
270
|
}
|
|
175
271
|
ctx.restore();
|
|
@@ -195,21 +291,90 @@ export function barIndexAtTime(cs, time) {
|
|
|
195
291
|
}
|
|
196
292
|
return -1;
|
|
197
293
|
}
|
|
294
|
+
/**
|
|
295
|
+
* The pixel rect of bar `i`'s **slot** — the region that *belongs* to the bar,
|
|
296
|
+
* as opposed to the ink {@link barRect} puts on the canvas. It spans the key's
|
|
297
|
+
* full `[begin, end]` in x (**no `gapPx` inset**) and the **whole plot height**
|
|
298
|
+
* in y. `null` for a gap (non-finite value), which owns no slot to select.
|
|
299
|
+
*
|
|
300
|
+
* The distinction is the point: a bar *is* the full width of its interval, and
|
|
301
|
+
* the drawing gap is a display affordance so adjacent columns read as discrete.
|
|
302
|
+
* Hit-testing the drawn rect made that affordance interactive — the gap became
|
|
303
|
+
* a dead channel you could point at and select nothing, and the empty plot
|
|
304
|
+
* space above a short bar likewise. Slots tile the axis, so every x inside the
|
|
305
|
+
* data range belongs to exactly one bar, which is what a column chart's hover
|
|
306
|
+
* should feel like and what {@link barIndexAtTime} (the x-scrub cursor) has
|
|
307
|
+
* always done.
|
|
308
|
+
*
|
|
309
|
+
* The plot's y extent is read from the `yScale`'s own domain, the same
|
|
310
|
+
* localized shape {@link resolveBarBaseline} uses. When it isn't readable (a
|
|
311
|
+
* bare test stub with no `.domain()`), this falls back to {@link barRect}'s
|
|
312
|
+
* value→baseline span, so a scale-less caller keeps the old behaviour rather
|
|
313
|
+
* than getting an unbounded hit region.
|
|
314
|
+
*
|
|
315
|
+
* `minWidthPx` still floors the span, so a lone point-keyed bar (zero-width
|
|
316
|
+
* key) stays selectable.
|
|
317
|
+
*
|
|
318
|
+
* **Two consequences worth knowing before you compose with it.**
|
|
319
|
+
*
|
|
320
|
+
* 1. **It reaches across the whole plot height, so it can shadow layers below
|
|
321
|
+
* it.** `resolveSelection` returns the topmost hit, so a `<BarChart>`
|
|
322
|
+
* declared *after* a `<ScatterChart>` / `<BoxPlot>` / another `<BarChart>`
|
|
323
|
+
* in the same row now claims every hit inside its x-range, at any y — where
|
|
324
|
+
* the drawn-rect target only claimed the bar's own ink. Declare a bar layer
|
|
325
|
+
* **below** the marks you want to stay clickable (which is also the usual
|
|
326
|
+
* z-order for bars-as-context). No shipped story composes that way, so this
|
|
327
|
+
* is latent rather than a live regression.
|
|
328
|
+
* 2. **Only the single-series vertical path uses it.** A stacked, `bins`,
|
|
329
|
+
* `categories` or horizontal `<BarChart>` hit-tests through
|
|
330
|
+
* {@link stackAt}, which still targets the drawn segment — a stack has to,
|
|
331
|
+
* since segments share a bin's x-range and only y tells them apart. So
|
|
332
|
+
* `<BarChart>` has two hit models; this is the one for a plain bar.
|
|
333
|
+
*/
|
|
334
|
+
export function barSlotRect(cs, i, xScale, yScale, baseline, minWidthPx) {
|
|
335
|
+
const v = cs.y[i];
|
|
336
|
+
if (!Number.isFinite(v))
|
|
337
|
+
return null;
|
|
338
|
+
// Gap-free: the slot is the key's own span.
|
|
339
|
+
const [x0, x1] = barSpanPx(cs.begin[i], cs.end[i], xScale, 0, minWidthPx);
|
|
340
|
+
const d = yScale.domain?.();
|
|
341
|
+
// `< 2`, not `=== 0`: a one-element domain would make both endpoints the same
|
|
342
|
+
// value, collapsing the slot to zero height and making the bar unhittable —
|
|
343
|
+
// worse than the fallback it was meant to skip. (`resolveBarBaseline`'s
|
|
344
|
+
// `=== 0` is fine because it clamps against min/max of the same endpoints.)
|
|
345
|
+
if (!d || d.length < 2) {
|
|
346
|
+
// No usable domain (a bare test stub): keep the drawn rect's y span.
|
|
347
|
+
const yValue = yScale(v);
|
|
348
|
+
const yBase = yScale(baseline);
|
|
349
|
+
return [x0, x1, Math.min(yValue, yBase), Math.max(yValue, yBase)];
|
|
350
|
+
}
|
|
351
|
+
const yA = yScale(d[0]);
|
|
352
|
+
const yB = yScale(d[d.length - 1]);
|
|
353
|
+
return [x0, x1, Math.min(yA, yB), Math.max(yA, yB)];
|
|
354
|
+
}
|
|
198
355
|
/**
|
|
199
356
|
* Hit-test plot-pixel `(px, py)` against `cs`'s bars — the **first** bar whose
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
357
|
+
* **slot** contains the point, or `null`. The geometry is {@link barSlotRect}:
|
|
358
|
+
* the bar's full interval width and the full plot height, *not* the drawn rect.
|
|
359
|
+
* Pointing at the gap between two columns, or above a short one, selects the
|
|
360
|
+
* bar whose slot you are in. The returned tuple is `[index, begin, value]` for
|
|
361
|
+
* the chart to assemble a `SelectInfo` (it owns the colour + label); keeping
|
|
362
|
+
* this layer free of the theme keeps it unit-testable without a `ChartTheme`.
|
|
363
|
+
*
|
|
364
|
+
* **Shared edges.** Contiguous bars meet exactly (`end[i] === begin[i+1]`) once
|
|
365
|
+
* the gap is gone, and both ends are inclusive, so a point landing precisely on
|
|
366
|
+
* the boundary matches **the left bar** — first match wins, the same rule
|
|
367
|
+
* {@link barIndexAtTime} documents, so hover and the x-scrub cursor agree.
|
|
368
|
+
*
|
|
369
|
+
* A **gap** bar (non-finite value) owns no slot and is skipped, so hovering
|
|
370
|
+
* where the data is missing selects nothing rather than a `NaN`.
|
|
205
371
|
*
|
|
206
372
|
* O(N) over the events (no spatial index — bar counts are view-scale, hundreds
|
|
207
|
-
* not millions; click is a rare event).
|
|
208
|
-
* series, so "first match" is unambiguous in practice.
|
|
373
|
+
* not millions; click is a rare event).
|
|
209
374
|
*/
|
|
210
|
-
export function barAt(cs, px, py, xScale, yScale, baseline,
|
|
375
|
+
export function barAt(cs, px, py, xScale, yScale, baseline, minWidthPx) {
|
|
211
376
|
for (let i = 0; i < cs.length; i += 1) {
|
|
212
|
-
const rect =
|
|
377
|
+
const rect = barSlotRect(cs, i, xScale, yScale, baseline, minWidthPx);
|
|
213
378
|
if (rect === null)
|
|
214
379
|
continue;
|
|
215
380
|
const [x0, x1, yTop, yBottom] = rect;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema-derived column-name types for the draw layers' column props —
|
|
3
|
+
* [PND-CHARTAPI]'s foundation.
|
|
4
|
+
*
|
|
5
|
+
* The 2026-08 API review's finding: the layers are generic over the schema,
|
|
6
|
+
* but their column props are bare `string`, so `<LineChart series={cpu}
|
|
7
|
+
* column="cpuu" />` compiles and fails at runtime — out of character for a
|
|
8
|
+
* library whose core exports `NumericColumnNameForSchema<S>`. These aliases
|
|
9
|
+
* close that gap in one place; **no prop position should reference core's
|
|
10
|
+
* helper directly.**
|
|
11
|
+
*
|
|
12
|
+
* ## Why the `never` guard is load-bearing
|
|
13
|
+
*
|
|
14
|
+
* `NumericColumnNameForSchema<SeriesSchema>` resolves to **`never`** — an
|
|
15
|
+
* unparameterized schema names no columns. Constraining a prop to it
|
|
16
|
+
* directly would therefore make `column` accept *nothing at all* for every
|
|
17
|
+
* consumer holding a loosely-typed series: a helper returning
|
|
18
|
+
* `TimeSeries<SeriesSchema>`, a React prop typed that way, or a component's
|
|
19
|
+
* own defaulted `S`. That is a large, silent breakage class — and one this
|
|
20
|
+
* repo's own suite cannot see, because its fixtures use `as const` schemas
|
|
21
|
+
* throughout (measured in `spikes/charts-type-seam/REPORT.md`, finding 5).
|
|
22
|
+
*
|
|
23
|
+
* So each alias falls back to `string` when the derived union is empty:
|
|
24
|
+
* a **narrow** schema gets precise names and a one-line error on a typo, a
|
|
25
|
+
* **loose** one keeps compiling exactly as before. The bracketed
|
|
26
|
+
* `[T] extends [never]` form is required — a bare `T extends never`
|
|
27
|
+
* distributes over unions and answers the wrong question.
|
|
28
|
+
*/
|
|
29
|
+
import type { NumericColumnNameForSchema, SeriesSchema, ValueColumnsForSchema, ValueSeriesSchema } from 'pond-ts';
|
|
30
|
+
/**
|
|
31
|
+
* A **numeric** value column of `S` — the constraint for every prop naming a
|
|
32
|
+
* column the layer reads as a number (a line's `column`, a band's
|
|
33
|
+
* `lower`/`upper`, a box's quantiles, an OHLC price).
|
|
34
|
+
*
|
|
35
|
+
* The test is **"is the schema loose?"**, not "did it yield no numeric
|
|
36
|
+
* columns" — those are different questions with different right answers, and
|
|
37
|
+
* conflating them was a real bug. A schema that names columns but has no
|
|
38
|
+
* numeric one (all-string) should reject *every* name, because there is
|
|
39
|
+
* genuinely nothing numeric to plot; only an **unparameterized** schema, whose
|
|
40
|
+
* column names are unbounded, should fall back to `string`. `string extends
|
|
41
|
+
* AnyColumn<S>` distinguishes them: it is true only when the name union is
|
|
42
|
+
* open.
|
|
43
|
+
*/
|
|
44
|
+
export type NumericColumn<S extends SeriesSchema> = string extends AnyColumn<S> ? string : NumericColumnNameForSchema<S>;
|
|
45
|
+
/**
|
|
46
|
+
* **Any** value column name of `S`, of any kind — the looseness probe for
|
|
47
|
+
* {@link NumericColumn}. On an unparameterized schema this is `string` (an
|
|
48
|
+
* open union), which is exactly what distinguishes "cannot check" from
|
|
49
|
+
* "checked, and nothing matches". Internal: no prop is typed with it, so it
|
|
50
|
+
* stays off the public surface until one is.
|
|
51
|
+
*/
|
|
52
|
+
type AnyColumn<S extends SeriesSchema> = ValueColumnsForSchema<S>[number]['name'];
|
|
53
|
+
/**
|
|
54
|
+
* The `ValueSeries` sibling of {@link NumericColumn} — a different schema type
|
|
55
|
+
* family, so it needs its own derivation; the loose-schema rule is identical.
|
|
56
|
+
*
|
|
57
|
+
* **Layers do not union the two.** A prop typed `NumericColumn<S> |
|
|
58
|
+
* ValueNumericColumn<VS>` is inert: only one of the two generics is ever
|
|
59
|
+
* inferred at a call site, so the other falls back to `string` and widens the
|
|
60
|
+
* union away. Each layer's props are instead a union **per series kind**, so
|
|
61
|
+
* the names check against the schema that was actually passed. Measured in
|
|
62
|
+
* `spikes/charts-type-seam/REPORT.md`.
|
|
63
|
+
*/
|
|
64
|
+
export type ValueNumericColumn<VS extends ValueSeriesSchema> = string extends VS[number]['name'] ? string : NumericColumnNameForValueSchema<VS>;
|
|
65
|
+
/**
|
|
66
|
+
* Names of `'number'`-kind value columns on a `ValueSeries` schema. Core
|
|
67
|
+
* exports the `TimeSeries` equivalent but not this one; derived locally
|
|
68
|
+
* rather than widening core's surface for a charts-only need.
|
|
69
|
+
*/
|
|
70
|
+
type NumericColumnNameForValueSchema<VS extends ValueSeriesSchema> = Extract<VS[number], {
|
|
71
|
+
readonly kind: 'number';
|
|
72
|
+
}>['name'];
|
|
73
|
+
export {};
|
|
74
|
+
//# sourceMappingURL=column-names.d.ts.map
|