@pond-ts/charts 0.55.0 → 0.56.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +140 -1
- package/dist/AreaChart.d.ts +18 -0
- package/dist/AreaChart.js +24 -1
- package/dist/ChartRow.js +57 -6
- package/dist/Layers.js +14 -1
- package/dist/YAxis.d.ts +29 -1
- package/dist/YAxis.js +5 -2
- package/dist/area.js +46 -15
- package/dist/band.js +13 -0
- package/dist/bars.d.ts +16 -0
- package/dist/bars.js +22 -2
- package/dist/context.d.ts +18 -2
- package/dist/dev.d.ts +2 -0
- package/dist/dev.js +2 -0
- package/dist/domain.d.ts +54 -1
- package/dist/domain.js +195 -2
- package/dist/format.d.ts +20 -0
- package/dist/format.js +23 -10
- package/dist/gaps.d.ts +33 -0
- package/dist/gaps.js +49 -0
- package/dist/line.js +10 -1
- package/dist/theme.d.ts +12 -0
- package/dist/viewport.d.ts +9 -1
- package/dist/viewport.js +40 -4
- package/dist/yticks.d.ts +44 -0
- package/dist/yticks.js +55 -0
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -8,7 +8,10 @@ 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.56.2...HEAD
|
|
12
|
+
[0.56.2]: https://github.com/pond-ts/pond/compare/v0.56.1...v0.56.2
|
|
13
|
+
[0.56.1]: https://github.com/pond-ts/pond/compare/v0.56.0...v0.56.1
|
|
14
|
+
[0.56.0]: https://github.com/pond-ts/pond/compare/v0.55.0...v0.56.0
|
|
12
15
|
[0.55.0]: https://github.com/pond-ts/pond/compare/v0.54.0...v0.55.0
|
|
13
16
|
[0.54.0]: https://github.com/pond-ts/pond/compare/v0.53.1...v0.54.0
|
|
14
17
|
[0.53.1]: https://github.com/pond-ts/pond/compare/v0.53.0...v0.53.1
|
|
@@ -56,6 +59,142 @@ include new features and type-level changes; patch bumps are strictly additive.
|
|
|
56
59
|
|
|
57
60
|
## [Unreleased]
|
|
58
61
|
|
|
62
|
+
## [0.56.2] — 2026-08-05
|
|
63
|
+
|
|
64
|
+
### Fixed
|
|
65
|
+
|
|
66
|
+
- **charts (tests only, no shipped change):** the log-axis rendered-label test
|
|
67
|
+
is no longer an exact-set assertion over every digit-bearing node in the
|
|
68
|
+
render tree. It passed on Node 22 and failed on CI's Node 18 with one extra
|
|
69
|
+
element, blocking the publish twice. The discrepancy is **unreproduced and
|
|
70
|
+
still open** — recorded as `[PND-LOGTICK-N18]` in `PND_CHARTS_PLAN.md` with
|
|
71
|
+
everything measured about it. The assertion now checks that every chosen tick
|
|
72
|
+
renders in order, which is the wiring this test exists to cover; the tick
|
|
73
|
+
_selection_ it was really about is pinned deterministically by the
|
|
74
|
+
`yTickValues` unit tests.
|
|
75
|
+
|
|
76
|
+
## [0.56.1] — 2026-08-05
|
|
77
|
+
|
|
78
|
+
### Fixed
|
|
79
|
+
|
|
80
|
+
- **charts (tests only, no shipped change):** a log-axis test asserted on
|
|
81
|
+
rendered label _text_, parsing numbers back out of the DOM to infer scale
|
|
82
|
+
behaviour. It passed locally and failed in CI on a value it could not have
|
|
83
|
+
produced there, which blocked the v0.56.0 publish. The root cause was never
|
|
84
|
+
reproduced; rather than guess at it, the assertion now compares the rendered
|
|
85
|
+
labels against the ticks the axis is specified to draw, formatted through the
|
|
86
|
+
same formatter — deterministic regardless of locale, formatting or DOM
|
|
87
|
+
differences, and the numeric guarantee itself was already pinned directly by
|
|
88
|
+
the `yTickValues` unit tests. The published artifact is identical to what
|
|
89
|
+
v0.56.0 would have been.
|
|
90
|
+
|
|
91
|
+
## [0.56.0] — 2026-08-05
|
|
92
|
+
|
|
93
|
+
### Added
|
|
94
|
+
|
|
95
|
+
- **charts:** **`<YAxis scale="log">` — a base-10 logarithmic y axis.** Every y
|
|
96
|
+
scale was `scaleLinear`, so data spanning orders of magnitude was
|
|
97
|
+
undrawable: on a linear axis everything below the top decade collapses onto
|
|
98
|
+
the baseline. Set `scale="log"` and the axis maps by ratio, ticking the
|
|
99
|
+
decades. `format` still formats the **value**, so a readout says `1.2 PB`
|
|
100
|
+
rather than its logarithm — the transform is in the scale, not in the data,
|
|
101
|
+
which is what keeps it transparent to every draw layer, annotation and
|
|
102
|
+
cursor readout.
|
|
103
|
+
|
|
104
|
+
A log domain cannot contain zero, and d3 maps a non-positive value to
|
|
105
|
+
**`NaN`** — a coordinate the canvas silently _drops_, which is why every
|
|
106
|
+
consequence below is about something failing invisibly rather than throwing.
|
|
107
|
+
So the axis is deliberate about it:
|
|
108
|
+
- **Domain policy matches the linear axis exactly.** Auto-fit takes the
|
|
109
|
+
smallest **positive** extent (one zero sample can't collapse the axis, and
|
|
110
|
+
a `BarChart` — whose extent always widens to include zero — can still share
|
|
111
|
+
it); a positive explicit `min`/`max` is honoured verbatim and never
|
|
112
|
+
discarded, with the _auto-fit_ side moving if the domain would otherwise
|
|
113
|
+
invert; a fully auto-fit domain is `.nice()`d out to whole powers of ten, so
|
|
114
|
+
the extremes get headroom instead of sitting clipped on the plot edge. A
|
|
115
|
+
non-positive bound has no position and is refused in favour of the data.
|
|
116
|
+
`pad` is applied multiplicatively, adding the same fraction of a decade at
|
|
117
|
+
both ends.
|
|
118
|
+
- **A value with no position on the axis renders as a gap.** Previously the
|
|
119
|
+
gap test was `Number.isFinite(value)`, and `0` is finite — so the coordinate
|
|
120
|
+
became `NaN`, the canvas dropped the path op without breaking the path, and
|
|
121
|
+
the two neighbours were joined by a straight line _over_ the missing data.
|
|
122
|
+
Lines, area fills and outlines, and band envelopes now all break there.
|
|
123
|
+
- **Layers that reach for a baseline rest on the axis floor.** `AreaChart`
|
|
124
|
+
resolves an out-of-domain `baseline` there (writing `baseline={0}` is
|
|
125
|
+
natural and correct on a linear axis), and a **stacked** bar layer starts
|
|
126
|
+
its first segment there — starting at zero made the bottom segment of every
|
|
127
|
+
stack both invisible and unhittable. Unchanged on a linear axis, where zero
|
|
128
|
+
clamped into the domain _is_ zero.
|
|
129
|
+
- **The dev-mode warning names only unambiguous mistakes**: a refused
|
|
130
|
+
`min`/`max`, negative data, or an axis with no positive data at all. It
|
|
131
|
+
deliberately says nothing about an extent of exactly `[0, hi]`, which a
|
|
132
|
+
line touching zero and a bar layer on strictly positive data both report
|
|
133
|
+
identically — warning there fired on _every_ bar chart on a log axis. It
|
|
134
|
+
warns once per distinct complaint rather than on every repaint.
|
|
135
|
+
|
|
136
|
+
- **charts:** **pan and zoom now yield whole-millisecond view ranges.** A
|
|
137
|
+
wheel-zoom derives its range from pixel positions through `xScale.invert()`,
|
|
138
|
+
so the result was fractional by construction — an ordinary scroll produced
|
|
139
|
+
`1.7e12 + 0.37`. The epoch millisecond is this model's atomic unit and
|
|
140
|
+
consumers are entitled to assume it; one did, and a calendar `cursorSequence`
|
|
141
|
+
threw on a plain scroll. `zoomRange` / `panRange` round both ends, and never
|
|
142
|
+
collapse a positive span to zero width in doing so. (Core's fractional-instant
|
|
143
|
+
fix covers the same crash from the other side; this closes the class.)
|
|
144
|
+
|
|
145
|
+
- **charts:** **`AreaStyle.flatFill` — stacked areas that read as slabs.** An
|
|
146
|
+
area's fill has always graded to transparent at the baseline, which is right
|
|
147
|
+
for the elevation form and wrong for a stack: every band showed the one
|
|
148
|
+
beneath it through the fade, so a stacked area was not really drawable. Set
|
|
149
|
+
`flatFill` and the fill is flat; omitted, the gradient is unchanged, so no
|
|
150
|
+
existing theme shifts. The docs theme's `seq1…seq8` area roles set it, since
|
|
151
|
+
stacking is what they exist for.
|
|
152
|
+
|
|
153
|
+
- **docs theme:** **a sequential ramp — `seq1…seq8` — for charts with more
|
|
154
|
+
series than the categorical set has hues.** `--pond-viz-1…5` were, and
|
|
155
|
+
remain, the categorical set; a chart needing more slots (an eight-source
|
|
156
|
+
stack, a wall of climate stripes) now steps **tonally** through the brand
|
|
157
|
+
teal instead of introducing competing hues. Eight steps, evenly spaced
|
|
158
|
+
(~ΔL\* 9 in CIELAB), defined for light and dark, each mode's ramp containing
|
|
159
|
+
that mode's `--pond-viz-1` exactly. Exposed as `line` / `area` / `bar` theme
|
|
160
|
+
roles on `docsTheme` (Storybook) and the docs site's `useSiteChartTheme`,
|
|
161
|
+
and as an array from the site's `useSequentialRamp()`. Dev-only: the ramp
|
|
162
|
+
lives in the `docs-theme.fixture.ts` Storybook fixture and the website's
|
|
163
|
+
CSS, both excluded from the published `@pond-ts/charts` build — the library
|
|
164
|
+
still ships no palette.
|
|
165
|
+
|
|
166
|
+
### Fixed
|
|
167
|
+
|
|
168
|
+
- **core:** **a fractional epoch millisecond no longer crashes calendar
|
|
169
|
+
math.** `Temporal.Instant` refuses a non-integer epoch ms outright
|
|
170
|
+
(`epoch milliseconds must be an integer`), and `toPlainDateStart` passed
|
|
171
|
+
whatever it was given straight through — so realizing a `Sequence.calendar`
|
|
172
|
+
over a fractional range threw, and in a React app the exception unmounted the
|
|
173
|
+
page. A fraction is not a caller error: a chart's wheel-zoom derives its view
|
|
174
|
+
range from pixel positions via `xScale.invert()`, so an ordinary scroll
|
|
175
|
+
produces `1.7e12 + 0.37`. The instant is now floored to the millisecond
|
|
176
|
+
containing it — the epoch millisecond is this model's atomic unit and
|
|
177
|
+
calendar boundaries are themselves whole milliseconds, so the bucket
|
|
178
|
+
containing `t` and the one containing `t + 0.37` are necessarily the same,
|
|
179
|
+
and integer inputs are untouched. (`Math.floor`, not `Math.trunc`: pre-1970
|
|
180
|
+
they disagree, and `-5.5` lies inside the millisecond spanning `[-6, -5)`.)
|
|
181
|
+
|
|
182
|
+
- **charts:** toggling **`<ChartContainer grid>`** now repaints immediately.
|
|
183
|
+
`Layers`' draw callback read `container.grid` but didn't depend on it, so
|
|
184
|
+
switching gridlines off changed nothing until an unrelated dependency moved —
|
|
185
|
+
in practice you had to pan or zoom a little to force the update. The same
|
|
186
|
+
omission covered `sessionDividers` and `xKind`.
|
|
187
|
+
|
|
188
|
+
- **charts:** the log axis's dev-mode warning no longer requires **node's
|
|
189
|
+
ambient types**. It was guarded by a bare `process.env.NODE_ENV`, which
|
|
190
|
+
typechecks only when a tool happens to resolve `@types/node` from a parent
|
|
191
|
+
`node_modules` — so `tsc` inside the package passed while running the _same_
|
|
192
|
+
tsconfig from a consumer's directory failed with `TS2591: Cannot find name
|
|
193
|
+
'process'`. That took out the docs site's TypeDoc step, and would equally hit
|
|
194
|
+
any consumer typechecking the package's sources. The guard now lives in
|
|
195
|
+
`src/dev.ts` behind a local declaration and a `typeof` check, so a browser
|
|
196
|
+
bundle with no `process` global doesn't throw at import either.
|
|
197
|
+
|
|
59
198
|
## [0.55.0] — 2026-08-04
|
|
60
199
|
|
|
61
200
|
### Added
|
package/dist/AreaChart.d.ts
CHANGED
|
@@ -102,6 +102,24 @@ type AreaChartSource<S extends SeriesSchema = SeriesSchema, VS extends ValueSeri
|
|
|
102
102
|
};
|
|
103
103
|
/** `<AreaChart>`'s props: the shared knobs plus one series-kind source shape. */
|
|
104
104
|
export type AreaChartProps<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema> = AreaChartCommon<S, VS> & AreaChartSource<S, VS>;
|
|
105
|
+
/** Read a d3 linear scale's domain lower bound (the axis floor) from the plain
|
|
106
|
+
* `(value) => pixel` function the row hands to `draw`. The runtime object is a
|
|
107
|
+
* d3 `ScaleLinear` (it carries `.domain()`); the {@link RowLayer} type narrows
|
|
108
|
+
* it to the call signature, so this reads the bound through a localized,
|
|
109
|
+
* documented shape rather than widening `drawArea`'s contract to d3-scale. */
|
|
110
|
+
/**
|
|
111
|
+
* The area's baseline in **data** units: the caller's `baseline` when it has a
|
|
112
|
+
* finite position on this axis, else the axis floor.
|
|
113
|
+
*
|
|
114
|
+
* The fallback is not defensive padding — it's the log case. `baseline={0}` is
|
|
115
|
+
* the natural thing to write and is correct on a linear axis; on a log axis
|
|
116
|
+
* zero has no position at all — `scaleLog()(0)` is **`NaN`** (the `-Infinity`
|
|
117
|
+
* the log *transform* produces is then interpolated into the range, and
|
|
118
|
+
* `∞ − ∞` is what comes out) — and a single non-finite coordinate turns the
|
|
119
|
+
* whole filled path into nothing drawn at all. Resolving to the floor keeps the
|
|
120
|
+
* layer's meaning ("fill from the bottom") on both scale kinds.
|
|
121
|
+
*/
|
|
122
|
+
export declare function resolveAreaBaseline(baseline: number | undefined, yScale: (value: number) => number): number;
|
|
105
123
|
/**
|
|
106
124
|
* An area draw layer: fills between a value `column` and a `baseline`, with a
|
|
107
125
|
* graded (gradient) shade — opaque at the line, transparent at the baseline —
|
package/dist/AreaChart.js
CHANGED
|
@@ -12,6 +12,24 @@ import { useSlotKey } from './use-slot-key.js';
|
|
|
12
12
|
* d3 `ScaleLinear` (it carries `.domain()`); the {@link RowLayer} type narrows
|
|
13
13
|
* it to the call signature, so this reads the bound through a localized,
|
|
14
14
|
* documented shape rather than widening `drawArea`'s contract to d3-scale. */
|
|
15
|
+
/**
|
|
16
|
+
* The area's baseline in **data** units: the caller's `baseline` when it has a
|
|
17
|
+
* finite position on this axis, else the axis floor.
|
|
18
|
+
*
|
|
19
|
+
* The fallback is not defensive padding — it's the log case. `baseline={0}` is
|
|
20
|
+
* the natural thing to write and is correct on a linear axis; on a log axis
|
|
21
|
+
* zero has no position at all — `scaleLog()(0)` is **`NaN`** (the `-Infinity`
|
|
22
|
+
* the log *transform* produces is then interpolated into the range, and
|
|
23
|
+
* `∞ − ∞` is what comes out) — and a single non-finite coordinate turns the
|
|
24
|
+
* whole filled path into nothing drawn at all. Resolving to the floor keeps the
|
|
25
|
+
* layer's meaning ("fill from the bottom") on both scale kinds.
|
|
26
|
+
*/
|
|
27
|
+
export function resolveAreaBaseline(baseline, yScale) {
|
|
28
|
+
const floor = domainFloor(yScale);
|
|
29
|
+
if (baseline === undefined)
|
|
30
|
+
return floor;
|
|
31
|
+
return Number.isFinite(yScale(baseline)) ? baseline : floor;
|
|
32
|
+
}
|
|
15
33
|
function domainFloor(yScale) {
|
|
16
34
|
const d = yScale.domain?.();
|
|
17
35
|
return d && d.length > 0 ? d[0] : 0;
|
|
@@ -137,7 +155,12 @@ export function AreaChart({ series, column, readout, as: semantic, axis, baselin
|
|
|
137
155
|
// Omitted baseline rests on the axis floor (resolved late from the
|
|
138
156
|
// scale, so it tracks the auto-fit domain); a fixed baseline is used
|
|
139
157
|
// verbatim.
|
|
140
|
-
|
|
158
|
+
// A log axis has no position for zero — or anything at or below
|
|
159
|
+
// it — so an explicit out-of-domain `baseline` would scale to
|
|
160
|
+
// `NaN` and poison every coordinate in the fill path. Fall back
|
|
161
|
+
// to the axis floor, which is exactly what an omitted baseline
|
|
162
|
+
// already resolves to.
|
|
163
|
+
resolveAreaBaseline(baseline, yScale), curveFactory, gaps, gapConnectorOpacity, decimate),
|
|
141
164
|
},
|
|
142
165
|
axisId: axis,
|
|
143
166
|
index,
|
package/dist/ChartRow.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Children, cloneElement, isValidElement, useCallback, useContext, useEffect, useMemo, useState, } from 'react';
|
|
3
|
-
import { scaleLinear } from 'd3-scale';
|
|
4
|
-
import {
|
|
2
|
+
import { Children, cloneElement, isValidElement, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
|
|
3
|
+
import { scaleLinear, scaleLog } from 'd3-scale';
|
|
4
|
+
import { isDev } from './dev.js';
|
|
5
|
+
import { logAxisWarning, needsExtents, resolveYDomain } from './domain.js';
|
|
5
6
|
import { resolveAxisFormat } from './format.js';
|
|
6
7
|
import { resolveYTickCount } from './yticks.js';
|
|
7
8
|
import { placeAxisSlots } from './slots.js';
|
|
@@ -39,6 +40,7 @@ function axisSpecEqual(a, b) {
|
|
|
39
40
|
return (a.id === b.id &&
|
|
40
41
|
a.side === b.side &&
|
|
41
42
|
a.width === b.width &&
|
|
43
|
+
a.scale === b.scale &&
|
|
42
44
|
// Object.is (not ===) so a degenerate NaN bound compares equal to itself and
|
|
43
45
|
// doesn't re-register every render.
|
|
44
46
|
Object.is(a.min, b.min) &&
|
|
@@ -171,6 +173,7 @@ export function ChartRow({ height, cursor, children }) {
|
|
|
171
173
|
id: IMPLICIT_AXIS_ID,
|
|
172
174
|
side: 'left',
|
|
173
175
|
width: 0,
|
|
176
|
+
scale: 'linear',
|
|
174
177
|
min: undefined,
|
|
175
178
|
max: undefined,
|
|
176
179
|
pad: 0,
|
|
@@ -222,20 +225,68 @@ export function ChartRow({ height, cursor, children }) {
|
|
|
222
225
|
const yScales = useMemo(() => {
|
|
223
226
|
const map = new Map();
|
|
224
227
|
for (const ax of effectiveAxes) {
|
|
225
|
-
const extents = ax
|
|
228
|
+
const extents = needsExtents(ax)
|
|
226
229
|
? layerList
|
|
227
230
|
.filter((entry) => (entry.axisId ?? defaultAxisId) === ax.id)
|
|
228
231
|
.map((entry) => entry.layer.yExtent())
|
|
229
232
|
: [];
|
|
230
|
-
const [lo, hi] = resolveYDomain(ax.min, ax.max, extents, ax.pad);
|
|
233
|
+
const [lo, hi] = resolveYDomain(ax.min, ax.max, extents, ax.pad, ax.scale);
|
|
231
234
|
// Reserve a header band at the top when any axis draws a `'top'` title,
|
|
232
235
|
// so the title clears the top tick + plot (the whole row shifts down
|
|
233
236
|
// uniformly, keeping stacked axes aligned). No top titles ⇒ range top 0,
|
|
234
237
|
// so nothing changes for existing charts.
|
|
235
|
-
|
|
238
|
+
// `scaleLog` and `scaleLinear` share the call/ticks/tickFormat/invert
|
|
239
|
+
// surface every consumer uses (see `YScale`), so choosing between them
|
|
240
|
+
// here is the whole of log support — no draw layer branches on it.
|
|
241
|
+
const base = ax.scale === 'log' ? scaleLog() : scaleLinear();
|
|
242
|
+
map.set(ax.id, base.domain([lo, hi]).range([height, topHeader]));
|
|
236
243
|
}
|
|
237
244
|
return map;
|
|
238
245
|
}, [effectiveAxes, layerList, height, defaultAxisId, topHeader]);
|
|
246
|
+
// Dev-mode diagnostics for a `scale="log"` axis (see `logAxisWarning`). Three
|
|
247
|
+
// things about *where* this sits are load-bearing, each of them a bug the
|
|
248
|
+
// first version shipped:
|
|
249
|
+
//
|
|
250
|
+
// - **An effect, not the scale memo.** Warning from inside `useMemo` is a
|
|
251
|
+
// side effect in a function React may call speculatively — and does call
|
|
252
|
+
// twice under StrictMode.
|
|
253
|
+
// - **Deduplicated by message, in a ref.** The comment on the original said
|
|
254
|
+
// "warn once per offending axis" and nothing implemented it, so a live
|
|
255
|
+
// chart re-warned on every appended sample. Keying on the message (not a
|
|
256
|
+
// bare "already warned" flag) still reports a *different* complaint if the
|
|
257
|
+
// data changes shape.
|
|
258
|
+
// - **`height` is not a dependency.** It is one for the scales, which is why
|
|
259
|
+
// the warning must not ride along: a drag-resize would otherwise emit a
|
|
260
|
+
// line per animation frame.
|
|
261
|
+
//
|
|
262
|
+
// Gated on `isDev` **and** on some axis actually being logarithmic, so a
|
|
263
|
+
// production build and every linear chart skip the extent walk entirely.
|
|
264
|
+
const warnedRef = useRef(new Map());
|
|
265
|
+
useEffect(() => {
|
|
266
|
+
if (!isDev || !effectiveAxes.some((ax) => ax.scale === 'log'))
|
|
267
|
+
return;
|
|
268
|
+
const warned = warnedRef.current;
|
|
269
|
+
for (const ax of effectiveAxes) {
|
|
270
|
+
// A linear axis in the same row has nothing to say and must not pay the
|
|
271
|
+
// O(points) walk below just because a sibling is logarithmic.
|
|
272
|
+
if (ax.scale !== 'log')
|
|
273
|
+
continue;
|
|
274
|
+
// Always walk the extents here, even for a fully-explicit domain the
|
|
275
|
+
// scale memo skips them for: data that cannot be drawn is worth saying so
|
|
276
|
+
// about whether or not it happened to constrain the bounds — and the
|
|
277
|
+
// both-explicit axis was exactly the case the first version stayed silent
|
|
278
|
+
// about.
|
|
279
|
+
const message = logAxisWarning(ax, layerList
|
|
280
|
+
.filter((entry) => (entry.axisId ?? defaultAxisId) === ax.id)
|
|
281
|
+
.map((entry) => entry.layer.yExtent()));
|
|
282
|
+
if (message === null)
|
|
283
|
+
warned.delete(ax.id);
|
|
284
|
+
else if (warned.get(ax.id) !== message) {
|
|
285
|
+
warned.set(ax.id, message);
|
|
286
|
+
console.warn(message);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}, [effectiveAxes, layerList, defaultAxisId]);
|
|
239
290
|
// Resolved auto-tick count per axis — explicit `<YAxis tickCount>` else
|
|
240
291
|
// height-derived (see resolveYTickCount). The single source the `<YAxis>`
|
|
241
292
|
// labels, the readout formatter (below), and the `Layers` gridlines all read,
|
package/dist/Layers.js
CHANGED
|
@@ -5,6 +5,7 @@ import { drawGrid, drawDividers, dividerAlphas, thinPixels } from './grid.js';
|
|
|
5
5
|
import { cursorParts, bandRect, regionSpan } from './tracker.js';
|
|
6
6
|
import { resolveSelection } from './select.js';
|
|
7
7
|
import { panRange, zoomRange, panRangeTrading, zoomRangeTrading, } from './viewport.js';
|
|
8
|
+
import { yTickValues } from './yticks.js';
|
|
8
9
|
import { flagChipStyle, flagChipX, axisPillX, axisPillStyle } from './chip.js';
|
|
9
10
|
import { ContainerContext, CursorContext, LayersContext, RowContext, } from './context.js';
|
|
10
11
|
/** Fallback **y**-gridline tick count, used only before the row publishes its
|
|
@@ -116,7 +117,7 @@ export function Layers({ children }) {
|
|
|
116
117
|
// a gridline sits under every `<YAxis>` label and no more.
|
|
117
118
|
const yCount = tickCounts.get(defaultAxisId) ?? GRID_TICKS;
|
|
118
119
|
const yTicks = gridY && !(yIsCategory && explicitY === undefined)
|
|
119
|
-
? (explicitY ?? gridY
|
|
120
|
+
? (explicitY ?? yTickValues(gridY, yCount)).map((t) => gridY(t))
|
|
120
121
|
: [];
|
|
121
122
|
// On a calendar axis the verticals are the FULL grain populations —
|
|
122
123
|
// every day in the month, every month in the year, every aligned
|
|
@@ -267,6 +268,18 @@ export function Layers({ children }) {
|
|
|
267
268
|
// a full replot per mousemove.
|
|
268
269
|
container.timeRange,
|
|
269
270
|
container.reportDrawStats,
|
|
271
|
+
// Read inside `draw`, so they have to invalidate it. Omitting `grid` meant
|
|
272
|
+
// toggling `<ChartContainer grid>` changed nothing until some *other*
|
|
273
|
+
// dep moved — pan the plot a pixel and the gridlines you switched off
|
|
274
|
+
// finally vanished. All primitives, so no per-frame identity churn.
|
|
275
|
+
container.grid,
|
|
276
|
+
container.sessionDividers,
|
|
277
|
+
container.xKind,
|
|
278
|
+
// `container.theme` is read too. It is deliberately NOT listed: it is the
|
|
279
|
+
// caller's prop and an inline object literal would rebuild `draw` every
|
|
280
|
+
// render (a full replot per frame). The values `draw` actually takes from
|
|
281
|
+
// it — `background`, `gridColor`, `gridDash` — are extracted above and
|
|
282
|
+
// listed individually, so a theme swap still invalidates.
|
|
270
283
|
row.rowKey,
|
|
271
284
|
]);
|
|
272
285
|
// Interaction overlay: the cursor marks live on a DOM/SVG overlay above the
|
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,
|
|
@@ -87,7 +90,7 @@ export function YAxis({ id, side = 'left', label, min, max, format, ticks, tickC
|
|
|
87
90
|
? ticks.map((t) => ({ value: t.at, label: t.label }))
|
|
88
91
|
: layerCategories !== null
|
|
89
92
|
? layerCategories.map((label, i) => ({ value: i + 0.5, label }))
|
|
90
|
-
: (yScale ? yScale
|
|
93
|
+
: (yScale ? yTickValues(yScale, count) : []).map((t) => ({
|
|
91
94
|
value: t,
|
|
92
95
|
label: fmt(t),
|
|
93
96
|
}));
|
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
|
-
//
|
|
281
|
-
//
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
@@ -251,6 +251,22 @@ export declare function stackValueExtent(ss: StackedBarSeries): [number, number]
|
|
|
251
251
|
* the x auto-fit for a vertical histogram, the y auto-fit for a horizontal one.
|
|
252
252
|
*/
|
|
253
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;
|
|
254
270
|
/**
|
|
255
271
|
* The pixel rect `[x0, x1, yTop, yBottom]` (ascending on both axes) of bin `b`'s
|
|
256
272
|
* segment `g`, stacked so it sits atop `cumBefore` (the summed value of the
|
package/dist/bars.js
CHANGED
|
@@ -435,6 +435,24 @@ export function stackBinExtent(ss) {
|
|
|
435
435
|
return null;
|
|
436
436
|
return [ss.begin[0], ss.end[ss.length - 1]];
|
|
437
437
|
}
|
|
438
|
+
/**
|
|
439
|
+
* The value a stack's **first** segment rests on, in data units — the same
|
|
440
|
+
* `0`-clamped-into-the-domain rule {@link resolveBarBaseline} applies to a plain
|
|
441
|
+
* bar, read off whichever scale carries the stacked value (`yScale` when the
|
|
442
|
+
* bars grow up, `xScale` when they grow right).
|
|
443
|
+
*
|
|
444
|
+
* Both stack walks used to start at a literal `0`, which is right only while the
|
|
445
|
+
* domain contains zero — and a **log** domain never can. `yScale(0)` on a log
|
|
446
|
+
* scale is `NaN`, `fillRect` with a `NaN` argument is a silent canvas no-op, and
|
|
447
|
+
* the same rect feeds {@link stackAt} — so the bottom segment of every stack
|
|
448
|
+
* both vanished *and* became unhittable, with nothing to see but a stack that
|
|
449
|
+
* starts one segment up. The linear case is unaffected: the value extents pull
|
|
450
|
+
* `0` into the domain, so this returns exactly `0` and the geometry is
|
|
451
|
+
* unchanged.
|
|
452
|
+
*/
|
|
453
|
+
export function stackBase(orientation, xScale, yScale) {
|
|
454
|
+
return resolveBarBaseline(orientation === 'vertical' ? yScale : xScale);
|
|
455
|
+
}
|
|
438
456
|
/**
|
|
439
457
|
* The pixel rect `[x0, x1, yTop, yBottom]` (ascending on both axes) of bin `b`'s
|
|
440
458
|
* segment `g`, stacked so it sits atop `cumBefore` (the summed value of the
|
|
@@ -491,10 +509,11 @@ export function segmentRect(ss, b, g, orientation, xScale, yScale, cumBefore, ga
|
|
|
491
509
|
*/
|
|
492
510
|
export function drawStacks(ctx, ss, orientation, xScale, yScale, style, gapPx, minSpanPx, seriesId, selection, hover) {
|
|
493
511
|
const G = ss.groups.length;
|
|
512
|
+
const base = stackBase(orientation, xScale, yScale);
|
|
494
513
|
ctx.save();
|
|
495
514
|
ctx.globalAlpha = style.opacity;
|
|
496
515
|
for (let b = 0; b < ss.length; b += 1) {
|
|
497
|
-
let cum =
|
|
516
|
+
let cum = base;
|
|
498
517
|
for (let g = 0; g < G; g += 1) {
|
|
499
518
|
const rect = segmentRect(ss, b, g, orientation, xScale, yScale, cum, gapPx, minSpanPx);
|
|
500
519
|
const v = ss.values[b * G + g];
|
|
@@ -543,8 +562,9 @@ export function drawStacks(ctx, ss, orientation, xScale, yScale, style, gapPx, m
|
|
|
543
562
|
*/
|
|
544
563
|
export function stackAt(ss, px, py, orientation, xScale, yScale, gapPx, minSpanPx) {
|
|
545
564
|
const G = ss.groups.length;
|
|
565
|
+
const base = stackBase(orientation, xScale, yScale);
|
|
546
566
|
for (let b = 0; b < ss.length; b += 1) {
|
|
547
|
-
let cum =
|
|
567
|
+
let cum = base;
|
|
548
568
|
for (let g = 0; g < G; g += 1) {
|
|
549
569
|
const rect = segmentRect(ss, b, g, orientation, xScale, yScale, cum, gapPx, minSpanPx);
|
|
550
570
|
const v = ss.values[b * G + g];
|