@pond-ts/charts 0.62.0 → 0.64.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.
@@ -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
package/dist/ChartRow.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Children, Fragment, isValidElement, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
2
+ import { Children, Fragment, isValidElement, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
3
3
  import { scaleLinear, scaleLog, scaleSymlog } from 'd3-scale';
4
4
  import { isDev } from './dev.js';
5
5
  import { useIndexedChildren } from './child-index.js';
@@ -90,6 +90,11 @@ function axisSpecEqual(a, b) {
90
90
  a.labelPlacement === b.labelPlacement &&
91
91
  a.index === b.index &&
92
92
  a.tickCount === b.tickCount &&
93
+ // The axis-edge chrome drawn *by the row* wears this (the crosshair's value
94
+ // pill), so a swallowed colour change would leave the pill on the old ink
95
+ // while the axis's own labels repainted — the same silent-staleness the
96
+ // `linearWindow` note above warns about.
97
+ a.color === b.color &&
93
98
  Object.is(a.format, b.format) &&
94
99
  numberArraysEqual(a.tickValues, b.tickValues));
95
100
  }
@@ -134,11 +139,73 @@ const TOP_LABEL_HEADER = 16;
134
139
  * Children lay out left-to-right in author order, so `<YAxis side="left"/>` goes
135
140
  * before `<Layers/>` and `<YAxis side="right"/>` after.
136
141
  */
137
- export function ChartRow({ height, cursor, children }) {
142
+ export function ChartRow({ height: heightProp, flex, cursor, children, }) {
138
143
  const container = useContext(ContainerContext);
139
144
  if (container === null) {
140
145
  throw new Error('<ChartRow> must be rendered inside a <ChartContainer>');
141
146
  }
147
+ // ── Sizing mode ([PND-HEIGHT]) ─────────────────────────────────────────────
148
+ // Fixed (`height` in px) or flex (`flex`, the default when neither is
149
+ // given). A flex row's box is sized by CSS (`flex: <n> 1 0` inside the
150
+ // container's column), and the row reads back what layout gave it — the
151
+ // browser subtracts the axis strip, fixed siblings, splitters and row gaps,
152
+ // so no caller (and no code here) ever knows what those cost.
153
+ const isFlex = heightProp === undefined;
154
+ const flexGrow = flex ?? 1;
155
+ const warnedSizingRef = useRef(false);
156
+ useEffect(() => {
157
+ if (!isDev || warnedSizingRef.current)
158
+ return;
159
+ if (heightProp !== undefined && flex !== undefined) {
160
+ // Both given is a contradiction, not a tiebreak: warn and honour
161
+ // `height` (the long-standing prop) so the chart renders
162
+ // deterministically.
163
+ warnedSizingRef.current = true;
164
+ console.warn(`[pond-charts] <ChartRow> got both height={${heightProp}} and ` +
165
+ `flex={${flex}} — they are alternative sizing modes. Using ` +
166
+ `height; drop one.`);
167
+ }
168
+ }, [heightProp, flex]);
169
+ const boxRef = useRef(null);
170
+ const [measured, setMeasured] = useState(0);
171
+ useLayoutEffect(() => {
172
+ if (!isFlex)
173
+ return;
174
+ const el = boxRef.current;
175
+ if (el === null)
176
+ return;
177
+ const measure = () => setMeasured((prev) => {
178
+ const next = Math.round(el.getBoundingClientRect().height);
179
+ // Latch the last non-zero height — a hidden row (display: none
180
+ // ancestor) is not a resized one, and writing 0 through would tear
181
+ // down its scales. Same rule as the container's width="auto".
182
+ return next > 0 ? next : prev;
183
+ });
184
+ measure();
185
+ if (typeof ResizeObserver === 'undefined')
186
+ return;
187
+ const ro = new ResizeObserver(measure);
188
+ ro.observe(el);
189
+ return () => ro.disconnect();
190
+ }, [isFlex]);
191
+ // A flex row inside a container that doesn't manage height has nothing to
192
+ // flex into: its box is 0 and stays 0, silently. That is a wiring error the
193
+ // consumer should hear about now, not a slow start to wait through.
194
+ const warnedNoHeightRef = useRef(false);
195
+ useEffect(() => {
196
+ if (!isDev ||
197
+ !isFlex ||
198
+ container.managesHeight ||
199
+ warnedNoHeightRef.current) {
200
+ return;
201
+ }
202
+ warnedNoHeightRef.current = true;
203
+ console.warn(`[pond-charts] <ChartRow flex> needs a container that manages height ` +
204
+ `— give <ChartContainer> a height ({number | 'auto'}). Inside a ` +
205
+ `container with no height this row's box collapses to 0 and never ` +
206
+ `paints.`);
207
+ }, [isFlex, container.managesHeight]);
208
+ const height = isFlex ? measured : heightProp;
142
209
  // Register on mount so the container can mark the first (topmost) row by
143
210
  // mount order — the shared cursor-time chip renders there only.
144
211
  const rowKey = useSlotKey();
@@ -165,6 +232,33 @@ export function ChartRow({ height, cursor, children }) {
165
232
  // min/max or series change silently rebind axes / reorder the z-stack.)
166
233
  const [axes, setAxes] = useState(() => new Map());
167
234
  const [layers, setLayers] = useState(() => new Map());
235
+ // Per-axis pixel zoom — a drag on one gutter (see `RowFrame.axisTransforms`).
236
+ // Empty until an axis is actually grabbed, so a chart with no axis gestures
237
+ // carries no extra state and the identity branch below skips the work.
238
+ // Filled by the scale memo below (see `baseYScales` on the frame).
239
+ const baseRef = useRef(new Map());
240
+ const [axisTransforms, setAxisTransforms] = useState(() => new Map());
241
+ const applyAxisTransform = useCallback((id, next) => {
242
+ setAxisTransforms((prev) => {
243
+ const cur = prev.get(id);
244
+ if (cur !== undefined && cur.k === next.k && cur.ty === next.ty) {
245
+ return prev; // no-op: don't re-render (a wheel notch at a clamp)
246
+ }
247
+ const map = new Map(prev);
248
+ // Identity is the absence of a transform, not an entry recording one —
249
+ // so the reset genuinely returns the axis to the un-grabbed state and
250
+ // the scale memo's fast path applies again.
251
+ if (next.k === 1 && next.ty === 0) {
252
+ if (cur === undefined)
253
+ return prev;
254
+ map.delete(id);
255
+ }
256
+ else {
257
+ map.set(id, next);
258
+ }
259
+ return map;
260
+ });
261
+ }, []);
168
262
  // Registration is idempotent under value-equality: a `<YAxis>` re-fires its
169
263
  // register effect whenever its `spec` memo yields a fresh object — which an
170
264
  // inline `ticks={[]}` / `format` or a re-rendered parent does every render. If
@@ -234,6 +328,7 @@ export function ChartRow({ height, cursor, children }) {
234
328
  format: undefined,
235
329
  tickValues: undefined,
236
330
  tickCount: undefined,
331
+ color: undefined,
237
332
  index: 0,
238
333
  },
239
334
  ], [realAxes]);
@@ -278,6 +373,7 @@ export function ChartRow({ height, cursor, children }) {
278
373
  const { k: yk, ty: yty } = container.yTransform;
279
374
  const yScales = useMemo(() => {
280
375
  const map = new Map();
376
+ const bases = new Map();
281
377
  for (const ax of effectiveAxes) {
282
378
  const extents = needsExtents(ax)
283
379
  ? layerList
@@ -315,14 +411,43 @@ export function ChartRow({ height, cursor, children }) {
315
411
  // each other in the first cut. Narrowing the domain means ticks, padding
316
412
  // and every downstream reader see an ordinary axis over the visible
317
413
  // window, and none of them need to know a transform exists.
318
- if (yk !== 1 || yty !== 0) {
319
- const at = (px) => +s.invert((px - yty) / yk);
414
+ //
415
+ // The **per-axis** transform (an axis-gutter drag) is applied the same
416
+ // way, immediately after — so a doubly-transformed axis is still just an
417
+ // ordinary axis over the doubly-narrowed window. Sequential rather than
418
+ // pre-composed on purpose: each step inverts through the scale it is
419
+ // actually narrowing, which is what keeps it correct on a log / symlog
420
+ // axis, where pixel→value is not affine and two composed `k`s would not
421
+ // land where two applications do.
422
+ const narrow = (k, ty) => {
423
+ const at = (px) => +s.invert((px - ty) / k);
320
424
  s.domain([at(height), at(topHeader)]);
425
+ };
426
+ // Snapshot before either transform: `baseYScales` is the domain the axis
427
+ // RESOLVED to (bounds + pad + nice), which is the space a controlled
428
+ // consumer's `min`/`max` live in. Reading the transformed scale instead and
429
+ // handing those values back re-applies the transform on top of them.
430
+ bases.set(ax.id, s.copy());
431
+ if (yk !== 1 || yty !== 0)
432
+ narrow(yk, yty);
433
+ const own = axisTransforms.get(ax.id);
434
+ if (own !== undefined && (own.k !== 1 || own.ty !== 0)) {
435
+ narrow(own.k, own.ty);
321
436
  }
322
437
  map.set(ax.id, s);
323
438
  }
439
+ baseRef.current = bases;
324
440
  return map;
325
- }, [effectiveAxes, layerList, height, defaultAxisId, topHeader, yk, yty]);
441
+ }, [
442
+ effectiveAxes,
443
+ layerList,
444
+ height,
445
+ defaultAxisId,
446
+ topHeader,
447
+ yk,
448
+ yty,
449
+ axisTransforms,
450
+ ]);
326
451
  // Dev-mode diagnostics for a `scale="log"` axis (see `logAxisWarning`). Three
327
452
  // things about *where* this sits are load-bearing, each of them a bug the
328
453
  // first version shipped:
@@ -451,6 +576,53 @@ export function ChartRow({ height, cursor, children }) {
451
576
  map.set(ax.id, ax.side);
452
577
  return map;
453
578
  }, [effectiveAxes]);
579
+ // How far out in its gutter each axis sits: the px from the plot's edge to the
580
+ // axis's inner edge, walking each side plot-outward and accumulating the
581
+ // *reserved* slot widths (what the axis boxes actually render at, so the
582
+ // offset lands on the axis and not between two of them). Side alone would put
583
+ // every pill on the innermost axis — the wrong scale as soon as a side carries
584
+ // two (see RowFrame.axisOffsets). Right axes are authored inner→outer, left
585
+ // axes outer→inner, so the left list walks in reverse.
586
+ //
587
+ // Resolved per **instance** first, then collapsed to ids by the same
588
+ // last-declared-wins rule `axisSides` uses — so for a mirrored id (one scale
589
+ // registered on both sides, or a duplicate) the side and the offset always
590
+ // come from the *same* axis. A different rule per map (say, keeping the
591
+ // smallest offset) can pair one instance's side with another's offset, which
592
+ // is the very "pill on an axis that didn't measure it" this fixes.
593
+ const axisOffsets = useMemo(() => {
594
+ const byInstance = new Map();
595
+ const walk = (side) => {
596
+ const inward = realEntries.filter(([, spec]) => spec.side === side);
597
+ if (side === 'left')
598
+ inward.reverse();
599
+ let offset = 0;
600
+ for (const [key, spec] of inward) {
601
+ byInstance.set(key, offset);
602
+ offset += axisSlots.get(key) ?? spec.width;
603
+ }
604
+ };
605
+ walk('right');
606
+ walk('left');
607
+ // realEntries is index-sorted, the order effectiveAxes (and so axisSides)
608
+ // walks — hence the same winner on a repeated id.
609
+ const map = new Map();
610
+ for (const [key, spec] of realEntries) {
611
+ map.set(spec.id, byInstance.get(key) ?? 0);
612
+ }
613
+ return map;
614
+ }, [realEntries, axisSlots]);
615
+ // Each axis's own ink (`<YAxis color>`) — the axis-edge pill that lands on an
616
+ // axis wears its colour, so with several axes the number says which scale it
617
+ // is on. Axes that set no colour are absent (the pill falls back to theme).
618
+ const axisColors = useMemo(() => {
619
+ const map = new Map();
620
+ for (const ax of effectiveAxes) {
621
+ if (ax.color !== undefined)
622
+ map.set(ax.id, ax.color);
623
+ }
624
+ return map;
625
+ }, [effectiveAxes]);
454
626
  const frame = useMemo(() => ({
455
627
  height,
456
628
  topInset: topHeader,
@@ -458,10 +630,15 @@ export function ChartRow({ height, cursor, children }) {
458
630
  isFirstRow,
459
631
  rowKey,
460
632
  yScales,
633
+ baseYScales: baseRef.current,
634
+ axisTransforms,
635
+ applyAxisTransform,
461
636
  formats,
462
637
  tickValues,
463
638
  tickCounts,
464
639
  axisSides,
640
+ axisOffsets,
641
+ axisColors,
465
642
  defaultAxisId,
466
643
  axisSlots,
467
644
  registerAxis,
@@ -476,10 +653,14 @@ export function ChartRow({ height, cursor, children }) {
476
653
  isFirstRow,
477
654
  rowKey,
478
655
  yScales,
656
+ axisTransforms,
657
+ applyAxisTransform,
479
658
  formats,
480
659
  tickValues,
481
660
  tickCounts,
482
661
  axisSides,
662
+ axisOffsets,
663
+ axisColors,
483
664
  defaultAxisId,
484
665
  axisSlots,
485
666
  registerAxis,
@@ -556,11 +737,17 @@ export function ChartRow({ height, cursor, children }) {
556
737
  "wrap the row's <Layers>, leaving each <YAxis> a direct child of the " +
557
738
  '<ChartRow>.');
558
739
  }, [axisInsideWrapper]);
559
- return (_jsxs(RowContext.Provider, { value: frame, children: [cursor !== undefined && (_jsx(LegacyCursor, { mode: cursor, showTime: container.cursorTime, snap: container.crosshairSnap })), _jsxs("div", { style: {
740
+ return (_jsxs(RowContext.Provider, { value: frame, children: [cursor !== undefined && (_jsx(LegacyCursor, { mode: cursor, showTime: container.cursorTime, snap: container.crosshairSnap })), _jsxs("div", { ref: boxRef, style: {
560
741
  display: 'flex',
561
742
  flexDirection: 'row',
562
743
  width: `${container.width}px`,
563
- height: `${height}px`,
744
+ // Fixed rows keep their pixels; a flex row is sized by the
745
+ // container's column layout and reads the result back
746
+ // ([PND-HEIGHT]). `minHeight: 0` is what lets it actually shrink —
747
+ // a flex child's default min-height is its content.
748
+ ...(isFlex
749
+ ? { flex: `${flexGrow} 1 0%`, minHeight: 0 }
750
+ : { height: `${height}px` }),
564
751
  }, children: [leftPad > 0 && _jsx("div", { style: { flex: `0 0 ${leftPad}px` } }), leftAxisEls, plotEls, rightAxisEls, rightPad > 0 && _jsx("div", { style: { flex: `0 0 ${rightPad}px` } })] })] }));
565
752
  }
566
753
  //# sourceMappingURL=ChartRow.js.map