@pond-ts/charts 0.56.2 → 0.57.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 +149 -1
- package/dist/BarChart.d.ts +84 -9
- package/dist/BarChart.js +113 -12
- package/dist/ChartContainer.d.ts +44 -1
- package/dist/ChartContainer.js +18 -2
- package/dist/YAxis.d.ts +28 -1
- package/dist/YAxis.js +24 -2
- package/dist/annotations.d.ts +74 -0
- package/dist/annotations.js +97 -7
- package/dist/bars.d.ts +123 -4
- package/dist/bars.js +280 -33
- package/dist/context.d.ts +12 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.js +3 -2
- package/dist/theme.d.ts +61 -6
- package/dist/theme.js +5 -0
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -8,7 +8,8 @@ The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`,
|
|
|
8
8
|
under a single `v*` tag, so this file covers them all. Pre-1.0: minor bumps may
|
|
9
9
|
include new features and type-level changes; patch bumps are strictly additive.
|
|
10
10
|
|
|
11
|
-
[Unreleased]: https://github.com/pond-ts/pond/compare/v0.
|
|
11
|
+
[Unreleased]: https://github.com/pond-ts/pond/compare/v0.57.0...HEAD
|
|
12
|
+
[0.57.0]: https://github.com/pond-ts/pond/compare/v0.56.2...v0.57.0
|
|
12
13
|
[0.56.2]: https://github.com/pond-ts/pond/compare/v0.56.1...v0.56.2
|
|
13
14
|
[0.56.1]: https://github.com/pond-ts/pond/compare/v0.56.0...v0.56.1
|
|
14
15
|
[0.56.0]: https://github.com/pond-ts/pond/compare/v0.55.0...v0.56.0
|
|
@@ -59,6 +60,153 @@ include new features and type-level changes; patch bumps are strictly additive.
|
|
|
59
60
|
|
|
60
61
|
## [Unreleased]
|
|
61
62
|
|
|
63
|
+
## [0.57.0] — 2026-08-07
|
|
64
|
+
|
|
65
|
+
### Added
|
|
66
|
+
|
|
67
|
+
- **charts: `<Zone>` — a shaded y-span annotation.** The fourth annotation
|
|
68
|
+
mark, and the value-axis counterpart of `<Region>`: a band between two y
|
|
69
|
+
values, spanning the full plot width. The mark for a **classification of the
|
|
70
|
+
value axis** — US EPA AQI categories, heart-rate / power zones, SLO bands,
|
|
71
|
+
control-chart spec limits — where a reading only means something read against
|
|
72
|
+
a scale.
|
|
73
|
+
|
|
74
|
+
```tsx
|
|
75
|
+
{
|
|
76
|
+
AQI_CATEGORIES.map((c) => (
|
|
77
|
+
<Zone key={c.role} from={c.from} to={c.to} axis="aqi" role={c.role} />
|
|
78
|
+
));
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Colour comes from `theme.annotation.roles[role]`, so a zone _set_ styles as
|
|
83
|
+
one palette in the theme rather than N colours at the call site. Bounds are
|
|
84
|
+
order-free, clamped to the plot (a band past the axis domain is cut, one
|
|
85
|
+
fully outside culls), and accept `±Infinity` for genuinely open-ended bands
|
|
86
|
+
(`to={Infinity}` — AQI's "Hazardous", a `ZoneTime.openEnded` zone), so a
|
|
87
|
+
whole category table can be rendered and the axis decides what shows.
|
|
88
|
+
|
|
89
|
+
Three defaults deliberately **invert** the rest of the annotation family,
|
|
90
|
+
because a zone spans the full width and a set tiles the row:
|
|
91
|
+
`selectable={false}` (else every mousemove lights a band and its hit area
|
|
92
|
+
eats the plot's clicks), `edges={false}` (contiguous sets share every
|
|
93
|
+
interior boundary — edges-on draws each twice), and no auto-label (the bounds
|
|
94
|
+
are already legible on the y axis; the useful label is a name). `<Zone>` has
|
|
95
|
+
no `onChange` — drag-to-edit zones await a consumer.
|
|
96
|
+
|
|
97
|
+
Guide: [From a CSV to a banded chart](https://pond-ts.org/docs/how-to-guides/air-quality-bands).
|
|
98
|
+
|
|
99
|
+
- **charts: `annotation.dash` — an optional dash pattern for the annotation
|
|
100
|
+
register**, per-register or per-role (`{ color, fillOpacity?, dash? }`), same
|
|
101
|
+
shape as `LineStyle.dash`. Applies to marker / baseline lines and region /
|
|
102
|
+
zone boundaries; fills are never dashed. A dashed reference line reads as
|
|
103
|
+
_placed_ rather than _measured_ — the job the annotation register exists to
|
|
104
|
+
do, and one colour alone can't always carry.
|
|
105
|
+
|
|
106
|
+
- **charts: threshold-banded bars** — `<BarChart thresholds={[1, 2]}>` colours
|
|
107
|
+
one bar **along its length** against a ladder (neutral → warning → alarm), so
|
|
108
|
+
a long bar shows how far through the ladder it travelled rather than only
|
|
109
|
+
which band it ended in. Band fills come from the new
|
|
110
|
+
**`BarStyle.bands`** on the resolved theme role, overridable per chart with
|
|
111
|
+
**`<BarChart bandColors>`** — breakpoints are data, colour stays in the theme,
|
|
112
|
+
the same split `colors` already applies to a stack's group fills.
|
|
113
|
+
|
|
114
|
+
The shape was previously expressible as N `<BarChart>` layers drawn
|
|
115
|
+
outermost-first, each clipped to a band, compositing the gradient by
|
|
116
|
+
overpainting. That produces the same pixels and loses what matters: N layers
|
|
117
|
+
means N hit targets, N `SelectInfo.mark` identities and N legend rows for
|
|
118
|
+
something the reader sees as one bar. Banding is **draw-only** — the hit rect
|
|
119
|
+
is untouched, so a banded bar stays one bar. It also measures **27–49%
|
|
120
|
+
cheaper** than the layered workaround at 8–400 categories
|
|
121
|
+
(`scripts/perf-bandbar.mjs`, which interleaves the arms and prints the
|
|
122
|
+
ratios), and costs nothing when unused.
|
|
123
|
+
|
|
124
|
+
Applies to any single-value bar — `series`, `bins`, `categories`, both
|
|
125
|
+
orientations. Negatives band symmetrically on the magnitude, so a ± diverging
|
|
126
|
+
scale needs no negative breakpoints. Ignored (with a dev warning) on a
|
|
127
|
+
multi-group stack, and yields to `binColors` when both are set. Suppresses
|
|
128
|
+
envelope decimation for the same reason `binColors` does.
|
|
129
|
+
|
|
130
|
+
- **charts: `<YAxis hide>`** — keep the scale, draw no gutter, reserve no
|
|
131
|
+
width. A `<YAxis>` does two jobs — it _holds the scale_ (`min`/`max`/`scale`/
|
|
132
|
+
`pad`) and it _renders a gutter_ — and there was no way to ask for the first
|
|
133
|
+
without the second. A caller could express "auto domain, no gutter" (omit the
|
|
134
|
+
axis) or "explicit domain, with a gutter", but not the pairing a fixed-domain
|
|
135
|
+
chart needs. Omitting the axis is not equivalent: the row supplies an implicit
|
|
136
|
+
auto-domain axis, which is exactly what must not be given up. `width={0}`
|
|
137
|
+
isn't either — the labels still draw, over the plot.
|
|
138
|
+
|
|
139
|
+
Gridlines are unaffected: they belong to the plot, not the gutter, and
|
|
140
|
+
`<ChartContainer grid>` already governs them.
|
|
141
|
+
|
|
142
|
+
- **charts: `<ChartContainer maxBandWidth>` + `bandAlign`** — cap the **slot
|
|
143
|
+
pitch** on a category x axis and place the resulting block
|
|
144
|
+
(`'start'` (default) / `'center'` / `'end'`). A band scale otherwise spreads
|
|
145
|
+
its categories across the full plot width, so three categories in a 900px
|
|
146
|
+
panel become three 300px bars and thirty become thirty 30px ones — the same
|
|
147
|
+
chart in the same panel reading as two different charts depending on how many
|
|
148
|
+
categories the data returned. Fine for a fixed domain; wrong for a **live**
|
|
149
|
+
one, where bar width becomes a variable that moves on its own and a reader
|
|
150
|
+
can't compare the chart to itself a minute ago.
|
|
151
|
+
|
|
152
|
+
`maxBandWidth` caps the **slot**; `<BarChart gap>` still insets the bar
|
|
153
|
+
within it — one knob for pitch, one for ink. Omitting `maxBandWidth` is the
|
|
154
|
+
previous fill behaviour exactly, and a cap too loose to bind degrades back to
|
|
155
|
+
it rather than clipping. There is deliberately no `bandAlign: 'fill'`: "fill"
|
|
156
|
+
is what omitting the cap means, and a `fill` alongside a pitch cap would be a
|
|
157
|
+
contradiction rather than a choice.
|
|
158
|
+
|
|
159
|
+
**Vertical / x-axis categories only** — a `orientation="horizontal"`
|
|
160
|
+
categorical chart puts its categories on the y axis as unit slots, a
|
|
161
|
+
different mechanism this does not cap.
|
|
162
|
+
|
|
163
|
+
- **charts: themed emphasis on the category path** — `BarStyle.hover` /
|
|
164
|
+
`.highlight` now apply to `categories` and horizontal bars, which routed
|
|
165
|
+
through the transposed stacked draw path and read neither. New
|
|
166
|
+
`BarStyle.selectedOutline` (the selected bar's stroke, where the default is
|
|
167
|
+
its own fill) and `BarStyle.emphasisOpacity` (the alpha a live bar pops to,
|
|
168
|
+
previously hard-coded `1`) make the emphasis tunable rather than fixed.
|
|
169
|
+
|
|
170
|
+
The _behaviour_ was defensible; the problem was that the theme accepted
|
|
171
|
+
values it would not use. `bar.hover` / `.highlight` were typed, settable and
|
|
172
|
+
documented as the emphasis channel, and silently did nothing on the most
|
|
173
|
+
common categorical chart, so a theme author set them, saw no change, and
|
|
174
|
+
could not tell whether they were wrong about the colour or the mechanism.
|
|
175
|
+
The one genuine exclusion stays and is now the only one: a `binColors` bar
|
|
176
|
+
keeps its own colour under hover/selection, because swapping a
|
|
177
|
+
zone-coloured or direction-coloured bar to a single highlight hue erases
|
|
178
|
+
what the colour encodes.
|
|
179
|
+
|
|
180
|
+
### Fixed
|
|
181
|
+
|
|
182
|
+
- **charts: a negative segment in a multi-group stack is no longer silently
|
|
183
|
+
dropped** ([PND-SIGNSTACK]). Positives stack **up** from the baseline and
|
|
184
|
+
negatives stack **down** from it — two running totals per bin — so the
|
|
185
|
+
**signed stacked histogram** (net flow by category, inflow/outflow, buy/sell
|
|
186
|
+
pressure by venue) renders correctly. `stackValueExtent` grew the matching
|
|
187
|
+
negative half; both had to move together, since an extent stopping at `0`
|
|
188
|
+
would clip the segments the draw path now emits.
|
|
189
|
+
|
|
190
|
+
**This is a visible behaviour change** for any existing chart feeding
|
|
191
|
+
negative values into a multi-group stack — but such a chart was previously
|
|
192
|
+
rendering _wrongly_: the dropped segments did not clamp, warn or throw, every
|
|
193
|
+
remaining segment stacked up as though they had never been in the data, and a
|
|
194
|
+
mixed-sign series came out as a confident, wrong, all-positive chart. An
|
|
195
|
+
all-positive stack is bit-identical to before. Splitting into two layers was
|
|
196
|
+
not a workaround either — the negative layer was still `G > 1`, so it was
|
|
197
|
+
dropped too.
|
|
198
|
+
|
|
199
|
+
### Changed
|
|
200
|
+
|
|
201
|
+
- **charts (docs): `<BarChart bins>` now states that it selects a _value_
|
|
202
|
+
axis** ([PND-TICKUNIT]), so a time-bucketed histogram fed through `bins` gets
|
|
203
|
+
the decimal 1-2-5 tick ladder rather than the duration ladder a clock
|
|
204
|
+
subdivides by — labelling e.g. 11:40 and 13:20 at a ~100-minute step. The
|
|
205
|
+
natural reading ("I have pre-binned buckets, so I'll pass `bins`") is exactly
|
|
206
|
+
what forecloses the time axis, and nothing at the call site said so. The prop
|
|
207
|
+
docs now point a time-keyed caller at `<BarChart series columns>`, where the
|
|
208
|
+
clock ticks are native. No behaviour change.
|
|
209
|
+
|
|
62
210
|
## [0.56.2] — 2026-08-05
|
|
63
211
|
|
|
64
212
|
### Fixed
|
package/dist/BarChart.d.ts
CHANGED
|
@@ -26,6 +26,22 @@ import type { DecimateOption } from './decimate.js';
|
|
|
26
26
|
* (`Array<{ start, end, …aggregates }>`); the names are **aggregate
|
|
27
27
|
* fields** of the record, not schema columns, so they stay `string`. Pair
|
|
28
28
|
* with `ordinal` for a band axis.
|
|
29
|
+
*
|
|
30
|
+
* **`bins` selects a *value* axis, so a time-bucketed histogram fed this way
|
|
31
|
+
* gets decimal ticks** ([PND-TICKUNIT]). A `TimeSeries`/`Map` bins on time; a
|
|
32
|
+
* `ValueSeries`/`bins`-array bins on a value axis — which means the tick
|
|
33
|
+
* ladder is the plain 1-2-5 walk, not the **duration** ladder a clock
|
|
34
|
+
* subdivides by (15s and 30s are round durations where 20s and 50s are not).
|
|
35
|
+
* A minute-of-day histogram passed as `bins` therefore labels something like
|
|
36
|
+
* 11:40 and 13:20 — real times at a ~100-minute step — and nothing at the
|
|
37
|
+
* call site says why.
|
|
38
|
+
*
|
|
39
|
+
* The natural reading ("I have pre-binned buckets, so I'll pass `bins`") is
|
|
40
|
+
* exactly what forecloses the time axis. For a **time-keyed** histogram use
|
|
41
|
+
* the series door instead — `<BarChart series columns>` on a time-keyed wide
|
|
42
|
+
* series — and the clock ticks are native with no workaround.
|
|
43
|
+
* `<ChartContainer origin>` does not rescue it: it relabels a value axis but
|
|
44
|
+
* does not re-ladder it.
|
|
29
45
|
* - **`categories`** — an ordered `{ label, value }[]`, one bar per category.
|
|
30
46
|
* Takes **no** `column`/`columns` (each datum carries its own value).
|
|
31
47
|
* Vertical puts the categories on the ordinal **x** axis (the container's
|
|
@@ -116,6 +132,58 @@ export interface BarChartCommon<S extends SeriesSchema = SeriesSchema, VS extend
|
|
|
116
132
|
* carry many bars' colours, so every visible bar draws.
|
|
117
133
|
*/
|
|
118
134
|
binColors?: readonly (string | undefined)[];
|
|
135
|
+
/**
|
|
136
|
+
* **Threshold breakpoints** — colour each bar *along its length* against a
|
|
137
|
+
* ladder, so a long bar shows how far through the ladder it travelled rather
|
|
138
|
+
* than only which band it ended in. Breakpoints are **absolute data values**
|
|
139
|
+
* in the axis's own units — `[1, 2]` means "warning above 1, alarm above 2",
|
|
140
|
+
* not offsets from wherever the bar happens to rest — and `n` of them make
|
|
141
|
+
* `n + 1` bands. Each must be finite and greater than zero; anything else is
|
|
142
|
+
* dropped with a dev warning (the ladder is walked on the magnitude, so a
|
|
143
|
+
* negative breakpoint is not expressible).
|
|
144
|
+
*
|
|
145
|
+
* ```tsx
|
|
146
|
+
* // neutral to 1, warning 1–2, alarm above 2
|
|
147
|
+
* <BarChart categories={cats} thresholds={[1, 2]} />
|
|
148
|
+
* ```
|
|
149
|
+
*
|
|
150
|
+
* Band fills come from {@link BarStyle.bands} on the resolved role
|
|
151
|
+
* (`theme.bar[as] ?? theme.bar.default`), overridden by {@link bandColors}.
|
|
152
|
+
* Breakpoints are data and live here; colour stays in the theme — the same
|
|
153
|
+
* split as `colors` over the stack's group fills.
|
|
154
|
+
*
|
|
155
|
+
* **A banded bar is still one bar.** It keeps one hit region, one stable
|
|
156
|
+
* `SelectInfo.mark` and one legend row — which is the whole difference from
|
|
157
|
+
* the N-overlaid-layers recipe this replaces, where each band was separately
|
|
158
|
+
* hittable and separately listed. Selection and hover pop the opacity and
|
|
159
|
+
* keep the band colours (as `binColors` does), outlining in the colour of the
|
|
160
|
+
* band the value actually reached.
|
|
161
|
+
*
|
|
162
|
+
* **Negatives band symmetrically**: the ladder is walked on the magnitude and
|
|
163
|
+
* re-signed, so a bar hanging below the baseline reads the same ±ladder
|
|
164
|
+
* without negative breakpoints. Out-of-order entries are sorted (the bands
|
|
165
|
+
* are defined by their boundaries, so there is no second reading) and
|
|
166
|
+
* non-finite ones dropped.
|
|
167
|
+
*
|
|
168
|
+
* Applies to any **single-value** bar — a `series`/`bins` chart, `categories`,
|
|
169
|
+
* and both orientations. On a genuine **multi-group stack** it is ignored
|
|
170
|
+
* with a dev warning: a segment that is already one slice of a total has no
|
|
171
|
+
* defined banding. Set with `binColors` it also yields (per-bar colour is the
|
|
172
|
+
* more specific answer) and warns. **Disables envelope decimation** for the
|
|
173
|
+
* same reason `binColors` does.
|
|
174
|
+
*/
|
|
175
|
+
thresholds?: readonly number[];
|
|
176
|
+
/**
|
|
177
|
+
* Call-site override for the {@link thresholds} band fills — `bandColors[k]`
|
|
178
|
+
* paints the band above `thresholds[k - 1]`, so a ladder of `n` thresholds
|
|
179
|
+
* reads `n + 1` entries. Omitted ⇒ {@link BarStyle.bands} from the theme.
|
|
180
|
+
*
|
|
181
|
+
* Prefer the theme for anything a design system owns; this is the escape
|
|
182
|
+
* hatch for a one-off ladder that shouldn't mint a theme role. If neither
|
|
183
|
+
* source supplies enough entries, the shortfall falls back to the flat fill
|
|
184
|
+
* and dev-warns rather than silently drawing an unbanded bar.
|
|
185
|
+
*/
|
|
186
|
+
bandColors?: readonly string[];
|
|
119
187
|
/**
|
|
120
188
|
* Bar growth direction (the histogram orientation). **Default `'vertical'`.**
|
|
121
189
|
*
|
|
@@ -220,14 +288,21 @@ export type BarChartProps<S extends SeriesSchema = SeriesSchema, VS extends Valu
|
|
|
220
288
|
* domain spans zero, or on the axis floor when an explicit `<YAxis min>` sits
|
|
221
289
|
* above zero (see {@link resolveBarBaseline}).
|
|
222
290
|
*
|
|
223
|
-
* **Baseline (stacked).** A stack is **cumulative from value 0** —
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
* An explicit `<YAxis min>` **above** 0 is therefore unsupported
|
|
227
|
-
* would hide the bottom of the cumulative column; only the
|
|
228
|
-
* draws (clipped cleanly at the plot floor, as any bar
|
|
229
|
-
*
|
|
230
|
-
*
|
|
291
|
+
* **Baseline (stacked).** A stack is **cumulative from value 0** — so its value
|
|
292
|
+
* axis **must include 0**. The auto-fit guarantees this:
|
|
293
|
+
* {@link stackValueExtent} returns `[minNegativeTotal, maxPositiveTotal]`, both
|
|
294
|
+
* seeded at `0`. An explicit `<YAxis min>` **above** 0 is therefore unsupported
|
|
295
|
+
* for a stack — it would hide the bottom of the cumulative column; only the
|
|
296
|
+
* portion above the floor draws (clipped cleanly at the plot floor, as any bar
|
|
297
|
+
* below an explicit floor is).
|
|
298
|
+
*
|
|
299
|
+
* **Signed stacks are supported** ([PND-SIGNSTACK]): each bin keeps two running
|
|
300
|
+
* totals, so positive segments stack **up** from the zero line and negative
|
|
301
|
+
* ones stack **down** from it — the signed histogram (net flow by category,
|
|
302
|
+
* inflow/outflow, buy/sell pressure by venue). A **zero** segment is still
|
|
303
|
+
* skipped, having no extent to draw or hit-test. This changed in the
|
|
304
|
+
* threshold-banding wave: negative segments were previously dropped outright
|
|
305
|
+
* and silently, so a mixed-sign series rendered as an all-positive chart.
|
|
231
306
|
*
|
|
232
307
|
* **Interaction (opt-in via `id`).** Hover lights the bar / segment under the
|
|
233
308
|
* cursor (hit-tested by pixel rect, so it works in both orientations); click
|
|
@@ -244,6 +319,6 @@ export type BarChartProps<S extends SeriesSchema = SeriesSchema, VS extends Valu
|
|
|
244
319
|
* </Layers>
|
|
245
320
|
* ```
|
|
246
321
|
*/
|
|
247
|
-
export declare function BarChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, bins, categories, column, columns, as: semantic, colors, binColors, orientation, ordinal, id, axis, gap, decimate, legend, index, }: BarChartProps<S, VS>): null;
|
|
322
|
+
export declare function BarChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, bins, categories, column, columns, as: semantic, colors, binColors, thresholds, bandColors, orientation, ordinal, id, axis, gap, decimate, legend, index, }: BarChartProps<S, VS>): null;
|
|
248
323
|
export {};
|
|
249
324
|
//# sourceMappingURL=BarChart.d.ts.map
|
package/dist/BarChart.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { useContext, useEffect, useMemo } from 'react';
|
|
2
2
|
import { Interval, ValueSeries } from 'pond-ts';
|
|
3
3
|
import { barsFromTimeSeries, barsFromBins, barsFromValueSeries, categoryStack, stacksFromBins, stacksFromColumns, stacksFromGroups, } from './data.js';
|
|
4
|
-
import { barAt, barExtent, barIndexAtTime, drawBars, drawStacks, resolveBarBaseline, stackAt, stackBinExtent, stackValueExtent, } from './bars.js';
|
|
4
|
+
import { barAt, barExtent, barIndexAtTime, drawBars, drawStacks, normalizeThresholds, resolveBarBaseline, stackAt, stackBinExtent, stackValueExtent, } from './bars.js';
|
|
5
|
+
import { isDev } from './dev.js';
|
|
5
6
|
import { ContainerContext, LayersContext, } from './context.js';
|
|
6
7
|
import { legendLabelFor, useLegendItems, } from './swatch.js';
|
|
7
8
|
import { useSlotKey } from './use-slot-key.js';
|
|
@@ -24,14 +25,21 @@ import { useSlotKey } from './use-slot-key.js';
|
|
|
24
25
|
* domain spans zero, or on the axis floor when an explicit `<YAxis min>` sits
|
|
25
26
|
* above zero (see {@link resolveBarBaseline}).
|
|
26
27
|
*
|
|
27
|
-
* **Baseline (stacked).** A stack is **cumulative from value 0** —
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* An explicit `<YAxis min>` **above** 0 is therefore unsupported
|
|
31
|
-
* would hide the bottom of the cumulative column; only the
|
|
32
|
-
* draws (clipped cleanly at the plot floor, as any bar
|
|
33
|
-
*
|
|
34
|
-
*
|
|
28
|
+
* **Baseline (stacked).** A stack is **cumulative from value 0** — so its value
|
|
29
|
+
* axis **must include 0**. The auto-fit guarantees this:
|
|
30
|
+
* {@link stackValueExtent} returns `[minNegativeTotal, maxPositiveTotal]`, both
|
|
31
|
+
* seeded at `0`. An explicit `<YAxis min>` **above** 0 is therefore unsupported
|
|
32
|
+
* for a stack — it would hide the bottom of the cumulative column; only the
|
|
33
|
+
* portion above the floor draws (clipped cleanly at the plot floor, as any bar
|
|
34
|
+
* below an explicit floor is).
|
|
35
|
+
*
|
|
36
|
+
* **Signed stacks are supported** ([PND-SIGNSTACK]): each bin keeps two running
|
|
37
|
+
* totals, so positive segments stack **up** from the zero line and negative
|
|
38
|
+
* ones stack **down** from it — the signed histogram (net flow by category,
|
|
39
|
+
* inflow/outflow, buy/sell pressure by venue). A **zero** segment is still
|
|
40
|
+
* skipped, having no extent to draw or hit-test. This changed in the
|
|
41
|
+
* threshold-banding wave: negative segments were previously dropped outright
|
|
42
|
+
* and silently, so a mixed-sign series rendered as an all-positive chart.
|
|
35
43
|
*
|
|
36
44
|
* **Interaction (opt-in via `id`).** Hover lights the bar / segment under the
|
|
37
45
|
* cursor (hit-tested by pixel rect, so it works in both orientations); click
|
|
@@ -48,7 +56,7 @@ import { useSlotKey } from './use-slot-key.js';
|
|
|
48
56
|
* </Layers>
|
|
49
57
|
* ```
|
|
50
58
|
*/
|
|
51
|
-
export function BarChart({ series, bins, categories, column, columns, as: semantic, colors, binColors, orientation = 'vertical', ordinal = false, id, axis, gap, decimate = true, legend, index = 0, }) {
|
|
59
|
+
export function BarChart({ series, bins, categories, column, columns, as: semantic, colors, binColors, thresholds, bandColors, orientation = 'vertical', ordinal = false, id, axis, gap, decimate = true, legend, index = 0, }) {
|
|
52
60
|
const container = useContext(ContainerContext);
|
|
53
61
|
if (container === null) {
|
|
54
62
|
throw new Error('<BarChart> must be rendered inside a <ChartContainer>');
|
|
@@ -232,6 +240,87 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
232
240
|
// The stacked path's bar-thickness floor comes from `bar.default` (not the `as`
|
|
233
241
|
// role — `as` is single-series only), matching how `gapPx` sources its default.
|
|
234
242
|
const stackMinWidth = bar.default.minWidth;
|
|
243
|
+
// ── Threshold ladder ([PND-BANDBAR2]) ────────────────────────────────────
|
|
244
|
+
// Resolved once here rather than per bar per frame: normalize the breakpoints
|
|
245
|
+
// (sort, drop non-finite), then pair them with `bandColors` → the role's
|
|
246
|
+
// `BarStyle.bands`. Everything that can go wrong with the pairing is a
|
|
247
|
+
// *silent* wrong-looking chart, so each case dev-warns — this feature exists
|
|
248
|
+
// because a quietly-unbanded bar was the workaround's failure mode.
|
|
249
|
+
// Value-compare the two array props rather than relying on their identity.
|
|
250
|
+
// `thresholds={[1, 2]}` inline is the documented usage and the shape every
|
|
251
|
+
// story and doc example uses — and a fresh array each render would rebuild
|
|
252
|
+
// the ladder, hence the layer `entry` below, hence a `registerLayer` call
|
|
253
|
+
// **every render**. That is a repaint treadmill, not just a noisy warning.
|
|
254
|
+
// The same value-compare-on-registration reasoning `<YAxis ticks>` already
|
|
255
|
+
// applies.
|
|
256
|
+
const thresholdKey = thresholds === undefined ? '' : thresholds.join(',');
|
|
257
|
+
const bandColorKey = bandColors === undefined ? '' : bandColors.join(',');
|
|
258
|
+
const bandLadder = useMemo(() => {
|
|
259
|
+
const steps = normalizeThresholds(thresholds);
|
|
260
|
+
if (steps === null) {
|
|
261
|
+
if (isDev && thresholds !== undefined && thresholds.length > 0) {
|
|
262
|
+
console.warn('<BarChart thresholds>: no usable breakpoints, so no banding was ' +
|
|
263
|
+
'applied — each must be finite and greater than zero. Bars draw ' +
|
|
264
|
+
'in the flat fill.');
|
|
265
|
+
}
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
// Some, but not all, entries dropped. Silently banding on a subset of what
|
|
269
|
+
// the caller wrote is exactly the class of quiet wrongness this feature is
|
|
270
|
+
// meant to remove, so say so.
|
|
271
|
+
if (isDev && thresholds !== undefined && steps.length < thresholds.length) {
|
|
272
|
+
console.warn(`<BarChart thresholds>: dropped ${thresholds.length - steps.length} ` +
|
|
273
|
+
'breakpoint(s) that were not finite and greater than zero. The ' +
|
|
274
|
+
'ladder is walked on the magnitude and mirrored onto whichever side ' +
|
|
275
|
+
'of zero a bar is on, so a negative breakpoint has no meaning; ' +
|
|
276
|
+
`banding on [${steps.join(', ')}].`);
|
|
277
|
+
}
|
|
278
|
+
const want = steps.length + 1;
|
|
279
|
+
const supplied = bandColors ?? singleStyle.bands;
|
|
280
|
+
if (supplied === undefined || supplied.length === 0) {
|
|
281
|
+
if (isDev) {
|
|
282
|
+
console.warn(`<BarChart thresholds>: ${steps.length} breakpoint(s) need ${want} ` +
|
|
283
|
+
'band colours, but neither `bandColors` nor the theme role’s ' +
|
|
284
|
+
'`BarStyle.bands` supplies any. Bars draw in the flat fill.');
|
|
285
|
+
}
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
if (supplied.length < want && isDev) {
|
|
289
|
+
console.warn(`<BarChart thresholds>: ${steps.length} breakpoint(s) need ${want} ` +
|
|
290
|
+
`band colours but only ${supplied.length} were supplied; bands ` +
|
|
291
|
+
'above the last colour fall back to the flat fill.');
|
|
292
|
+
}
|
|
293
|
+
// Pad a short ladder with the flat fill so the draw path can index freely.
|
|
294
|
+
const resolved = supplied.length >= want
|
|
295
|
+
? supplied.slice(0, want)
|
|
296
|
+
: [
|
|
297
|
+
...supplied,
|
|
298
|
+
...Array.from({ length: want - supplied.length }, () => singleStyle.fill),
|
|
299
|
+
];
|
|
300
|
+
return { thresholds: steps, colors: resolved };
|
|
301
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- `thresholdKey` /
|
|
302
|
+
// `bandColorKey` are the value-compared stand-ins for the array props.
|
|
303
|
+
}, [thresholdKey, bandColorKey, singleStyle]);
|
|
304
|
+
// Conflicts between the ladder and the shapes it can't apply to. In an effect
|
|
305
|
+
// so a re-render doesn't re-log; each fires once per genuinely new pairing.
|
|
306
|
+
const multiGroup = shape.kind === 'stacked' && shape.ss.groups.length > 1;
|
|
307
|
+
const hasLadder = bandLadder !== undefined;
|
|
308
|
+
const hasBinColors = binColors !== undefined;
|
|
309
|
+
useEffect(() => {
|
|
310
|
+
if (!isDev || !hasLadder)
|
|
311
|
+
return;
|
|
312
|
+
if (hasBinColors) {
|
|
313
|
+
console.warn('<BarChart>: `thresholds` and `binColors` are both set. They are two ' +
|
|
314
|
+
'answers to “what colour is this bar”; `binColors` wins as the more ' +
|
|
315
|
+
'specific one, and the threshold bands are ignored.');
|
|
316
|
+
}
|
|
317
|
+
if (multiGroup) {
|
|
318
|
+
console.warn('<BarChart>: `thresholds` is ignored on a multi-group stack — a ' +
|
|
319
|
+
'segment that is already one slice of a total has no defined ' +
|
|
320
|
+
'banding. Threshold bands apply to single-value bars (`series` / ' +
|
|
321
|
+
'`bins` / `categories`), in either orientation.');
|
|
322
|
+
}
|
|
323
|
+
}, [hasLadder, hasBinColors, multiGroup]);
|
|
235
324
|
// Stacked style: per-group fills (colors override → theme role → default),
|
|
236
325
|
// plus the shared opacity / outline from the default bar style. Memoized on the
|
|
237
326
|
// groups + colours so a selection change doesn't rebuild it.
|
|
@@ -243,6 +332,17 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
243
332
|
fills,
|
|
244
333
|
opacity: base.opacity,
|
|
245
334
|
outlineWidth: base.outlineWidth,
|
|
335
|
+
// [PND-CATEMPH] Forward the themed emphasis so the category / horizontal
|
|
336
|
+
// path can read the same `fill → hover → highlight` channel every other
|
|
337
|
+
// bar does, instead of accepting those theme values and ignoring them.
|
|
338
|
+
highlight: base.highlight,
|
|
339
|
+
...(base.hover !== undefined ? { hover: base.hover } : {}),
|
|
340
|
+
...(base.selectedOutline !== undefined
|
|
341
|
+
? { selectedOutline: base.selectedOutline }
|
|
342
|
+
: {}),
|
|
343
|
+
...(base.emphasisOpacity !== undefined
|
|
344
|
+
? { emphasisOpacity: base.emphasisOpacity }
|
|
345
|
+
: {}),
|
|
246
346
|
...(binColors !== undefined ? { binFills: binColors } : {}),
|
|
247
347
|
};
|
|
248
348
|
}, [bar, groups, colors, binColors]);
|
|
@@ -331,7 +431,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
331
431
|
};
|
|
332
432
|
},
|
|
333
433
|
}),
|
|
334
|
-
draw: (ctx, xScale, yScale) => drawBars(ctx, bs, xScale, yScale, singleStyle, resolveBarBaseline(yScale), gapPx, id, selection, hover, decimate, binColors),
|
|
434
|
+
draw: (ctx, xScale, yScale) => drawBars(ctx, bs, xScale, yScale, singleStyle, resolveBarBaseline(yScale), gapPx, id, selection, hover, decimate, binColors, bandLadder),
|
|
335
435
|
},
|
|
336
436
|
axisId: axis,
|
|
337
437
|
index,
|
|
@@ -391,7 +491,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
391
491
|
};
|
|
392
492
|
},
|
|
393
493
|
}),
|
|
394
|
-
draw: (ctx, xScale, yScale) => drawStacks(ctx, ss, orientation, xScale, yScale, stackStyle, gapPx, stackMinWidth, id, selection, hover),
|
|
494
|
+
draw: (ctx, xScale, yScale) => drawStacks(ctx, ss, orientation, xScale, yScale, stackStyle, gapPx, stackMinWidth, id, selection, hover, bandLadder),
|
|
395
495
|
},
|
|
396
496
|
axisId: axis,
|
|
397
497
|
index,
|
|
@@ -405,6 +505,7 @@ export function BarChart({ series, bins, categories, column, columns, as: semant
|
|
|
405
505
|
singleStyle,
|
|
406
506
|
stackStyle,
|
|
407
507
|
binColors,
|
|
508
|
+
bandLadder,
|
|
408
509
|
label,
|
|
409
510
|
id,
|
|
410
511
|
gapPx,
|
package/dist/ChartContainer.d.ts
CHANGED
|
@@ -14,6 +14,49 @@ export interface ChartContainerProps {
|
|
|
14
14
|
* the data — so a tuple stays a time domain on a time chart.
|
|
15
15
|
*/
|
|
16
16
|
range?: readonly [number, number] | TimeRange;
|
|
17
|
+
/**
|
|
18
|
+
* **Cap the slot pitch** on a **category** x axis, in CSS pixels
|
|
19
|
+
* ([PND-BANDPACK]). A band scale otherwise spreads its categories across the
|
|
20
|
+
* full plot width, so three categories in a 900px panel become three 300px
|
|
21
|
+
* bars and thirty become thirty 30px ones — the same chart in the same panel
|
|
22
|
+
* reading as two different charts depending on how many categories the data
|
|
23
|
+
* happened to return.
|
|
24
|
+
*
|
|
25
|
+
* That is fine for a static chart with a known domain and wrong for a **live**
|
|
26
|
+
* one: when the category count moves over a session, bar width becomes a
|
|
27
|
+
* meaningless variable that moves on its own, and a reader can't compare the
|
|
28
|
+
* chart to what it looked like a minute ago or to the same chart on another
|
|
29
|
+
* screen. Capping the pitch keeps bar width constant and comparable, and the
|
|
30
|
+
* empty space left over is itself information — it shows the set is small.
|
|
31
|
+
*
|
|
32
|
+
* Omitted ⇒ slots fill the plot (unchanged). When `n × maxBandWidth` exceeds
|
|
33
|
+
* the plot, the cap can't bind and the slots fill as before, so this degrades
|
|
34
|
+
* correctly as categories accumulate. Use {@link bandAlign} to say where the
|
|
35
|
+
* capped block sits.
|
|
36
|
+
*
|
|
37
|
+
* **This caps the slot, not the bar.** `<BarChart gap>` still insets the bar
|
|
38
|
+
* within its slot, and the two compose — one knob for pitch, one for ink,
|
|
39
|
+
* neither doing the other's job. (Inverting `gap` against a measured plot
|
|
40
|
+
* width was the workaround this replaces for the width half; the packing half
|
|
41
|
+
* had no workaround at all.)
|
|
42
|
+
*
|
|
43
|
+
* **Vertical / x-axis categories only.** A `orientation="horizontal"`
|
|
44
|
+
* categorical chart puts its categories on the **y** axis as unit slots,
|
|
45
|
+
* which is a different mechanism and is not capped by this.
|
|
46
|
+
*/
|
|
47
|
+
maxBandWidth?: number;
|
|
48
|
+
/**
|
|
49
|
+
* Where the capped category block sits in the plot when {@link maxBandWidth}
|
|
50
|
+
* binds. **Default `'start'`** — pack from the left, leaving the far side
|
|
51
|
+
* empty. `'center'` and `'end'` place it otherwise.
|
|
52
|
+
*
|
|
53
|
+
* A no-op without `maxBandWidth`, or when the cap doesn't bind: the block
|
|
54
|
+
* fills the plot and there is no slack to place. (There is deliberately no
|
|
55
|
+
* `'fill'` member — "fill" is what *omitting* `maxBandWidth` means, and a
|
|
56
|
+
* `fill` value alongside a pitch cap would be a contradiction rather than a
|
|
57
|
+
* choice.)
|
|
58
|
+
*/
|
|
59
|
+
bandAlign?: 'start' | 'center' | 'end';
|
|
17
60
|
/**
|
|
18
61
|
* A **trading-calendar** discontinuity provider — closed-market time
|
|
19
62
|
* (weekends, holidays, overnight, lunch breaks) collapsed. Supply it to turn
|
|
@@ -419,5 +462,5 @@ export interface ChartContainerProps {
|
|
|
419
462
|
* {@link TimeAxis} at the bottom, aligned under the plots. Y axes are per-row
|
|
420
463
|
* (`<YAxis>`).
|
|
421
464
|
*/
|
|
422
|
-
export declare function ChartContainer({ range, width, rowGap, showAxis, trackerPosition, onTrackerChanged, onDrawStats, selected, onSelect, hovered, onHover, panZoom, bounds, onTimeRangeChange, minDuration, cursor, cursorSequence, onRegionSelect, regionSelectModifier, cursorTime, crosshairSnap, editAnnotations, creating, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap, timeFormat, cursorFormat, origin, theme, discontinuities, calendar, spacing, grid, sessionDividers, children, }: ChartContainerProps): import("react/jsx-runtime").JSX.Element;
|
|
465
|
+
export declare function ChartContainer({ range, maxBandWidth, bandAlign, width, rowGap, showAxis, trackerPosition, onTrackerChanged, onDrawStats, selected, onSelect, hovered, onHover, panZoom, bounds, onTimeRangeChange, minDuration, cursor, cursorSequence, onRegionSelect, regionSelectModifier, cursorTime, crosshairSnap, editAnnotations, creating, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap, timeFormat, cursorFormat, origin, theme, discontinuities, calendar, spacing, grid, sessionDividers, children, }: ChartContainerProps): import("react/jsx-runtime").JSX.Element;
|
|
423
466
|
//# sourceMappingURL=ChartContainer.d.ts.map
|
package/dist/ChartContainer.js
CHANGED
|
@@ -45,7 +45,7 @@ function normalizeRange(range) {
|
|
|
45
45
|
* {@link TimeAxis} at the bottom, aligned under the plots. Y axes are per-row
|
|
46
46
|
* (`<YAxis>`).
|
|
47
47
|
*/
|
|
48
|
-
export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, onDrawStats, selected, onSelect, hovered, onHover, panZoom = false, bounds, onTimeRangeChange, minDuration = 1, cursor = DEFAULT_CURSOR_MODE, cursorSequence, onRegionSelect, regionSelectModifier, cursorTime = false, crosshairSnap = true, editAnnotations = false, creating = null, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap = true, timeFormat, cursorFormat, origin, theme, discontinuities, calendar, spacing, grid = true, sessionDividers = 'none', children, }) {
|
|
48
|
+
export function ChartContainer({ range, maxBandWidth, bandAlign = 'start', width, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, onDrawStats, selected, onSelect, hovered, onHover, panZoom = false, bounds, onTimeRangeChange, minDuration = 1, cursor = DEFAULT_CURSOR_MODE, cursorSequence, onRegionSelect, regionSelectModifier, cursorTime = false, crosshairSnap = true, editAnnotations = false, creating = null, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap = true, timeFormat, cursorFormat, origin, theme, discontinuities, calendar, spacing, grid = true, sessionDividers = 'none', children, }) {
|
|
49
49
|
// Normalize the `panZoom` mode (boolean shorthand or the three-way string)
|
|
50
50
|
// into the two gesture flags the event surface reads. `true` ⇒ both; `'pan'`
|
|
51
51
|
// ⇒ drag only; `false`/`'none'` ⇒ neither. Zoom implies pan (there is no
|
|
@@ -414,7 +414,21 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
414
414
|
// reads by **name** — `cursorFormat` has nothing to format, so the
|
|
415
415
|
// readout channel stays unset.
|
|
416
416
|
const cats = categories ?? [];
|
|
417
|
-
|
|
417
|
+
// [PND-BANDPACK] Cap the slot pitch, then place the resulting block. With
|
|
418
|
+
// no cap (or one too loose to bind) `packed === plotWidth` and `offset`
|
|
419
|
+
// is 0, so the range is `[0, plotWidth]` exactly as before — the whole
|
|
420
|
+
// feature collapses to the shipped behaviour when unused.
|
|
421
|
+
const n = cats.length;
|
|
422
|
+
const pitch = n > 0 ? plotWidth / n : plotWidth;
|
|
423
|
+
const capped = maxBandWidth !== undefined && maxBandWidth > 0
|
|
424
|
+
? Math.min(pitch, maxBandWidth)
|
|
425
|
+
: pitch;
|
|
426
|
+
const packed = n > 0 ? capped * n : plotWidth;
|
|
427
|
+
const slack = Math.max(0, plotWidth - packed);
|
|
428
|
+
const offset = bandAlign === 'center' ? slack / 2 : bandAlign === 'end' ? slack : 0;
|
|
429
|
+
const s = scaleBand(cats)
|
|
430
|
+
.domain([0, n])
|
|
431
|
+
.range([offset, offset + packed]);
|
|
418
432
|
return {
|
|
419
433
|
xScale: s,
|
|
420
434
|
formatTime: (v) => s.label(v),
|
|
@@ -555,6 +569,8 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
555
569
|
}, [
|
|
556
570
|
resolvedKind,
|
|
557
571
|
categories,
|
|
572
|
+
maxBandWidth,
|
|
573
|
+
bandAlign,
|
|
558
574
|
d0,
|
|
559
575
|
d1,
|
|
560
576
|
plotWidth,
|
package/dist/YAxis.d.ts
CHANGED
|
@@ -106,6 +106,33 @@ export interface YAxisProps {
|
|
|
106
106
|
boundaryLabels?: boolean;
|
|
107
107
|
/** Gutter width in CSS pixels (default 50). */
|
|
108
108
|
width?: number;
|
|
109
|
+
/**
|
|
110
|
+
* **Keep the scale, draw no gutter.** The axis still registers its domain
|
|
111
|
+
* (`min`/`max`/`scale`/`pad`) and layers still bind to it by `id`, but it
|
|
112
|
+
* renders nothing and reserves **no width** — the plot gets the space.
|
|
113
|
+
*
|
|
114
|
+
* A `<YAxis>` does two jobs: it *holds the scale* and it *renders a gutter*.
|
|
115
|
+
* Without this there was no way to ask for the first without the second, so a
|
|
116
|
+
* chart with a **fixed** domain whose scale is already explained by its
|
|
117
|
+
* chrome (threshold band lines, a legend, a panel header) had two reachable
|
|
118
|
+
* options and needed a third:
|
|
119
|
+
*
|
|
120
|
+
* | | auto domain | explicit domain |
|
|
121
|
+
* |---|---|---|
|
|
122
|
+
* | **gutter** | `<YAxis />` | `<YAxis min max />` |
|
|
123
|
+
* | **no gutter** | omit the axis | ← this prop |
|
|
124
|
+
*
|
|
125
|
+
* Omitting the axis is not the same thing: the row then supplies an implicit
|
|
126
|
+
* auto-domain axis, and the fixed domain is exactly what must not be given
|
|
127
|
+
* up. `width={0}` is not it either — the labels still draw, now over the
|
|
128
|
+
* plot.
|
|
129
|
+
*
|
|
130
|
+
* **Gridlines are unaffected.** They belong to the plot, not the gutter, and
|
|
131
|
+
* `<ChartContainer grid>` already controls them — so a hidden axis can still rule
|
|
132
|
+
* its own gridlines, which is usually what a "the shape matters, the numbers
|
|
133
|
+
* don't" chart wants. Turn them off there if you want neither.
|
|
134
|
+
*/
|
|
135
|
+
hide?: boolean;
|
|
109
136
|
/**
|
|
110
137
|
* This axis instance's colour — tick labels and the axis title take it,
|
|
111
138
|
* overriding the theme's `axis.label` / `axis.title.color`. The multi-axis
|
|
@@ -128,5 +155,5 @@ export interface YAxisProps {
|
|
|
128
155
|
* tick marks + labels from that scale. Charts attach via `<LineChart axis="id">`
|
|
129
156
|
* (default: the first axis).
|
|
130
157
|
*/
|
|
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;
|
|
158
|
+
export declare function YAxis({ id, side, label, scale, min, max, format, ticks, tickCount, pad, boundaryLabels, width, hide, labelPlacement, color, index, }: YAxisProps): import("react/jsx-runtime").JSX.Element | null;
|
|
132
159
|
//# sourceMappingURL=YAxis.d.ts.map
|