@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.
package/dist/Layers.js CHANGED
@@ -207,7 +207,7 @@ export function Layers({ children }) {
207
207
  }), [row.registerLayer, row.unregisterLayer]);
208
208
  const background = container.theme.background;
209
209
  const { grid: gridColor, gridDash } = container.theme.axis;
210
- const { layers, yScales, formats, defaultAxisId, tickValues, tickCounts, axisSides, } = row;
210
+ const { layers, yScales, baseYScales, formats, defaultAxisId, tickValues, tickCounts, axisSides, axisOffsets, axisColors, } = row;
211
211
  // x geometry is shared and lives on the container (uniform across rows), and
212
212
  // so is the x tick count — vertical gridlines must sit under the `<XAxis>`
213
213
  // labels, which pass the same `xTickCount` to the same scale.
@@ -346,21 +346,23 @@ export function Layers({ children }) {
346
346
  const report = container.reportDrawStats;
347
347
  if (report === undefined) {
348
348
  for (const entry of layers) {
349
- const yScale = yScales.get(entry.axisId ?? defaultAxisId);
349
+ const axisId = entry.axisId ?? defaultAxisId;
350
+ const yScale = yScales.get(axisId);
350
351
  if (yScale === undefined)
351
352
  continue;
352
- entry.layer.draw(ctx, xScale, yScale);
353
+ entry.layer.draw(ctx, xScale, yScale, baseYScales.get(axisId));
353
354
  }
354
355
  }
355
356
  else {
356
357
  const infos = [];
357
358
  let totalDrawMs = 0;
358
359
  for (const entry of layers) {
359
- const yScale = yScales.get(entry.axisId ?? defaultAxisId);
360
+ const axisId = entry.axisId ?? defaultAxisId;
361
+ const yScale = yScales.get(axisId);
360
362
  if (yScale === undefined)
361
363
  continue;
362
364
  const t0 = performance.now();
363
- const stats = entry.layer.draw(ctx, xScale, yScale);
365
+ const stats = entry.layer.draw(ctx, xScale, yScale, baseYScales.get(axisId));
364
366
  const drawMs = performance.now() - t0;
365
367
  totalDrawMs += drawMs;
366
368
  infos.push({
@@ -377,6 +379,7 @@ export function Layers({ children }) {
377
379
  }, [
378
380
  layers,
379
381
  yScales,
382
+ baseYScales,
380
383
  xScale,
381
384
  xTickCount,
382
385
  defaultAxisId,
@@ -516,14 +519,20 @@ export function Layers({ children }) {
516
519
  // The chip uses this layer's axis formatter, so a readout value reads
517
520
  // exactly as the axis labels it.
518
521
  const fmt = formats.get(axisId) ?? String;
519
- // Which gutter the crosshair value pill hugs (the axis's own side).
522
+ // Where the crosshair value pill lands, and in what ink: this axis's own
523
+ // side, its offset out into that gutter (so the pill sits on the axis that
524
+ // measured the value, not the innermost one), and its `<YAxis color>`.
520
525
  const side = axisSides.get(axisId) ?? 'left';
526
+ const axisOffset = axisOffsets.get(axisId) ?? 0;
527
+ const axisColor = axisColors.get(axisId);
521
528
  for (const s of entry.layer.sampleAt(cursorTime)) {
522
529
  out.push({
523
530
  px: xScale(s.x),
524
531
  py: yScale(s.value),
525
532
  axisId,
526
533
  side,
534
+ axisOffset,
535
+ axisColor,
527
536
  formatted: fmt(s.value),
528
537
  color: s.color,
529
538
  label: s.label,
@@ -538,6 +547,8 @@ export function Layers({ children }) {
538
547
  yScales,
539
548
  formats,
540
549
  axisSides,
550
+ axisOffsets,
551
+ axisColors,
541
552
  xScale,
542
553
  defaultAxisId,
543
554
  ]);
@@ -1124,7 +1135,7 @@ export function Layers({ children }) {
1124
1135
  // canvas repaints only on a mark transition — not every move (the move just
1125
1136
  // slides the SVG cursor). A row with no selectable layer (line/area/band)
1126
1137
  // resolves to null → a no-op. Uses the raw pointer, not the snapped x.
1127
- const hit = resolveSelection(r.layers, rawX, py, c.xScale, (axisId) => r.yScales.get(axisId ?? r.defaultAxisId));
1138
+ const hit = resolveSelection(r.layers, rawX, py, c.xScale, (axisId) => r.yScales.get(axisId ?? r.defaultAxisId), 'hover', (axisId) => r.baseYScales.get(axisId ?? r.defaultAxisId));
1128
1139
  // The resting BLOCK preview (a mounted <MultiSelector>): hover lights
1129
1140
  // every mark in the snap block under the pointer — exactly the set a
1130
1141
  // drag begun and released here would select, from exactly the sweep's
@@ -1384,7 +1395,7 @@ export function Layers({ children }) {
1384
1395
  // 'select', not 'hover': a click must be able to resolve to NO mark —
1385
1396
  // that null is the deselect signal (the empty commit) — so layers whose
1386
1397
  // hover target is generous (a bar's full-height slot) narrow it here.
1387
- const hit = resolveSelection(r.layers, e.clientX - rect.left, e.clientY - rect.top, c.xScale, (axisId) => r.yScales.get(axisId ?? r.defaultAxisId), 'select');
1398
+ const hit = resolveSelection(r.layers, e.clientX - rect.left, e.clientY - rect.top, c.xScale, (axisId) => r.yScales.get(axisId ?? r.defaultAxisId), 'select', (axisId) => r.baseYScales.get(axisId ?? r.defaultAxisId));
1388
1399
  const modifiers = {
1389
1400
  additive: e.metaKey || e.ctrlKey,
1390
1401
  ctrlKey: e.ctrlKey,
@@ -1539,6 +1550,10 @@ export function Layers({ children }) {
1539
1550
  py: cursor.cursorY,
1540
1551
  formatted: fmt(ys.invert(cursor.cursorY)),
1541
1552
  side: axisSides.get(defaultAxisId) ?? 'left',
1553
+ // The free reticle reads the row's *default* axis, so its pill belongs on
1554
+ // that axis — at its offset, in its ink — like a snapped sample's.
1555
+ axisOffset: axisOffsets.get(defaultAxisId) ?? 0,
1556
+ axisColor: axisColors.get(defaultAxisId),
1542
1557
  };
1543
1558
  }, [
1544
1559
  wantsPointer,
@@ -1548,6 +1563,8 @@ export function Layers({ children }) {
1548
1563
  yScales,
1549
1564
  formats,
1550
1565
  axisSides,
1566
+ axisOffsets,
1567
+ axisColors,
1551
1568
  defaultAxisId,
1552
1569
  ]);
1553
1570
  // The in-plot cursor time, readout-formatted — the `showTime` presets' chip
package/dist/XAxis.d.ts CHANGED
@@ -133,6 +133,12 @@ export interface XAxisProps {
133
133
  * numbers, with no axis-type prop here; the kind follows the data.
134
134
  *
135
135
  * `<TimeAxis>` is the time-flavoured preset (`<XAxis />`).
136
+ *
137
+ * **Gestures.** With `<ChartContainer axisPanZoom="x">` (or `"xy"`) the strip is a
138
+ * second handle on the canvas gesture: it **pans on drag** and **zooms on
139
+ * wheel**, with double-click returning to the declared `range`. Same maths as the
140
+ * plot's own drag, including `bounds` / `minDuration` and the trading calendar.
141
+ * A category axis has no continuous domain and stays inert.
136
142
  */
137
143
  export declare function XAxis({ format, label, side, height, ticks: customTicks, transform, color, align, dateStyle, onMouseEvent, }?: XAxisProps): import("react/jsx-runtime").JSX.Element;
138
144
  export {};
package/dist/XAxis.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Fragment, useContext } from 'react';
2
+ import { Fragment, useContext, useRef } from 'react';
3
3
  import { scaleLinear } from 'd3-scale';
4
4
  import { derivedTicks } from './derivedTicks.js';
5
5
  import { ContainerContext, CursorContext, } from './context.js';
@@ -8,6 +8,8 @@ import { xAxisCursorEntries } from './cursors.js';
8
8
  import { axisPillStyle } from './chip.js';
9
9
  import { resolveAxisFormat, resolveTimeFormat, } from './format.js';
10
10
  import { axisMouseProps, axisPointerPx, } from './axis-events.js';
11
+ import { useAxisGestures } from './use-axis-gestures.js';
12
+ import { panRange, panRangeTrading, zoomRange, zoomRangeTrading, } from './viewport.js';
11
13
  /** Tick strip height (mark + value label) in CSS px. */
12
14
  const TICK_STRIP = 22;
13
15
  /** Extra height reserved for an axis `label` line. */
@@ -157,6 +159,12 @@ export function thinCategoryLabels(ticks, slot, plotWidth, fontSize, fontFamily)
157
159
  * numbers, with no axis-type prop here; the kind follows the data.
158
160
  *
159
161
  * `<TimeAxis>` is the time-flavoured preset (`<XAxis />`).
162
+ *
163
+ * **Gestures.** With `<ChartContainer axisPanZoom="x">` (or `"xy"`) the strip is a
164
+ * second handle on the canvas gesture: it **pans on drag** and **zooms on
165
+ * wheel**, with double-click returning to the declared `range`. Same maths as the
166
+ * plot's own drag, including `bounds` / `minDuration` and the trading calendar.
167
+ * A category axis has no continuous domain and stays inert.
160
168
  */
161
169
  export function XAxis({ format, label, side = 'bottom', height, ticks: customTicks, transform, color, align = 'center', dateStyle = 'flat', onMouseEvent, } = {}) {
162
170
  const container = useContext(ContainerContext);
@@ -417,14 +425,84 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
417
425
  // scale — no gutter arithmetic. The label reads the same channel a cursor
418
426
  // pill does: the band scale's category name on a category axis (a d3 number
419
427
  // format can't name one), this axis's readout format everywhere else.
428
+ // The view the current pan started from — see `onDragStart`.
429
+ const panStartRef = useRef(null);
430
+ // Drag pans and wheel zooms the shared x view, double-click returns to the
431
+ // declared range. Enabled by the container's own `panZoom` zoom-x degree of
432
+ // freedom — a chart that never opted in captures nothing here.
433
+ //
434
+ // A **category** axis has no continuous domain to zoom, exactly as for the
435
+ // plot's gesture (`Layers`' wheel makes the same exclusion), so the strip
436
+ // stays inert there rather than snapping between slots.
437
+ const gestures = useAxisGestures({
438
+ axis: 'x',
439
+ // Gated on `<ChartContainer axisPanZoom>` — the axis opt-in — and NOT on the
440
+ // plot's `panZoom`: inheriting that would hand every already-interactive
441
+ // chart gestures its author never asked for. Once opted in the strip is the
442
+ // canvas gesture (drag pans, wheel zooms). A category axis has no continuous
443
+ // domain for either.
444
+ drag: container.axisPanZoomX && xKind !== 'category' ? 'pan' : 'none',
445
+ wheel: container.axisPanZoomX && xKind !== 'category',
446
+ // Snapshot the view at press: the pan is re-derived from it on every move
447
+ // (the plot's own approach), so a long drag can't accumulate rounding, and
448
+ // `roundRange`'s ms snap can't ratchet the span.
449
+ onDragStart: () => {
450
+ panStartRef.current = [container.timeRange[0], container.timeRange[1]];
451
+ },
452
+ onPan: (totalDeltaPx) => {
453
+ const start = panStartRef.current;
454
+ if (start === null)
455
+ return;
456
+ // Dragging right moves the view EARLIER — the content follows the pointer,
457
+ // the sign the plot's drag uses.
458
+ if (container.discontinuities) {
459
+ // Trading-time axis: pan by an equal amount of *trading* time so the
460
+ // drag feels uniform across collapsed gaps (a raw-ms shift jumps).
461
+ const fraction = plotWidth > 0 ? -totalDeltaPx / plotWidth : 0;
462
+ container.applyRange(panRangeTrading(start, fraction, container.discontinuities));
463
+ return;
464
+ }
465
+ const span = start[1] - start[0];
466
+ const dt = plotWidth > 0 ? -totalDeltaPx * (span / plotWidth) : 0;
467
+ container.applyRange(panRange(start, dt, { log: container.xIsLog, snap: xKind === 'time' }));
468
+ },
469
+ onZoom: (factor, pivotPx) => {
470
+ const pivot = +xScale.invert(pivotPx);
471
+ // The same two-branch zoom the plot uses, so an axis drag and a plot
472
+ // wheel move the view by identical maths — including `minDuration` as the
473
+ // zoom-in floor and, on a trading axis, a floor in *trading* ms.
474
+ // `applyRange` then applies `bounds` for both.
475
+ container.applyRange(container.discontinuities
476
+ ? zoomRangeTrading(container.timeRange, pivot, factor, container.discontinuities, container.minDuration)
477
+ : zoomRange(container.timeRange, pivot, factor, container.minDuration, {
478
+ log: container.xIsLog,
479
+ snap: xKind === 'time',
480
+ }));
481
+ },
482
+ onReset: () => container.applyRange(container.seedRange),
483
+ });
420
484
  const mouse = axisMouseProps(onMouseEvent, 'x', undefined, (event) => {
485
+ // A zoom drag ends with a trailing `click` on the strip, which would report
486
+ // as "clicked the axis at the value I released on" — a value the user never
487
+ // aimed at. Swallow exactly that one report; every other event still flows.
488
+ if (event.type === 'click' && gestures.consumeDrag())
489
+ return null;
421
490
  const value = +xScale.invert(axisPointerPx(event, 'x', [0, plotWidth]));
422
491
  return {
423
492
  value,
424
493
  label: xKind === 'category' ? fmt(value) : readoutFmt(value),
425
494
  };
426
495
  });
427
- return (_jsxs("div", { "data-axis": "x", ...mouse, style: {
496
+ return (_jsxs("div", { "data-axis": "x", ref: gestures.ref, ...gestures.props, ...mouse,
497
+ // Both spreads carry an `onDoubleClick` — the gesture's reset and the
498
+ // consumer's report — and a spread silently keeps the last one. Compose
499
+ // them, reporting *before* the reset so the payload describes the view the
500
+ // user actually double-clicked in.
501
+ onDoubleClick: (e) => {
502
+ mouse.onDoubleClick?.(e);
503
+ gestures.props.onDoubleClick?.();
504
+ }, style: {
505
+ ...gestures.style,
428
506
  position: 'relative',
429
507
  marginLeft: `${leftGutter}px`,
430
508
  width: `${plotWidth}px`,
@@ -514,7 +592,7 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
514
592
  fontWeight: 600,
515
593
  whiteSpace: 'nowrap',
516
594
  overflow: 'hidden',
517
- }, children: b.label }, b.start));
595
+ }, children: b.showLabel ? b.label : '' }, b.start));
518
596
  }) })), label !== undefined && (_jsx("div", { style: {
519
597
  position: 'absolute',
520
598
  left: 0,
package/dist/YAxis.d.ts CHANGED
@@ -195,7 +195,14 @@ export interface YAxisProps {
195
195
  * overriding the theme's `axis.label` / `axis.title.color`. The multi-axis
196
196
  * convention of colouring each y axis to match its series (`color`
197
197
  * matching the layer's) — busy, but standard. Omit for the theme's axis
198
- * colours. Presentation-only: it never re-registers the axis.
198
+ * colours.
199
+ *
200
+ * **Also worn by the axis-edge chrome that lands on this axis** — a
201
+ * `<CrosshairCursor>`'s value pill takes it when the reticle reads a series
202
+ * scaled here, so with several axes the pill says *which* scale the number is
203
+ * on (the ChartIQ price-tag convention). That is why it rides on the
204
+ * registered spec: the pill is drawn by the row's cursor overlay, not by this
205
+ * component, so a colour it never registered could not reach it.
199
206
  */
200
207
  color?: string;
201
208
  /**
@@ -209,8 +216,83 @@ export interface YAxisProps {
209
216
  * menu, down/up, move, enter, leave — so switch on `event.type`. Nothing is
210
217
  * attached when the prop is omitted, so the move events cost nothing unless
211
218
  * you ask for them. A `hide`den axis draws no gutter and so fires nothing.
219
+ *
220
+ * A gutter that also zooms (see the component docs) still reports every event
221
+ * here, minus the trailing `click` a zoom drag would otherwise synthesize.
212
222
  */
213
223
  onMouseEvent?: AxisMouseHandler;
224
+ /**
225
+ * A gutter gesture scaled this axis — **the "auto vs manual" hand-off.**
226
+ * Fires with the `[min, max]` **bounds** the gesture arrived at, and with
227
+ * `null` when the axis is released back to auto-fit (double-click).
228
+ *
229
+ * Named for bounds rather than the domain because that is what it reports: with
230
+ * a `pad` set, the visible domain is these bounds *plus* the padding, and it is
231
+ * the bounds you hand back as `min`/`max`.
232
+ *
233
+ * The common shape this exists for: an auto-fitting y axis on a chart whose x
234
+ * is panned and zoomed. The moment the user scrolls or drags the y gutter they
235
+ * have overridden the fit, and a UI usually wants to *say* so — show the
236
+ * resulting min/max, mark the scale "manual", and offer a toggle back to auto
237
+ * (which is the same thing double-clicking the gutter does).
238
+ *
239
+ * ```tsx
240
+ * const [scale, setScale] = useState<[number, number] | null>(null); // null = auto
241
+ * <YAxis
242
+ * id="price"
243
+ * {...(scale ? { min: scale[0], max: scale[1] } : {})}
244
+ * onBoundsChange={setScale}
245
+ * />
246
+ * ```
247
+ *
248
+ * **Providing it makes the axis controlled**, exactly as `onTimeRangeChange`
249
+ * does for the x view: the gesture then only *reports*, and what the axis draws
250
+ * is whatever `min`/`max` you feed back. Omit it and the axis holds the zoom
251
+ * itself (an internal per-axis transform) — which is the standalone behaviour,
252
+ * and why a chart with no scale UI needs no wiring at all.
253
+ *
254
+ * The reported pair is in data units, ready to hand straight back as
255
+ * `min`/`max`.
256
+ *
257
+ * **`scale="symlog"` is approximate on this path, by construction.**
258
+ * {@link linearWindow} is a fraction of the *domain*, so bounds fed back
259
+ * re-derive the knee and reshape the curve — the grabbed pixel cannot be held
260
+ * on a curve that moves with the bounds. (It is the same fact that makes
261
+ * `linearWindow` deliberately *not* recompute under a 2-D gesture.) The zoom is
262
+ * still monotone and well-behaved; if you need the pixel held exactly on a
263
+ * symlog axis, leave this callback off and let the axis hold the zoom itself,
264
+ * where the knee stays anchored to the resolved domain. With an active plot-level y zoom (`panZoom="panZoomY"`/`"panZoomXY"`)
265
+ * the two **compose**: the bounds are the axis's own, and the plot transform
266
+ * still narrows what is drawn on top of them. On a `log` axis it stays positive (the zoom is done in log
267
+ * space), so it is always a domain the axis can actually draw.
268
+ */
269
+ onBoundsChange?: (bounds: readonly [number, number] | null) => void;
270
+ /**
271
+ * Pin the zoom to the value-**0** gridline instead of the pointer, and drop
272
+ * drag-to-pan entirely — for a bar chart, whose baseline must never move.
273
+ * A bar rests on `0` (or, straddling positive and negative, has `0`
274
+ * *somewhere inside* the visible range) — panning or zooming about an
275
+ * arbitrary pointer position would slide that baseline around the plot,
276
+ * which reads as the data moving under gestures that never touched it (see
277
+ * `resolveBarBaseline`, and [PND-XBASE] for the still-open x-axis
278
+ * counterpart of this exact failure mode). `zeroAnchored` sidesteps it:
279
+ * every wheel notch scales the axis around wherever `0` currently renders,
280
+ * so the baseline holds however far in or out you scroll — whether it sits
281
+ * at the plot floor (all-positive bars) or in the middle (bars that
282
+ * straddle zero).
283
+ *
284
+ * One qualification: the pivot is clamped into the axis's own pixel range,
285
+ * so if `0` has been scrolled off the plot entirely the zoom pivots about
286
+ * the nearer edge instead and the baseline does creep. For a bar chart that
287
+ * is benign — the clamp lands on the same floor `resolveBarBaseline`
288
+ * already resolves against — but it is a creep, not a guarantee.
289
+ *
290
+ * Still gated by the container's own `axisPanZoom` opt-in (`'y'` / `'xy'`)
291
+ * — this only changes *what* the gesture does once enabled, not whether
292
+ * it's enabled. Double-click still resets to the declared/auto-fit view.
293
+ * Default `false`.
294
+ */
295
+ zeroAnchored?: boolean;
214
296
  /**
215
297
  * @internal Declaration position among the row's children, injected by
216
298
  * `ChartRow` so the first-declared axis stays the default. Do not set.
@@ -224,6 +306,14 @@ export interface YAxisProps {
224
306
  * computes this axis's scale from the charts linked to it; the gutter then draws
225
307
  * tick marks + labels from that scale. Charts attach via `<LineChart axis="id">`
226
308
  * (default: the first axis).
309
+ *
310
+ * **Gestures.** With `<ChartContainer axisPanZoom="y">` (or `"xy"`) the gutter is
311
+ * grabbable: drag or wheel it to scale **this axis only**
312
+ * — a sibling axis on the other side, and every other row, hold still — and
313
+ * double-click to release it back to its fit. That per-axis scaling is what the
314
+ * plot's vertical gesture deliberately cannot do; see
315
+ * {@link RowFrame.axisTransforms}. Report it to a scale UI with
316
+ * {@link YAxisProps.onBoundsChange}.
227
317
  */
228
- export declare function YAxis({ id, side, label, scale, linearWindow, min, max, format, ticks, tickCount, pad, boundaryLabels, width, hide, labelPlacement, color, onMouseEvent, index, }: YAxisProps): import("react/jsx-runtime").JSX.Element | null;
318
+ export declare function YAxis({ id, side, label, scale, linearWindow, min, max, format, ticks, tickCount, pad, boundaryLabels, width, hide, labelPlacement, color, onMouseEvent, onBoundsChange, zeroAnchored, index, }: YAxisProps): import("react/jsx-runtime").JSX.Element | null;
229
319
  //# sourceMappingURL=YAxis.d.ts.map