@pond-ts/charts 0.62.0 → 0.63.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 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.62.0...HEAD
11
+ [Unreleased]: https://github.com/pond-ts/pond/compare/v0.63.0...HEAD
12
+ [0.63.0]: https://github.com/pond-ts/pond/compare/v0.62.0...v0.63.0
12
13
  [0.62.0]: https://github.com/pond-ts/pond/compare/v0.61.0...v0.62.0
13
14
  [0.61.0]: https://github.com/pond-ts/pond/compare/v0.60.0...v0.61.0
14
15
  [0.60.0]: https://github.com/pond-ts/pond/compare/v0.59.0...v0.60.0
@@ -65,6 +66,105 @@ include new features and type-level changes; patch bumps are strictly additive.
65
66
 
66
67
  ## [Unreleased]
67
68
 
69
+ ## [0.63.0] — 2026-08-18
70
+
71
+ ### Added
72
+
73
+ - `@pond-ts/charts`: **axis pan/zoom**, behind a new
74
+ **`<ChartContainer axisPanZoom>`** (`'none'` — the default — / `'x'` / `'y'` /
75
+ `'xy'`). Opted in, the `<XAxis>` strip and each `<YAxis>` gutter become
76
+ grabbable, and **double-click** puts one back (the declared `range` on x, its
77
+ fit on y). The cursor stays an ordinary arrow at rest and becomes directional
78
+ (`↕` / `↔`) only while a gesture is running.
79
+
80
+ **The opt-in is deliberately separate from `panZoom`, in both directions.** An
81
+ already-interactive chart does not grow axis gestures on upgrade — nothing
82
+ changes for any existing chart — and a chart can scale its y axes with
83
+ `panZoom` off entirely, which is what you want when the plot's drag belongs to
84
+ a selection sweep.
85
+
86
+ **The x strip is the canvas gesture, moved to the axis** — drag pans, wheel
87
+ zooms about the pointer — reusing the plot's own domain-space maths, so
88
+ `bounds` and `minDuration` still fence the view and a trading-time axis still
89
+ pans and floors in _trading_ time. A category axis has no continuous domain and
90
+ stays inert.
91
+
92
+ **A y gutter zooms, and only the axis you grabbed** — its sibling and every
93
+ other row hold still. That needed a new per-axis pixel transform
94
+ (`RowFrame.axisTransforms`, layered under the container's uniform
95
+ `yTransform`), since the uniform one exists precisely so a _plot_ gesture never
96
+ has to pick an axis; unlike it, this is not floored at `k ≥ 1`, because
97
+ squashing an axis you grabbed is the point.
98
+
99
+ - `@pond-ts/charts`: **`<YAxis onBoundsChange>`** — the auto-vs-manual hand-off
100
+ for a scaled y axis. Fires with the `[min, max]` a gutter gesture reached, and
101
+ with `null` when the axis is released back to auto-fit, so a UI can show the
102
+ bounds, badge the scale "manual", and offer a toggle back. Providing it makes
103
+ the axis **controlled** (the gesture only reports; `min`/`max` fed back are
104
+ what it draws), exactly as `onTimeRangeChange` does for the x view; omit it and
105
+ the axis holds the zoom itself. `ContainerFrame` also gains `seedRange` — the
106
+ declared view, as against the gestured `timeRange` — which is the x reset's
107
+ target.
108
+
109
+ - **charts: `<ChartContainer height>` + `<ChartRow flex>` — container-owned
110
+ vertical layout** ([PND-HEIGHT]). `height` takes a pixel number or `'auto'`
111
+ (measured by the same `ResizeObserver` as `width="auto"`); omitted stays the
112
+ classic mode where rows declare pixels. A managed container renders as a
113
+ **flex column** — the rows block flexes, the x-axis strip keeps its natural
114
+ height — and flex rows (a bare `<ChartRow>` is `flex={1}`) divide what the
115
+ browser says is left, then read that height back for their y-scales.
116
+
117
+ The design constraint was that **CSS does the subtraction**: the axis
118
+ strip's height depends on its `label`, the theme font size, the tick
119
+ ladder's calendar band row at the current grain, and stacked marker pills —
120
+ it is not a constant a caller can subtract, and every consumer who tried
121
+ carried a wrong one (`20` and `24` in one codebase; this site's own
122
+ resizable-panels recipe said `22`). It also means non-row children between
123
+ rows — the recipe's draggable splitter — keep taking their natural space,
124
+ so that recipe reduces to its drag handler: one flex row absorbing slack
125
+ over one fixed row the drag resizes, no `AXIS_H`, no measuring hook.
126
+
127
+ A flex row paints nothing until its first measurement, latches its last
128
+ non-zero height while hidden (a `display: none` tab switch keeps its
129
+ scales), and warns in dev when mounted in a container that never sizes it.
130
+ The container **warns in dev when a measured dimension stays 0** — the
131
+ unconstrained-parent deadlock, which for height is the _default_ (a
132
+ flex-column child's height is its content) rather than an edge case.
133
+
134
+ Fixed-`height` rows keep their pixels everywhere, managed or not; a
135
+ container with no `height` behaves exactly as before.
136
+
137
+ ### Fixed
138
+
139
+ - **charts: the crosshair's value pill lands on the axis that measured the
140
+ value, in that axis's colour.** With two y-axes on one side, the pill was
141
+ placed by side alone — always against the plot edge — so a reading taken off
142
+ the **outer** axis appeared over the **inner** axis's ticks, in the cursor's
143
+ grey: a number pinned to a ruler that never measured it. The reticle picks one
144
+ series, so the resolved sample now carries its axis's gutter **offset** (the
145
+ reserved widths of the columns between it and the plot) and its
146
+ `<YAxis color>`, and the pill uses both — position _and_ ink say which of two
147
+ stacked scales the number is on. A single axis per side is unchanged (offset
148
+ `0`), as is an axis that sets no colour (the theme's cursor ink).
149
+
150
+ A pill placed further out is now **bridged back to the plot edge** by a 1px
151
+ connector in its own colour — the y-side twin of the crosshair's x-axis time
152
+ connector, without which a pill a column out reads as a value floating in a
153
+ gutter. It draws at half opacity because, unlike the time connector's empty
154
+ strip, it crosses another axis's tick labels; nothing is drawn on the innermost
155
+ axis, where the pill already meets the line.
156
+
157
+ `<YAxis color>` is now part of the axis's registered spec rather than
158
+ presentation-only, because the pill is drawn by the row's cursor overlay, not
159
+ by `<YAxis>`. `<Baseline indicator>` — the other on-axis value pill — got the
160
+ same placement fix and the same connector. New `Cursors/Crosshair` stories fan
161
+ the states out (`AxisColor`, `StackedAxes`, `StackedAxesColored`,
162
+ `StackedAxesLeft`, `StackedAxesBothSides`).
163
+
164
+ Not covered: `<YAxisIndicator>` still takes an explicit `side` alongside its
165
+ `axis`, so it can be pointed at a gutter its axis isn't in; reconciling those
166
+ two props is a public-API question, left for its own change.
167
+
68
168
  ## [0.62.0] — 2026-08-16
69
169
 
70
170
  ### Added
@@ -294,6 +294,48 @@ export interface ChartContainerProps {
294
294
  * render.
295
295
  */
296
296
  width?: number | 'auto';
297
+ /**
298
+ * Total height in CSS pixels, or `'auto'` to fill the available height —
299
+ * **the container-owned vertical layout** ([PND-HEIGHT]). Omitted means the
300
+ * classic mode: rows declare pixel heights and the container's height is
301
+ * their sum.
302
+ *
303
+ * With a height, the container renders as a **flex column** — the rows
304
+ * block flexes, the x-axis strip keeps its natural height at the bottom —
305
+ * and `<ChartRow flex>` rows (a bare `<ChartRow>` is `flex={1}`) divide
306
+ * whatever the browser says is left. That "whatever the browser says" is
307
+ * the point: the axis strip's height depends on its `label`, the theme's
308
+ * font size, whether the tick ladder is showing its calendar band row at
309
+ * the current grain, and how many marker pills stack — it is not a constant
310
+ * a caller could subtract, and every consumer who tried carried a wrong
311
+ * number (20, 24, and the recipe's 22 were all in the wild for one strip).
312
+ * CSS does the subtraction, so there is no number to know.
313
+ *
314
+ * A single full-bleed chart is therefore zero arithmetic:
315
+ *
316
+ * ```tsx
317
+ * <ChartContainer width="auto" height="auto">
318
+ * <ChartRow>
319
+ * <YAxis id="v" />
320
+ * <Layers>…</Layers>
321
+ * </ChartRow>
322
+ * </ChartContainer>
323
+ * ```
324
+ *
325
+ * Fixed-`height` rows keep their pixels inside a managed container, and
326
+ * non-row children (a draggable splitter between two rows) take their
327
+ * natural space — so the resizable-panels shape becomes one `flex` row
328
+ * absorbing slack over one fixed row the drag resizes, with no reserved
329
+ * strip constant and no measuring hook.
330
+ *
331
+ * `'auto'` measures with the same `ResizeObserver` as `width="auto"`, gates
332
+ * the first paint until both needed dimensions exist, latches the last
333
+ * non-zero size while hidden, and — because a flex-**column** child's
334
+ * height defaults to its content — **warns in dev when a measured dimension
335
+ * stays 0**: the parent needs a definite height, or the deadlock is the
336
+ * default.
337
+ */
338
+ height?: number | 'auto';
297
339
  /** Vertical space between rows in CSS pixels (not under the axis). Default 0. */
298
340
  rowGap?: number;
299
341
  /**
@@ -438,8 +480,38 @@ export interface ChartContainerProps {
438
480
  * The boolean form is the back-compat shorthand (`true` ⇒ `'panZoom'`,
439
481
  * `false` ⇒ `'none'`). Bound the reachable range with {@link bounds}
440
482
  * (zoom-out / pan extent) and {@link minDuration} (zoom-in floor).
483
+ *
484
+ * **This prop is about the plot only.** Gestures on the axis strips are a
485
+ * separate opt-in — see {@link axisPanZoom} — so turning pan/zoom on here does
486
+ * not silently make the axes grabbable.
441
487
  */
442
488
  panZoom?: boolean | 'none' | 'pan' | 'panZoom' | 'panZoomX' | 'panZoomY' | 'panZoomXY';
489
+ /**
490
+ * Which **axis strips** take gestures — the opt-in for grabbing an axis, and
491
+ * **`'none'` by default** so no existing chart changes behaviour:
492
+ *
493
+ * - `'none'` (or `false`, the **default**) — the strips are inert chrome.
494
+ * - `'x'` — the `<XAxis>` strip **pans on drag and zooms on wheel**, exactly as
495
+ * the plot's own gestures do (same maths, same sign, same {@link bounds} /
496
+ * {@link minDuration} fences). Double-click returns to the declared
497
+ * {@link range}. A category axis has no continuous domain and stays inert.
498
+ * - `'y'` — each `<YAxis>` gutter **zooms that one axis** on drag or wheel,
499
+ * double-click releasing it back to its fit. Report it to a scale UI with
500
+ * {@link YAxisProps.onBoundsChange}.
501
+ * - `'xy'` (or `true`) — both.
502
+ *
503
+ * **Deliberately independent of {@link panZoom}**, in both directions. A chart
504
+ * can scale its y axes without letting the plot capture vertical drags (which
505
+ * would fight a selection sweep), and an interactive plot does not hand its
506
+ * axes gestures nobody asked for. The one thing they share is the view itself:
507
+ * the x strip moves the same range the plot's pan does, and reports through
508
+ * {@link onTimeRangeChange} the same way.
509
+ *
510
+ * The pairing to reach for on a time-series chart is
511
+ * `panZoom="panZoom" axisPanZoom="xy"` — drag the plot to pan, drag the x strip
512
+ * to pan, wheel either to zoom, and drag a y gutter to override its fit.
513
+ */
514
+ axisPanZoom?: boolean | 'none' | 'x' | 'y' | 'xy';
443
515
  /**
444
516
  * **Outer pan/zoom extent** — `[min, max]` (same units as {@link range}) the
445
517
  * view can never move outside. Panning into an edge stops there (the window
@@ -58,17 +58,24 @@ function normalizeRange(range) {
58
58
  * ChartContainerProps.width} and {@link AutoWidthContainer}.
59
59
  */
60
60
  export function ChartContainer(props) {
61
- const { width } = props;
61
+ const { width, height } = props;
62
62
  // The measure pass is a *different component* rather than a branch inside
63
63
  // the resolved one, because the resolved container may not render at all
64
- // until a width exists — and ~60 hooks cannot be conditional. Choosing the
65
- // component by the prop's kind (number vs auto) means flipping a container
66
- // between fixed and auto remounts it; that is a layout change, and a
67
- // remount is the honest response to one.
68
- if (typeof width === 'number') {
69
- return _jsx(ResolvedChartContainer, { ...props, width: width });
64
+ // until its dimensions exist — and ~60 hooks cannot be conditional. Choosing
65
+ // the component by the props' kinds (number vs auto) means flipping between
66
+ // fixed and auto remounts; that is a layout change, and a remount is the
67
+ // honest response to one.
68
+ //
69
+ // The dimensions default differently, deliberately: an omitted `width`
70
+ // means `'auto'` (a chart must have a width, and filling is the sensible
71
+ // way to get one), while an omitted `height` means *unmanaged* — the
72
+ // classic mode where rows declare pixel heights and the container is their
73
+ // sum. `'auto'` height is opt-in because it changes who answers "how tall
74
+ // is a row".
75
+ if (typeof width === 'number' && height !== 'auto') {
76
+ return _jsx(ResolvedChartContainer, { ...props, width: width, height: height });
70
77
  }
71
- return _jsx(AutoWidthContainer, { ...props });
78
+ return _jsx(AutoSizeContainer, { ...props });
72
79
  }
73
80
  /**
74
81
  * The `width="auto"` half: render a plain full-width box, measure it, and
@@ -88,24 +95,47 @@ export function ChartContainer(props) {
88
95
  * never overflow its own measurement. A caller who wants a bordered frame
89
96
  * puts it on a wrapper *outside* the container.
90
97
  */
91
- function AutoWidthContainer(props) {
98
+ function AutoSizeContainer(props) {
92
99
  const boxRef = useRef(null);
93
- const [measured, setMeasured] = useState(0);
100
+ const [measured, setMeasured] = useState({ width: 0, height: 0 });
101
+ // Which dimensions this instance is responsible for. A numeric width with
102
+ // height="auto" measures height only, and vice versa.
103
+ const needWidth = typeof props.width !== 'number';
104
+ const needHeight = props.height === 'auto';
105
+ // The needs, readable from the long-lived measure closure without going
106
+ // stale — `props.width` can legally flip number ↔ 'auto' without leaving
107
+ // this component (the dispatcher only remounts on the managed/unmanaged
108
+ // boundary).
109
+ const needsRef = useRef({ needWidth, needHeight });
110
+ needsRef.current = { needWidth, needHeight };
94
111
  useLayoutEffect(() => {
95
112
  const el = boxRef.current;
96
113
  if (el === null)
97
114
  return;
98
115
  const measure = () => setMeasured((prev) => {
99
- const next = Math.round(el.getBoundingClientRect().width);
100
- // **Latch the last non-zero width.** A box measures 0 whenever it is
101
- // not laid out most often because an ancestor went `display: none`
102
- // (a tab switch, a collapsed accordion), which is a *hidden* chart,
103
- // not a resized one. Writing that 0 through would unmount the resolved
104
- // container and discard everything it owns: pan/zoom position,
105
- // selection, hover, and every layer's memoized draw state, all
106
- // rebuilt on the way back. Keeping the stale width holds the chart
107
- // mounted through the hide, and the next real measurement corrects it.
108
- return next > 0 ? next : prev;
116
+ const need = needsRef.current;
117
+ const r = el.getBoundingClientRect();
118
+ // **Latch the last non-zero value, per dimension.** A box measures 0
119
+ // whenever it is not laid out most often because an ancestor went
120
+ // `display: none` (a tab switch, a collapsed accordion), which is a
121
+ // *hidden* chart, not a resized one. Writing that 0 through would
122
+ // unmount the resolved container and discard everything it owns:
123
+ // pan/zoom position, selection, hover, and every layer's memoized
124
+ // draw state, all rebuilt on the way back. Keeping the stale value
125
+ // holds the chart mounted through the hide, and the next real
126
+ // measurement corrects it.
127
+ const w = Math.round(r.width);
128
+ const h = Math.round(r.height);
129
+ // Track only the dimensions this instance is responsible for
130
+ // (Layer-2 review find): a width-only container that also stored
131
+ // height would re-render its whole tree on every *content*-height
132
+ // change — the classic splitter drag, an axis strip growing a band
133
+ // row — where the pre-[PND-HEIGHT] width-only measure bailed.
134
+ const width = need.needWidth && w > 0 ? w : prev.width;
135
+ const height = need.needHeight && h > 0 ? h : prev.height;
136
+ return width === prev.width && height === prev.height
137
+ ? prev
138
+ : { width, height };
109
139
  });
110
140
  measure();
111
141
  // Guarded rather than assumed: a non-browser render target (SSR, an older
@@ -117,10 +147,58 @@ function AutoWidthContainer(props) {
117
147
  ro.observe(el);
118
148
  return () => ro.disconnect();
119
149
  }, []);
120
- return (_jsx("div", { ref: boxRef, style: { width: '100%' }, children: measured > 0 && _jsx(ResolvedChartContainer, { ...props, width: measured }) }));
150
+ const width = needWidth ? measured.width : props.width;
151
+ const height = needHeight
152
+ ? measured.height
153
+ : props.height;
154
+ const ready = width > 0 && (!needHeight || measured.height > 0);
155
+ // **A measured dimension that stays 0 is a standing deadlock, not a slow
156
+ // start** — the parent's size is content-derived and the chart is the
157
+ // content that would have given it one, so nothing will ever paint and
158
+ // nothing errors. Worse for height than width: a flex-*column* child's
159
+ // height defaults to `auto`, so there the deadlock is the default, not an
160
+ // edge case. Say so once, in dev, after layout has had ample time.
161
+ const warnedZeroRef = useRef(false);
162
+ useEffect(() => {
163
+ if (!isDev || ready || warnedZeroRef.current)
164
+ return;
165
+ const t = setTimeout(() => {
166
+ if (ready || warnedZeroRef.current)
167
+ return;
168
+ const el = boxRef.current;
169
+ if (el === null)
170
+ return;
171
+ const r = el.getBoundingClientRect();
172
+ const stuck = [
173
+ ...(needWidth && Math.round(r.width) === 0 ? ['width'] : []),
174
+ ...(needHeight && Math.round(r.height) === 0 ? ['height'] : []),
175
+ ];
176
+ if (stuck.length === 0)
177
+ return;
178
+ warnedZeroRef.current = true;
179
+ console.warn(`[pond-charts] <ChartContainer> measured ${stuck.join(' and ')} of 0 ` +
180
+ `and it has not changed — the chart will stay blank. The measured ` +
181
+ `box fills its parent, so the parent needs a definite ` +
182
+ `${stuck.join('/')} (a sized ancestor, a flex basis, or ` +
183
+ `\`min-${stuck[0]}: 0\` on a flex child); a parent sized by its ` +
184
+ `own content deadlocks, because the chart is that content.`);
185
+ }, ZERO_SIZE_WARNING_MS);
186
+ return () => clearTimeout(t);
187
+ }, [ready, needWidth, needHeight]);
188
+ return (_jsx("div", { ref: boxRef, style: {
189
+ width: '100%',
190
+ // Only claim the parent's height when asked to measure it: a
191
+ // width-only auto container must keep its intrinsic height (the rows'
192
+ // sum), or every pre-[PND-HEIGHT] consumer's layout changes.
193
+ ...(needHeight ? { height: '100%', minHeight: 0 } : {}),
194
+ }, children: ready && (_jsx(ResolvedChartContainer, { ...props, width: width, height: height })) }));
121
195
  }
196
+ /** How long a measured dimension may stay 0 before the dev warning names the
197
+ * deadlock (see {@link AutoSizeContainer}). Long enough for any real layout
198
+ * pass; a chart legitimately gated this long is not painting anyway. */
199
+ const ZERO_SIZE_WARNING_MS = 600;
122
200
  /** {@link ChartContainer} with its width resolved to a concrete pixel number. */
123
- function ResolvedChartContainer({ range, categories: categoriesProp, maxBandWidth, bandAlign = 'start', width, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, onDrawStats, panZoom = false, bounds, onTimeRangeChange, minDuration = 1, cursor: cursorProp, cursorSequence: cursorSequenceProp, onRegionSelect, regionSelectModifier, cursorTime: cursorTimeProp, crosshairSnap: crosshairSnapProp, editAnnotations = false, creating = null, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap = true, timeFormat, cursorFormat: cursorFormatProp, origin, theme, discontinuities, calendar, spacing, xScale: xScaleKind = 'linear', grid = true, sessionDividers = 'none', children, }) {
201
+ function ResolvedChartContainer({ range, categories: categoriesProp, maxBandWidth, bandAlign = 'start', width, height, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, onDrawStats, panZoom = false, axisPanZoom = false, bounds, onTimeRangeChange, minDuration = 1, cursor: cursorProp, cursorSequence: cursorSequenceProp, onRegionSelect, regionSelectModifier, cursorTime: cursorTimeProp, crosshairSnap: crosshairSnapProp, editAnnotations = false, creating = null, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap = true, timeFormat, cursorFormat: cursorFormatProp, origin, theme, discontinuities, calendar, spacing, xScale: xScaleKind = 'linear', grid = true, sessionDividers = 'none', children, }) {
124
202
  // ── Legacy cursor props (deprecated) ───────────────────────────────────────
125
203
  // The string surface keeps working for one minor: the resolved mode is
126
204
  // synthesized into the equivalent mounted preset below (`<LegacyCursor>`),
@@ -128,6 +206,10 @@ function ResolvedChartContainer({ range, categories: categoriesProp, maxBandWidt
128
206
  // *explicitly* set (never on the defaults). Mounted cursor components in the
129
207
  // same scope override the shim. See docs/rfcs/interaction.md §9 / A4.4.
130
208
  const cursor = cursorProp ?? DEFAULT_CURSOR_MODE;
209
+ // [PND-HEIGHT] Whether this container owns vertical layout (see the
210
+ // `height` prop). Carried on the frame so a `<ChartRow flex>` can tell a
211
+ // home that can size it from one that never will.
212
+ const managesHeight = height !== undefined;
131
213
  // [PND-IGNITECAT] The declared slot list, normalized to `null` when absent
132
214
  // and held by **content** identity. An inline `categories={['a', 'b']}` is a
133
215
  // fresh array every render; keying the kind/scale memos off the raw prop
@@ -221,6 +303,11 @@ function ResolvedChartContainer({ range, categories: categoriesProp, maxBandWidt
221
303
  panZoom === 'panZoomX' ||
222
304
  panZoom === 'panZoomXY';
223
305
  const zoomY = panZoom === 'panZoomY' || panZoom === 'panZoomXY';
306
+ // Axis-strip gestures are their own opt-in (see `axisPanZoom`), so they are
307
+ // resolved from that prop alone — never from `panZoom`, which would make every
308
+ // already-interactive chart grow axis gestures on upgrade.
309
+ const axisPanZoomX = axisPanZoom === true || axisPanZoom === 'x' || axisPanZoom === 'xy';
310
+ const axisPanZoomY = axisPanZoom === true || axisPanZoom === 'y' || axisPanZoom === 'xy';
224
311
  const panX = zoomX || panZoom === 'pan';
225
312
  const panY = zoomY;
226
313
  const panEnabled = panX || panY;
@@ -244,7 +331,14 @@ function ResolvedChartContainer({ range, categories: categoriesProp, maxBandWidt
244
331
  const k = Math.max(1, next.k);
245
332
  setYTransform((prev) => prev.k === k && prev.ty === next.ty ? prev : { k, ty: next.ty });
246
333
  }, []);
247
- const interactive = panEnabled || zoomEnabled;
334
+ // The x **strip**'s gestures move the same view the plot's do, so they must
335
+ // make the container own a view as well. Leaving `axisPanZoomX` out of this
336
+ // silently broke the headline combination — `axisPanZoom="x"` with the default
337
+ // `panZoom="none"`: `applyRange` wrote `internalRange` while `view` kept
338
+ // reading `seed`, so an uncontrolled strip captured the drag and drew nothing.
339
+ // (`axisPanZoomY` is absent on purpose: a gutter zoom is per-axis row state,
340
+ // not the shared x view.)
341
+ const interactive = panEnabled || zoomEnabled || axisPanZoomX;
248
342
  // The explicit base domain from `range` (a tuple or a TimeRange). `undefined`
249
343
  // ⇒ auto-fit (resolved from the layers below). Pan/zoom seeds from it; `seed`
250
344
  // is the placeholder while auto-fitting.
@@ -1210,6 +1304,11 @@ function ResolvedChartContainer({ range, categories: categoriesProp, maxBandWidt
1210
1304
  // it lives in `cursorFrame` below, [PND-HOVCTX] — but the tuple stays a memo
1211
1305
  // to hold the line for every other rebuild path.)
1212
1306
  const timeRangeTuple = useMemo(() => [d0, d1], [d0, d1]);
1307
+ // The declared view (`range`), as against the gestured one above — the x
1308
+ // strip's double-click reset target. Memoized on its endpoints for the same
1309
+ // reason `timeRangeTuple` is: it sits on the frame, and a fresh tuple each
1310
+ // render would re-identify it for every draw callback that reads the frame.
1311
+ const seedRangeTuple = useMemo(() => [seed[0], seed[1]], [seed[0], seed[1]]);
1213
1312
  // The per-move cursor state, split into its own context so a mousemove
1214
1313
  // re-identifies only this small object — not the ~50-field frame below, which
1215
1314
  // stays stable across hovers so `YAxis` / `Bar` / `Box` don't re-render. See
@@ -1221,6 +1320,10 @@ function ResolvedChartContainer({ range, categories: categoriesProp, maxBandWidt
1221
1320
  }), [cursorX, hoverPoint]);
1222
1321
  const frame = useMemo(() => ({
1223
1322
  timeRange: timeRangeTuple,
1323
+ seedRange: seedRangeTuple,
1324
+ axisPanZoomX,
1325
+ axisPanZoomY,
1326
+ managesHeight,
1224
1327
  width,
1225
1328
  theme: theme ?? defaultTheme,
1226
1329
  plotWidth,
@@ -1302,6 +1405,10 @@ function ResolvedChartContainer({ range, categories: categoriesProp, maxBandWidt
1302
1405
  firstRowKey,
1303
1406
  }), [
1304
1407
  timeRangeTuple,
1408
+ seedRangeTuple,
1409
+ axisPanZoomX,
1410
+ axisPanZoomY,
1411
+ managesHeight,
1305
1412
  width,
1306
1413
  theme,
1307
1414
  plotWidth,
@@ -1385,13 +1492,33 @@ function ResolvedChartContainer({ range, categories: categoriesProp, maxBandWidt
1385
1492
  // The 'line' default nobody asked for is IMPLICIT — the one cursor a
1386
1493
  // <MultiSelector>'s resting block preview may replace with the
1387
1494
  // brush band. An explicit `cursor` prop (any mode) still wins.
1388
- implicit: cursorProp === undefined }), _jsxs("div", { style: { width: `${width}px` }, children: [_jsx("div", { style: {
1495
+ implicit: cursorProp === undefined }), _jsxs("div", { style: {
1496
+ width: `${width}px`,
1497
+ // [PND-HEIGHT] A managed height makes the outer box a flex
1498
+ // column: the rows block below flexes, the axis strip keeps its
1499
+ // natural height at the bottom, and CSS subtracts one from the
1500
+ // other. That subtraction being layout rather than arithmetic is
1501
+ // the feature — the strip's height varies with label, font size,
1502
+ // calendar bands and pill lanes, so no constant is correct.
1503
+ ...(height !== undefined
1504
+ ? {
1505
+ height: `${height}px`,
1506
+ display: 'flex',
1507
+ flexDirection: 'column',
1508
+ }
1509
+ : {}),
1510
+ }, children: [_jsx("div", { style: {
1389
1511
  display: 'flex',
1390
1512
  flexDirection: 'column',
1391
1513
  gap: `${rowGap}px`,
1392
1514
  // The positioned ancestor for overlay chrome (`<Legend>`): the
1393
1515
  // card anchors to the rows block, never the axis strip below.
1394
1516
  position: 'relative',
1517
+ // The rows block takes what the axis strip leaves. `minHeight:
1518
+ // 0` lets it shrink below its content — without it a flex
1519
+ // child's floor is its content and nothing can ever get
1520
+ // smaller.
1521
+ ...(height !== undefined ? { flex: '1 1 0%', minHeight: 0 } : {}),
1395
1522
  }, children: children }), showAxis && _jsx(TimeAxis, {})] })] }) }));
1396
1523
  }
1397
1524
  //# sourceMappingURL=ChartContainer.js.map
@@ -1,8 +1,44 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import { type CursorMode } from './context.js';
3
3
  export interface ChartRowProps {
4
- /** Row height in CSS pixels. */
5
- height: number;
4
+ /**
5
+ * Row height in CSS pixels — the **fixed** sizing mode. Omit it (or pass
6
+ * {@link flex}) to let the row share the container's remaining height
7
+ * instead; a bare `<ChartRow>` means `flex={1}`.
8
+ */
9
+ height?: number;
10
+ /**
11
+ * Share of the container's **remaining** height ([PND-HEIGHT]) — the
12
+ * CSS-flex sizing mode, and what an omitted `height` defaults to (`1`).
13
+ *
14
+ * The remainder is what CSS flex layout says it is: the container's height
15
+ * minus its axis strip, minus every fixed-`height` row, minus any non-row
16
+ * children you placed between rows (a draggable splitter), minus `rowGap`s.
17
+ * That is deliberate — the row's box is `flex: <n> 1 0`, so **the browser
18
+ * does the subtraction** and there is no strip-height constant for a caller
19
+ * to know, guess, or drift on (the reporting consumer had `20` and `24` in
20
+ * one codebase for a strip that is actually 22 — *when it is not showing a
21
+ * calendar band row or marker pills, which change it*). The row then reads
22
+ * back the height the layout gave it and builds its y-scales from that.
23
+ *
24
+ * Mixing modes is the point, not an edge case: a price row over a volume
25
+ * row is `<ChartRow flex={3}>` over `<ChartRow flex={1}>`; the splitter
26
+ * shape is one `flex` row that absorbs slack over one fixed row the drag
27
+ * resizes.
28
+ *
29
+ * **Needs a container that manages height** — `<ChartContainer
30
+ * height={number | 'auto'}>`. Inside a container with no height, a flex
31
+ * row's box has nothing to flex into, collapses to zero, and stays gated
32
+ * out; dev builds warn.
33
+ *
34
+ * A flex row's first useful paint waits for its first measurement — by
35
+ * **timing**, not a gate: the first render does execute children at height
36
+ * 0 (a 0-height canvas draws nothing), and the layout effect's synchronous
37
+ * setState delivers the real height before the browser paints. Like the
38
+ * container's `width="auto"`, it keeps its last non-zero height while
39
+ * hidden, so a `display: none` tab switch does not discard its scales.
40
+ */
41
+ flex?: number;
6
42
  /**
7
43
  * Cursor presentation for this row, overriding the container's default
8
44
  * ({@link ChartContainerProps.cursor}). Omit to inherit. See {@link CursorMode}.
@@ -32,5 +68,5 @@ export interface ChartRowProps {
32
68
  * Children lay out left-to-right in author order, so `<YAxis side="left"/>` goes
33
69
  * before `<Layers/>` and `<YAxis side="right"/>` after.
34
70
  */
35
- export declare function ChartRow({ height, cursor, children }: ChartRowProps): import("react/jsx-runtime").JSX.Element;
71
+ export declare function ChartRow({ height: heightProp, flex, cursor, children, }: ChartRowProps): import("react/jsx-runtime").JSX.Element;
36
72
  //# sourceMappingURL=ChartRow.d.ts.map