@pond-ts/charts 0.43.0 → 0.44.1

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.
@@ -11,9 +11,18 @@ import { resolveCursorX, DEFAULT_CURSOR_MODE } from './tracker.js';
11
11
  import { resolveAxisFormat, resolveTimeFormat, } from './format.js';
12
12
  import { TimeAxis } from './TimeAxis.js';
13
13
  import { defaultTheme } from './theme.js';
14
- /** Time-axis tick count matches `<TimeAxis>` so the cursor-time formatter is
15
- * calibrated as the time-axis labels are. */
14
+ /** Tick count for a **continuous** (non-trading) x axis the `ticks(count)`
15
+ * request `<TimeAxis>`, the x gridlines, and the cursor-time formatter share
16
+ * (as the frame's `xTickCount`). */
16
17
  const TIME_TICK_COUNT = 5;
18
+ /** Target px of plot width per tick on a **trading-time** axis. That scale's
19
+ * `ticks(count)` treats `count` as a **cap on calendar buckets** (see
20
+ * `coarsenCalendar` — it picks the finest grain that fits), so the count must
21
+ * scale with the room the labels actually have: a fixed 5 coarsens any
22
+ * ≳6-month daily view to year grain — 2 ticks on a 900px plot. ~65px fits a
23
+ * `%b %d` anchor label at the default font plus breathing room, so a ~900px
24
+ * year-long daily view lands on month grain. */
25
+ const TRADING_TICK_PX = 65;
17
26
  /**
18
27
  * Normalize the `range` prop — a `[begin, end]` tuple or a `TimeRange` — to a
19
28
  * plain `[number, number]`, or `undefined` when omitted (→ auto-fit). The
@@ -323,6 +332,15 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
323
332
  ? calendar.discontinuities(spacing ? { spacing } : undefined)
324
333
  : undefined, [resolvedKind, discontinuities, calendar, spacing]);
325
334
  const xDiscontinuities = resolvedKind === 'time' ? (discontinuities ?? calendarProvider) : undefined;
335
+ // The shared x-side tick count — labels, x gridlines, session dividers, and
336
+ // `formatTime` all pass this one value, so they derive from the same instants
337
+ // (the alignment previously held by three hardcoded constants agreeing).
338
+ // Trading axis: width-derived, since the trading scale's `count` caps its
339
+ // calendar buckets rather than targeting a tick total; floored at 2 so a
340
+ // pre-layout zero width still requests a drawable tick set.
341
+ const xTickCount = xDiscontinuities !== undefined
342
+ ? Math.max(2, Math.floor(plotWidth / TRADING_TICK_PX))
343
+ : TIME_TICK_COUNT;
326
344
  const { xScale, formatTime } = useMemo(() => {
327
345
  if (resolvedKind === 'category') {
328
346
  // Ordinal column-domain axis: a band scale over the category slots. The
@@ -339,24 +357,26 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
339
357
  const s = scaleLinear().domain([d0, d1]).range([0, plotWidth]);
340
358
  return {
341
359
  xScale: s,
342
- formatTime: resolveAxisFormat(s, TIME_TICK_COUNT, timeFormat),
360
+ formatTime: resolveAxisFormat(s, xTickCount, timeFormat),
343
361
  };
344
362
  }
345
363
  if (xDiscontinuities !== undefined) {
346
364
  // Trading-time axis: closed-market gaps collapse, time proportional within
347
365
  // sessions. Same tickFormat surface as scaleTime, so the readout is shared.
366
+ // `xTickCount` reaches `tickFormat` too: the trading scale picks its anchor
367
+ // grain from the count, so labels sit on the exact instants the ticks do.
348
368
  const s = scaleTradingTime(xDiscontinuities)
349
369
  .domain([d0, d1])
350
370
  .range([0, plotWidth]);
351
371
  return {
352
372
  xScale: s,
353
- formatTime: resolveTimeFormat(s, TIME_TICK_COUNT, timeFormat),
373
+ formatTime: resolveTimeFormat(s, xTickCount, timeFormat),
354
374
  };
355
375
  }
356
376
  const s = scaleTime().domain([d0, d1]).range([0, plotWidth]);
357
377
  return {
358
378
  xScale: s,
359
- formatTime: resolveTimeFormat(s, TIME_TICK_COUNT, timeFormat),
379
+ formatTime: resolveTimeFormat(s, xTickCount, timeFormat),
360
380
  };
361
381
  }, [
362
382
  resolvedKind,
@@ -366,34 +386,58 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
366
386
  plotWidth,
367
387
  timeFormat,
368
388
  xDiscontinuities,
389
+ xTickCount,
369
390
  ]);
370
391
  // The crosshair pixel (see resolveCursorX). A stored hoverX is a *plot* pixel;
371
392
  // if plotWidth changes mid-hover (a gutter reserving, or a width change) it's
372
393
  // briefly stale until the next pointer move — rare, and the bounds check below
373
394
  // hides an out-of-plot crosshair meanwhile.
374
395
  const cursorX = resolveCursorX(trackerPosition, hoverX, xScale);
375
- // `cursor="region"` buckets: realize the `cursorSequence` over the current view
376
- // (a `Sequence` `.bounded`; a `BoundedSequence` used as-is), so the band can
377
- // find the interval under the pointer. Memoized on the sequence + view range;
378
- // a coarse sequence (days / sessions) is a handful of intervals.
379
- // A `Sequence` bucket is a **time** interval, so gate it to a time axis — on a
380
- // value axis (a horizontal histogram, a value-keyed chart) the value domain is
381
- // not epoch-ms, so realizing time buckets over it is meaningless (it would
382
- // shade the whole plot). Region cursor is time-axis only.
396
+ // `cursor="region"` snap buckets the intervals the band snaps to (and a drag
397
+ // extends bucket by bucket over). Two sources, in precedence order:
398
+ //
399
+ // 1. **An explicit `cursorSequence`** (time axis only): realized over the view
400
+ // (a `Sequence` `.bounded`; a `BoundedSequence` used as-is). A `Sequence`
401
+ // bucket is a *time* interval, so it's gated to a time axis realizing time
402
+ // buckets over a value domain is meaningless (it would shade the whole plot).
403
+ // 2. **A bar/histogram layer's bins** (`binIntervals`, time **or** value axis):
404
+ // when no `cursorSequence` is set, the region cursor snaps to the bars —
405
+ // a histogram gets bin-aligned selection for free (the first bar layer that
406
+ // publishes bins wins; a plain histogram has exactly one).
407
+ //
408
+ // With neither, `undefined` ⇒ the freeform region cursor (raw-span drag).
383
409
  const cursorBuckets = useMemo(() => {
384
- if (cursorSequence === undefined || resolvedKind !== 'time')
385
- return undefined;
386
- if (!(cursorSequence instanceof Sequence))
387
- return cursorSequence.intervals();
388
- // `bounded` (sample 'begin') drops a partial *leading* bucket the one that
389
- // contains the view start begins before it. Widen the realized range back by
390
- // one bucket width so that covering bucket is included (a coarse calendar
391
- // unit is bounded at ~a year; a fixed step uses its own width).
392
- const back = cursorSequence.kind() === 'fixed'
393
- ? cursorSequence.stepMs()
394
- : 366 * 86_400_000;
395
- return cursorSequence.bounded({ start: d0 - back, end: d1 }).intervals();
396
- }, [cursorSequence, d0, d1, resolvedKind]);
410
+ if (cursorSequence !== undefined && resolvedKind === 'time') {
411
+ if (!(cursorSequence instanceof Sequence))
412
+ return cursorSequence.intervals();
413
+ // `bounded` (sample 'begin') drops a partial *leading* bucket — the one that
414
+ // contains the view start begins before it. Widen the realized range back by
415
+ // one bucket width so that covering bucket is included (a coarse calendar
416
+ // unit is bounded at ~a year; a fixed step uses its own width).
417
+ const back = cursorSequence.kind() === 'fixed'
418
+ ? cursorSequence.stepMs()
419
+ : 366 * 86_400_000;
420
+ return cursorSequence.bounded({ start: d0 - back, end: d1 }).intervals();
421
+ }
422
+ // No sequence → snap to a bar/histogram layer's bins, if any (a value axis,
423
+ // or a time-axis histogram with no explicit sequence). `binIntervals` is only
424
+ // published by a vertical bar layer on a continuous axis, so this is a no-op
425
+ // for line/area/scatter rows and for a category axis.
426
+ //
427
+ // **First bar layer wins** — deliberately non-fatal, unlike `xCategories`
428
+ // (which *throws* when category rows disagree, because a mismatched slot order
429
+ // corrupts the shared band scale). Two overlaid histograms with different bins
430
+ // is a degenerate layout the region cursor just snaps to whichever registered
431
+ // first; a wrong snap grid is harmless where a wrong axis is not.
432
+ if (resolvedKind === 'time' || resolvedKind === 'value') {
433
+ for (const s of sources.values()) {
434
+ const bins = s.binIntervals?.() ?? null;
435
+ if (bins && bins.length > 0)
436
+ return bins;
437
+ }
438
+ }
439
+ return undefined;
440
+ }, [cursorSequence, d0, d1, resolvedKind, sources]);
397
441
  // Emit { time, values } for an outside readout — recomputed as the cursor moves
398
442
  // *or* the window slides under it (xScale change → new time at the same pixel).
399
443
  // Out of the plot (null, or a controlled trackerPosition d3 extrapolated past
@@ -455,6 +499,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
455
499
  onHoverAnnotation,
456
500
  onEditAnnotation,
457
501
  formatTime,
502
+ xTickCount,
458
503
  registerTrackerSource,
459
504
  unregisterTrackerSource,
460
505
  registerSelectable,
@@ -508,6 +553,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
508
553
  onHoverAnnotation,
509
554
  onEditAnnotation,
510
555
  formatTime,
556
+ xTickCount,
511
557
  registerTrackerSource,
512
558
  unregisterTrackerSource,
513
559
  registerSelectable,
package/dist/Layers.js CHANGED
@@ -2,16 +2,17 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
2
2
  import { Children, cloneElement, isValidElement, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
3
3
  import { Canvas } from './Canvas.js';
4
4
  import { drawGrid, drawDividers, thinPixels } from './grid.js';
5
- import { TimeRange } from 'pond-ts';
6
5
  import { cursorParts, bandRect, regionSpan } from './tracker.js';
7
6
  import { resolveSelection } from './select.js';
8
7
  import { panRange, zoomRange, panRangeTrading, zoomRangeTrading, } from './viewport.js';
9
8
  import { flagChipStyle, flagChipX, axisPillX, axisPillStyle } from './chip.js';
10
9
  import { ContainerContext, LayersContext, RowContext, } from './context.js';
11
- /** Gridline tick count. **Must match the axis label counts** (`XAxis`
12
- * `TICK_COUNT`, `ChartContainer` `TIME_TICK_COUNT`, `YAxis`) — the grid, the
13
- * session dividers, and the axis labels are all derived from `ticks(count)`, so
14
- * they only line up while the counts agree. Kept at 5 across all four. */
10
+ /** **Y**-gridline tick count. **Must match the y-axis label counts** (`YAxis`
11
+ * `TICK_COUNT`, `ChartRow` `AXIS_TICK_COUNT`) — horizontal gridlines and the y
12
+ * labels are both derived from `ticks(count)`, so they only line up while the
13
+ * counts agree; kept at 5 across all three. The **x** side instead reads the
14
+ * container's shared `xTickCount` (as `<XAxis>` and `formatTime` do), which is
15
+ * width-derived on a trading-time axis. */
15
16
  const GRID_TICKS = 5;
16
17
  /** Minimum px between session dividers — thins dense collapse points (e.g. a
17
18
  * daily chart where every candle is a new session) so the axis never crowds. */
@@ -57,8 +58,10 @@ export function Layers({ children }) {
57
58
  const background = container.theme.background;
58
59
  const { grid: gridColor, gridDash } = container.theme.axis;
59
60
  const { layers, yScales, formats, defaultAxisId, tickValues, axisSides } = row;
60
- // x geometry is shared and lives on the container (uniform across rows).
61
- const { xScale, plotWidth } = container;
61
+ // x geometry is shared and lives on the container (uniform across rows), and
62
+ // so is the x tick count — vertical gridlines must sit under the `<XAxis>`
63
+ // labels, which pass the same `xTickCount` to the same scale.
64
+ const { xScale, plotWidth, xTickCount } = container;
62
65
  const draw = useCallback((ctx, w, h) => {
63
66
  if (background !== undefined) {
64
67
  ctx.fillStyle = background;
@@ -72,7 +75,7 @@ export function Layers({ children }) {
72
75
  const explicitY = tickValues.get(defaultAxisId);
73
76
  // A category axis draws no vertical gridlines — a line through each bar
74
77
  // centre reads as noise; the bars are the structure.
75
- const xTickVals = container.xKind === 'category' ? [] : xScale.ticks(GRID_TICKS);
78
+ const xTickVals = container.xKind === 'category' ? [] : xScale.ticks(xTickCount);
76
79
  const xTicks = xTickVals.map((d) => xScale(+d));
77
80
  const yTicks = gridY
78
81
  ? (explicitY ?? gridY.ticks(GRID_TICKS)).map((t) => gridY(t))
@@ -105,6 +108,7 @@ export function Layers({ children }) {
105
108
  layers,
106
109
  yScales,
107
110
  xScale,
111
+ xTickCount,
108
112
  defaultAxisId,
109
113
  tickValues,
110
114
  background,
@@ -260,13 +264,17 @@ export function Layers({ children }) {
260
264
  }
261
265
  return;
262
266
  }
263
- // Region-cursor drag-select (opt-in via `onRegionSelect`, time axis only):
264
- // anchor the selection at the press; the band then extends as the pointer
265
- // moves (bucket by bucket with a sequence, freeform without), and release
266
- // commits the range. A `regionSelectModifier` (only while `panZoom` is on)
267
- // gates it behind the key so plain drag can still pan; otherwise it preempts
268
- // pan (returns before the pan is armed below).
269
- if (c.cursor === 'region' && c.onRegionSelect && c.xKind === 'time') {
267
+ // Region-cursor drag-select (opt-in via `onRegionSelect`): anchor the
268
+ // selection at the press; the band then extends as the pointer moves (bucket
269
+ // by bucket with a sequence, freeform without), and release commits the span.
270
+ // Works on a continuous x axis — time **or** value (a category axis is
271
+ // excluded; its ordinal-slot select is a different gesture). A
272
+ // `regionSelectModifier` (only while `panZoom` is on) gates it behind the key
273
+ // so plain drag can still pan; otherwise it preempts pan (returns before the
274
+ // pan is armed below).
275
+ if (c.cursor === 'region' &&
276
+ c.onRegionSelect &&
277
+ (c.xKind === 'time' || c.xKind === 'value')) {
270
278
  const needsShift = c.regionSelectModifier === 'shift' && c.panZoom;
271
279
  if (!needsShift || e.shiftKey) {
272
280
  const px = Math.max(0, Math.min(c.plotWidth, e.clientX - e.currentTarget.getBoundingClientRect().left));
@@ -417,7 +425,7 @@ export function Layers({ children }) {
417
425
  /* ignore */
418
426
  }
419
427
  if (span)
420
- c.onRegionSelect?.(new TimeRange({ start: span.start, end: span.end }));
428
+ c.onRegionSelect?.([span.start, span.end]);
421
429
  return;
422
430
  }
423
431
  if (c.creating !== null) {
@@ -623,12 +631,13 @@ export function Layers({ children }) {
623
631
  side: axisSides.get(defaultAxisId) ?? 'left',
624
632
  };
625
633
  })();
626
- // `region` cursor (time axis only): shade the span under the pointer. With a
627
- // `cursorSequence` the band snaps to the bucket (and extends bucket by bucket
628
- // under a drag); with none it's the **freeform** case a bare hover draws a
629
- // plain line (`regionLine`), a drag shades the raw `[anchor, pointer]`. Edges
630
- // map through `xScale`, so on a trading-time axis the band crops to live time.
631
- const regionActive = parts.band && container.xKind === 'time';
634
+ // `region` cursor (continuous x axis — time or value): shade the span under the
635
+ // pointer. With a `cursorSequence` (time axis only) the band snaps to the bucket
636
+ // (and extends bucket by bucket under a drag); with none always the case on a
637
+ // value axis it's the **freeform** case: a bare hover draws a plain line
638
+ // (`regionLine`), a drag shades the raw `[anchor, pointer]`. Edges map through
639
+ // `xScale`, so on a trading-time axis the band crops to live time.
640
+ const regionActive = parts.band && (container.xKind === 'time' || container.xKind === 'value');
632
641
  const band = regionActive && cursorTime !== null
633
642
  ? bandRect(container.cursorBuckets ?? [], cursorTime, (v) => xScale(v), plotWidth, container.regionAnchor ?? undefined)
634
643
  : null;
@@ -1,8 +1,21 @@
1
- import type { SeriesSchema, TimeSeries } from 'pond-ts';
1
+ import { ValueSeries } from 'pond-ts';
2
+ import type { SeriesSchema, TimeSeries, ValueSeriesSchema } from 'pond-ts';
2
3
  import { type ColorEncoding, type RadiusEncoding } from './encoding.js';
3
- export interface ScatterChartProps<S extends SeriesSchema> {
4
- /** The source series. Its key column supplies the time axis (each point's x). */
5
- series: TimeSeries<S>;
4
+ export interface ScatterChartProps<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema> {
5
+ /**
6
+ * The source series. A `TimeSeries` scatters against the time axis; a
7
+ * `ValueSeries` (`series.byValue('cumDist')`, or `ValueSeries.fromColumns`
8
+ * for natively value-keyed data — IV marks keyed by strike) against its
9
+ * value axis — the container infers which from the data, no axis-type prop
10
+ * (mirrors `<LineChart>`). Either way the key / axis column supplies each
11
+ * point's x and `column` supplies y.
12
+ *
13
+ * **Live charts:** `series.byValue(…)` mints a *fresh* projection each call,
14
+ * so passing `series={s.byValue('dist')}` inline re-registers this layer
15
+ * every render — memoize the projection (`useMemo`) on a frequently
16
+ * re-rendering chart.
17
+ */
18
+ series: TimeSeries<S> | ValueSeries<VS>;
6
19
  /** Name of the numeric value column — each point's y. */
7
20
  column: string;
8
21
  /**
@@ -24,6 +37,11 @@ export interface ScatterChartProps<S extends SeriesSchema> {
24
37
  * the key the controlled `selected` echo, dedup, and (later) multi-select all
25
38
  * match on — so a selection survives a data update where a sample `key` goes
26
39
  * stale.
40
+ *
41
+ * A point's identity within the series is its **x** (key / axis value). The
42
+ * key contract allows duplicate x's (equal timestamps; a value-axis plateau
43
+ * from `byValue('cumDist')`) — points sharing an x share identity, so
44
+ * selecting one highlights the last drawn point at that x.
27
45
  */
28
46
  id?: string;
29
47
  /**
@@ -59,6 +77,14 @@ export interface ScatterChartProps<S extends SeriesSchema> {
59
77
  * dense scatter is noise; this is for a handful of called-out marks.
60
78
  */
61
79
  label?: string | boolean;
80
+ /**
81
+ * A **pixel** shift applied to every point's x — zoom-stable. **Default `0`.**
82
+ * For pairing marks that share a key side by side (a call and a put mark at one
83
+ * strike: `offset={-4}` / `offset={+4}`). Pairs with `<BoxPlot offset>`; on the
84
+ * scatter the shift is exact — both the draw and the click hit-test move
85
+ * together, so a nudged point still selects.
86
+ */
87
+ offset?: number;
62
88
  /**
63
89
  * @internal Declaration position among the `<Layers>` children, injected by
64
90
  * `Layers` so z-order follows JSX order. Do not set.
@@ -66,7 +92,8 @@ export interface ScatterChartProps<S extends SeriesSchema> {
66
92
  index?: number;
67
93
  }
68
94
  /**
69
- * A scatter draw layer: one mark per finite point at `(time, column-value)`,
95
+ * A scatter draw layer: one mark per finite point at `(x, column-value)`
96
+ * — x from the series' key / axis column (time or value axis) —
70
97
  * with **data-driven radius + colour** (the signed-off exception — encode from
71
98
  * columns via scales, not a per-event style callback). Reads `column` into a
72
99
  * {@link ChartSeries} (gaps as NaN → no mark), registers into the enclosing
@@ -92,5 +119,5 @@ export interface ScatterChartProps<S extends SeriesSchema> {
92
119
  * </Layers>
93
120
  * ```
94
121
  */
95
- export declare function ScatterChart<S extends SeriesSchema>({ series, column, as: semantic, id, axis, radius, color, label, index, }: ScatterChartProps<S>): null;
122
+ export declare function ScatterChart<S extends SeriesSchema = SeriesSchema, VS extends ValueSeriesSchema = ValueSeriesSchema>({ series, column, as: semantic, id, axis, radius, color, label, offset, index, }: ScatterChartProps<S, VS>): null;
96
123
  //# sourceMappingURL=ScatterChart.d.ts.map
@@ -1,11 +1,13 @@
1
1
  import { useContext, useEffect, useMemo } from 'react';
2
- import { fromTimeSeries } from './data.js';
2
+ import { ValueSeries } from 'pond-ts';
3
+ import { fromTimeSeries, fromValueSeries } from './data.js';
3
4
  import { drawScatter, hitTestScatter, nearestIndex, scatterExtent, } from './scatter.js';
4
5
  import { resolveEncoding, } from './encoding.js';
5
6
  import { ContainerContext, LayersContext } from './context.js';
6
7
  import { useSlotKey } from './use-slot-key.js';
7
8
  /**
8
- * A scatter draw layer: one mark per finite point at `(time, column-value)`,
9
+ * A scatter draw layer: one mark per finite point at `(x, column-value)`
10
+ * — x from the series' key / axis column (time or value axis) —
9
11
  * with **data-driven radius + colour** (the signed-off exception — encode from
10
12
  * columns via scales, not a per-event style callback). Reads `column` into a
11
13
  * {@link ChartSeries} (gaps as NaN → no mark), registers into the enclosing
@@ -31,7 +33,7 @@ import { useSlotKey } from './use-slot-key.js';
31
33
  * </Layers>
32
34
  * ```
33
35
  */
34
- export function ScatterChart({ series, column, as: semantic, id, axis, radius, color, label, index = 0, }) {
36
+ export function ScatterChart({ series, column, as: semantic, id, axis, radius, color, label, offset = 0, index = 0, }) {
35
37
  const container = useContext(ContainerContext);
36
38
  if (container === null) {
37
39
  throw new Error('<ScatterChart> must be rendered inside a <ChartContainer>');
@@ -40,7 +42,9 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
40
42
  if (layers === null) {
41
43
  throw new Error('<ScatterChart> must be rendered inside a <Layers>');
42
44
  }
43
- const cs = useMemo(() => fromTimeSeries(series, column), [series, column]);
45
+ const cs = useMemo(() => series instanceof ValueSeries
46
+ ? fromValueSeries(series, column)
47
+ : fromTimeSeries(series, column), [series, column]);
44
48
  // Styling: semantic identifier → theme scatter style. The single styling
45
49
  // channel for the base mark.
46
50
  const { scatter } = container.theme;
@@ -53,13 +57,26 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
53
57
  // pulls a named numeric column to a Float64Array (gaps NaN) — the same path
54
58
  // fromTimeSeries uses; an unknown / non-numeric column throws there (eager,
55
59
  // so a typo surfaces at render, not silently as base-styled points).
56
- const encoding = useMemo(() => resolveEncoding(cs, style.radius, style.color, radius, color, (col) => fromTimeSeries(series, col).y), [cs, style.radius, style.color, radius, color, series]);
60
+ const encoding = useMemo(() => resolveEncoding(cs, style.radius, style.color, radius, color, (col) => series instanceof ValueSeries
61
+ ? fromValueSeries(series, col).y
62
+ : fromTimeSeries(series, col).y), [cs, style.radius, style.color, radius, color, series]);
57
63
  // Per-point label accessor: a column name reads that field, `true` reads the
58
64
  // plotted column, anything else (false / omitted) ⇒ no labels.
59
65
  const labelAt = useMemo(() => {
60
66
  if (label === undefined || label === false)
61
67
  return undefined;
62
68
  const field = label === true ? column : label;
69
+ if (series instanceof ValueSeries) {
70
+ // Columnar read — a ValueSeries has no per-row events. The field is a
71
+ // runtime string, cast onto the schema-literal column name (the same
72
+ // pattern as data.ts' readValueColumn). A gap or an unknown column reads
73
+ // undefined => no label at that point.
74
+ const col = series.column(field);
75
+ return (i) => {
76
+ const v = col?.read(i);
77
+ return v === undefined || v === null ? undefined : String(v);
78
+ };
79
+ }
63
80
  return (i) => {
64
81
  // series.at(i) is O(1) per row (columnar eventAt cache), so a label per
65
82
  // point stays cheap. The field is a runtime string → cast off the literal-
@@ -71,26 +88,28 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
71
88
  return v === undefined || v === null ? undefined : String(v);
72
89
  };
73
90
  }, [label, column, series]);
74
- // The point's stable key is its event begin (epoch ms) the same as cs.x[i],
75
- // which is the key column's begin buffer. Used for selection identity.
91
+ // The point's stable key is its x — the event begin (epoch ms) on a time
92
+ // axis, the axis value on a value axis; either way it's cs.x[i], the key
93
+ // column's begin buffer. Used for selection identity.
76
94
  const keyAt = useMemo(() => (i) => cs.x[i], [cs]);
77
95
  const entry = useMemo(() => ({
78
96
  layer: {
79
97
  yExtent: () => scatterExtent(cs),
80
- xKind: 'time',
98
+ // The container infers the shared x scale's kind + auto-fit domain from
99
+ // its layers: a ValueSeries scatters on a value axis, a TimeSeries on time.
100
+ xKind: series instanceof ValueSeries ? 'value' : 'time',
81
101
  xExtent: () => cs.length === 0 ? null : [cs.x[0], cs.x[cs.length - 1]],
82
- sampleAt: (time) => {
102
+ sampleAt: (x) => {
83
103
  // No readout past the data (tracker policy — the dot snaps to a drawn
84
- // mark, never extrapolates past the span); bounds from the time axis.
85
- if (cs.length === 0 ||
86
- time < cs.x[0] ||
87
- time > cs.x[cs.length - 1]) {
104
+ // mark, never extrapolates past the span); bounds from the columnar x
105
+ // axis (epoch ms or axis value — the bisect doesn't care).
106
+ if (cs.length === 0 || x < cs.x[0] || x > cs.x[cs.length - 1]) {
88
107
  return [];
89
108
  }
90
109
  // Nearest *drawn* point by index (skips gaps) — O(log N). Reading by
91
110
  // index gives the value, the snap-to x, and the encoded colour in one
92
111
  // shot, so the readout swatch matches the mark the user sees.
93
- const i = nearestIndex(cs, time);
112
+ const i = nearestIndex(cs, x);
94
113
  if (i < 0)
95
114
  return [];
96
115
  return [
@@ -108,9 +127,9 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
108
127
  ...(id === undefined
109
128
  ? {}
110
129
  : {
111
- hitTest: (px, py, xScale, yScale) => hitTestScatter(cs, px, py, xScale, yScale, encoding, keyAt, id, seriesLabel),
130
+ hitTest: (px, py, xScale, yScale) => hitTestScatter(cs, px, py, xScale, yScale, encoding, keyAt, id, seriesLabel, offset),
112
131
  }),
113
- draw: (ctx, xScale, yScale) => drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, container.selected, id),
132
+ draw: (ctx, xScale, yScale) => drawScatter(ctx, cs, xScale, yScale, style, encoding, keyAt, labelAt, font, container.selected, id, offset),
114
133
  },
115
134
  axisId: axis,
116
135
  index,
@@ -126,6 +145,7 @@ export function ScatterChart({ series, column, as: semantic, id, axis, radius, c
126
145
  labelAt,
127
146
  font,
128
147
  container.selected,
148
+ offset,
129
149
  axis,
130
150
  index,
131
151
  ]);
package/dist/XAxis.js CHANGED
@@ -7,7 +7,6 @@ import { resolveAxisFormat, resolveTimeFormat, } from './format.js';
7
7
  const TICK_STRIP = 22;
8
8
  /** Extra height reserved for an axis `label` line. */
9
9
  const LABEL_STRIP = 16;
10
- const TICK_COUNT = 5;
11
10
  /**
12
11
  * Thin + truncate a **category** axis's labels so a dense axis stays legible: keep
13
12
  * every `stride`-th label (so a kept label has room), and ellipsize one that still
@@ -53,7 +52,10 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
53
52
  if (container === null) {
54
53
  throw new Error('<XAxis> must be rendered inside a <ChartContainer>');
55
54
  }
56
- const { xScale, plotWidth, leftGutter, theme, formatTime, xKind } = container;
55
+ // `xTickCount` is the container's shared x-side count the same value the x
56
+ // gridlines and `formatTime` use, so labels and grid stay on the same instants
57
+ // (width-derived on a trading-time axis).
58
+ const { xScale, plotWidth, leftGutter, theme, formatTime, xKind, xTickCount, } = container;
57
59
  // The crosshair's x-time pill: when the container cursor is `'crosshair'` and a
58
60
  // cursor is live in-bounds, pin the hovered time to this axis (covering the
59
61
  // tick behind it), matching the on-axis y value pills the rows draw. Gated on
@@ -76,8 +78,8 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
76
78
  format === undefined || xKind === 'category'
77
79
  ? formatTime
78
80
  : xKind === 'time'
79
- ? resolveTimeFormat(xScale, TICK_COUNT, format)
80
- : resolveAxisFormat(xScale, TICK_COUNT, format);
81
+ ? resolveTimeFormat(xScale, xTickCount, format)
82
+ : resolveAxisFormat(xScale, xTickCount, format);
81
83
  // Marker annotations that opted into an axis indicator (`<Marker indicator>`)
82
84
  // pin their **time** to this shared x-axis — a pill at `at`, in the annotation
83
85
  // colour, reading like a tick. An indicator always shows the axis coordinate
@@ -126,7 +128,7 @@ export function XAxis({ format, label, side = 'bottom', height, ticks: customTic
126
128
  const maxPillLane = Math.max(0, pillLaneEnds.length - 1);
127
129
  const rawTicks = customTicks
128
130
  ? customTicks.map((t) => ({ x: xScale(t.at), label: t.label }))
129
- : xScale.ticks(TICK_COUNT).map((d) => ({
131
+ : xScale.ticks(xTickCount).map((d) => ({
130
132
  x: xScale(d),
131
133
  label: fmt(+d),
132
134
  }));
package/dist/box.d.ts CHANGED
@@ -3,9 +3,10 @@ import type { Scale } from './line.js';
3
3
  import type { BoxStyle } from './theme.js';
4
4
  /**
5
5
  * The `[min, max]` vertical extent of the **drawn** boxes — the lowest `lower`
6
- * whisker and highest `upper` whisker over keys where **all five** quantiles are
7
- * finite or `null` if none are. Gap keys (any quantile `NaN`) are excluded,
8
- * matching what {@link drawBox} draws, so they don't drag the y-domain.
6
+ * whisker and highest `upper` whisker over the keys {@link isFiniteBox} draws
7
+ * (a full box needs all five quantiles; a range-only box just `lower`/`upper`)
8
+ * or `null` if none are. Gap keys are excluded, matching what {@link drawBox}
9
+ * draws, so they don't drag the y-domain.
9
10
  *
10
11
  * Only `lower`/`upper` bound the extent: they are the outermost reach of a key
11
12
  * (the whisker ends), so `q1`/`median`/`q3` lie within `[lower, upper]` for any
@@ -44,16 +45,32 @@ export type BoxShape = 'whisker' | 'solid' | 'none';
44
45
  * reads darker on a light ground, brighter on a dark one), no stems/outline.
45
46
  * - **`none`** — the `q1→q3` box fill + outline only, no spread marks.
46
47
  *
47
- * Then, if `showMedian`, the median line across the box on top. Fills are
48
- * bracketed by `save`/`restore` so their `globalAlpha` doesn't leak.
48
+ * **Range-only** (`box.hasBox === false` no `q1`/`q3`): there's no body, so
49
+ * `whisker` draws **one** full `lower→upper` stem with caps, `solid` draws just
50
+ * the outer bar, and `none` draws **nothing** (no body + no spread ⇒ empty — pick
51
+ * `whisker`/`solid` for a range-only box). `showMedian` is a no-op when the box
52
+ * carries no `median` (`hasMedian === false`).
49
53
  *
50
- * **Gap-aware**: a key with any quantile non-finite is skipped entirely (no
51
- * partial box) the same contract as a band gap.
54
+ * Then, if `showMedian` (and a median is present), the median line on top. Fills
55
+ * are bracketed by `save`/`restore` so their `globalAlpha` doesn't leak.
56
+ * `offsetPx` shifts every mark in pixel space (for pairing same-key marks);
57
+ * `capWidthPx` sets a fixed whisker-cap width (else half the box width — a small
58
+ * fixed cap keeps paired offset marks' T-bars from overlapping), clamped to the
59
+ * box width.
60
+ *
61
+ * **Gap-aware**: a key whose present quantiles aren't all finite is skipped
62
+ * entirely (no partial box) — the same contract as a band gap.
52
63
  *
53
64
  * O(N) over the keys, a fixed number of path ops each — no per-key allocation
54
65
  * beyond the `barSpanPx` tuple.
55
66
  */
56
- export declare function drawBox(ctx: CanvasRenderingContext2D, box: BoxSeries, xScale: Scale, yScale: Scale, style: BoxStyle, gapPx?: number, minWidthPx?: number, shape?: BoxShape, showMedian?: boolean): void;
57
- /** All five quantiles finite at `i` — i.e. this key is drawn. */
67
+ export declare function drawBox(ctx: CanvasRenderingContext2D, box: BoxSeries, xScale: Scale, yScale: Scale, style: BoxStyle, gapPx?: number, minWidthPx?: number, shape?: BoxShape, showMedian?: boolean, offsetPx?: number, capWidthPx?: number): void;
68
+ /**
69
+ * This key is drawable — the quantiles it actually carries are all finite at `i`.
70
+ * `lower`/`upper` (the whisker reach) are always required; `q1`/`q3` only when the
71
+ * box has a body (`hasBox !== false`), `median` only when it has a centre line
72
+ * (`hasMedian !== false`). So a **range-only** box (bid→ask, no body/median) draws
73
+ * wherever `lower`/`upper` are finite, and a full box still needs all five.
74
+ */
58
75
  export declare function isFiniteBox(box: BoxSeries, i: number): boolean;
59
76
  //# sourceMappingURL=box.d.ts.map