@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 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.55.0...HEAD
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
@@ -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
- baseline ?? domainFloor(yScale), curveFactory, gaps, gapConnectorOpacity, decimate),
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 { resolveYDomain } from './domain.js';
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.min === undefined || ax.max === undefined
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
- map.set(ax.id, scaleLinear().domain([lo, hi]).range([height, topHeader]));
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.ticks(yCount)).map((t) => gridY(t))
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.ticks(count) : []).map((t) => ({
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
- // The pixel extent is the two value extremes mapped through the (monotonic,
281
- // always-`scaleLinear`) y scale; min/max them so the result is flip-agnostic,
282
- // exactly as the former per-point pixel scan produced. [PND-GRADX] moved the
283
- // O(N) walk into the memoized {@link columnFiniteExtent}.
284
- const pa = yScale(valueExtent[0]);
285
- const pb = yScale(valueExtent[1]);
286
- const topPx = Math.min(pa, pb); // smallest pixel y (highest on screen)
287
- const bottomPx = Math.max(pa, pb); // largest pixel y (lowest on screen)
288
- // The drawn region runs from the topmost of {values, baseline} to the
289
- // bottommost the fill reaches the baseline, so include it.
290
- const regionTop = Math.min(topPx, baselinePx);
291
- const regionBottom = Math.max(bottomPx, baselinePx);
292
- if (regionBottom - regionTop < 1e-6)
293
- return style.fill; // degenerate height
289
+ // Stacked areas opt out of the grade entirely: a band that fades to
290
+ // transparent at the baseline shows every band beneath it (see AreaStyle).
291
+ if (style.flatFill === true)
292
+ return style.fill;
293
+ // The pixel extent is the two value extremes mapped through the (monotonic)
294
+ // y scale; min/max them so the result is flip-agnostic, exactly as the former
295
+ // per-point pixel scan produced. [PND-GRADX] moved the O(N) walk into the
296
+ // memoized {@link columnFiniteExtent}.
297
+ //
298
+ // **An extreme with no position on the scale is dropped**, not min/maxed in.
299
+ // `valueExtent` is the data's own `[min, max]`, and on a **log** axis a
300
+ // non-positive extreme — a series that touches zero, which is the ordinary
301
+ // shape of traffic or storage data — maps to `NaN`. `Math.min(NaN, pb)` is
302
+ // `NaN`, `NaN` propagates to the height, and `NaN < 1e-6` is **false**, so the
303
+ // degenerate guard below waved it through to `createLinearGradient(0, NaN, 0,
304
+ // NaN)` — which throws `IndexSizeError` on a real canvas and takes the whole
305
+ // chart down. The region is seeded from the baseline pixel (always in-domain,
306
+ // via `resolveAreaBaseline`) and widened only by extremes that have a
307
+ // position, so the grade still spans the part of the series that draws.
308
+ let regionTop = baselinePx;
309
+ let regionBottom = baselinePx;
310
+ const widen = (px) => {
311
+ if (!Number.isFinite(px))
312
+ return;
313
+ if (px < regionTop)
314
+ regionTop = px;
315
+ if (px > regionBottom)
316
+ regionBottom = px;
317
+ };
318
+ widen(yScale(valueExtent[0]));
319
+ widen(yScale(valueExtent[1]));
320
+ // `!(… >= 1e-6)` rather than `< 1e-6`, so a non-finite height — a baseline
321
+ // that somehow has no position either, leaving nothing finite to anchor on —
322
+ // falls back to the flat fill instead of reaching the gradient calls.
323
+ if (!(regionBottom - regionTop >= 1e-6))
324
+ return style.fill; // degenerate
294
325
  const opaque = style.fill;
295
326
  const transparent = withAlpha(style.fill, 0);
296
327
  const grad = ctx.createLinearGradient(0, regionTop, 0, regionBottom);
package/dist/band.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { area as d3area, curveLinear } from 'd3-shape';
2
2
  import { cullBandSeries } from './culling.js';
3
3
  import { decimateBand } from './decimate.js';
4
+ import { gapUnscalable } from './gaps.js';
4
5
  /**
5
6
  * The `[min, max]` vertical extent of the **drawn** band — the lowest `lower`
6
7
  * and highest `upper` over samples where both edges are finite — or `null` if
@@ -60,6 +61,18 @@ export function drawBand(ctx, band, xScale, yScale, style, curve = curveLinear,
60
61
  band = decimateBand(band, xScale, ctx, k);
61
62
  decimated = band !== before;
62
63
  }
64
+ // An edge with no position on the y scale becomes an ordinary NaN gap, so the
65
+ // envelope breaks there rather than emitting dropped path ops that stitch the
66
+ // neighbouring samples together. A `lower` of `0` is the common shape — a band
67
+ // measured from nothing — and on a log axis zero has no position, so without
68
+ // this the fill silently spanned the samples it could not draw. Gapping either
69
+ // edge gaps the sample, which is already the band's contract: a sample counts
70
+ // only where **both** edges do. A no-op on an affine (linear) y scale.
71
+ const gapLower = gapUnscalable(band.lower, band.length, yScale);
72
+ const gapUpper = gapUnscalable(band.upper, band.length, yScale);
73
+ if (gapLower !== band.lower || gapUpper !== band.upper) {
74
+ band = { ...band, lower: gapLower, upper: gapUpper };
75
+ }
63
76
  const gen = d3area()
64
77
  .defined((_, i) => Number.isFinite(band.lower[i]) && Number.isFinite(band.upper[i]))
65
78
  .x((_, i) => xScale(band.x[i]))
package/dist/bars.d.ts CHANGED
@@ -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 = 0;
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 = 0;
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];