@pond-ts/charts 0.43.0 → 0.44.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
@@ -2,7 +2,6 @@ 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';
@@ -260,13 +259,17 @@ export function Layers({ children }) {
260
259
  }
261
260
  return;
262
261
  }
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') {
262
+ // Region-cursor drag-select (opt-in via `onRegionSelect`): anchor the
263
+ // selection at the press; the band then extends as the pointer moves (bucket
264
+ // by bucket with a sequence, freeform without), and release commits the span.
265
+ // Works on a continuous x axis — time **or** value (a category axis is
266
+ // excluded; its ordinal-slot select is a different gesture). A
267
+ // `regionSelectModifier` (only while `panZoom` is on) gates it behind the key
268
+ // so plain drag can still pan; otherwise it preempts pan (returns before the
269
+ // pan is armed below).
270
+ if (c.cursor === 'region' &&
271
+ c.onRegionSelect &&
272
+ (c.xKind === 'time' || c.xKind === 'value')) {
270
273
  const needsShift = c.regionSelectModifier === 'shift' && c.panZoom;
271
274
  if (!needsShift || e.shiftKey) {
272
275
  const px = Math.max(0, Math.min(c.plotWidth, e.clientX - e.currentTarget.getBoundingClientRect().left));
@@ -417,7 +420,7 @@ export function Layers({ children }) {
417
420
  /* ignore */
418
421
  }
419
422
  if (span)
420
- c.onRegionSelect?.(new TimeRange({ start: span.start, end: span.end }));
423
+ c.onRegionSelect?.([span.start, span.end]);
421
424
  return;
422
425
  }
423
426
  if (c.creating !== null) {
@@ -623,12 +626,13 @@ export function Layers({ children }) {
623
626
  side: axisSides.get(defaultAxisId) ?? 'left',
624
627
  };
625
628
  })();
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';
629
+ // `region` cursor (continuous x axis — time or value): shade the span under the
630
+ // pointer. With a `cursorSequence` (time axis only) the band snaps to the bucket
631
+ // (and extends bucket by bucket under a drag); with none always the case on a
632
+ // value axis it's the **freeform** case: a bare hover draws a plain line
633
+ // (`regionLine`), a drag shades the raw `[anchor, pointer]`. Edges map through
634
+ // `xScale`, so on a trading-time axis the band crops to live time.
635
+ const regionActive = parts.band && (container.xKind === 'time' || container.xKind === 'value');
632
636
  const band = regionActive && cursorTime !== null
633
637
  ? bandRect(container.cursorBuckets ?? [], cursorTime, (v) => xScale(v), plotWidth, container.regionAnchor ?? undefined)
634
638
  : 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/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
package/dist/box.js CHANGED
@@ -3,9 +3,10 @@ import { barSpanPx } from './range.js';
3
3
  const WHISKER_CAP_FRACTION = 0.5;
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
@@ -56,70 +57,101 @@ export function boxIndexAtTime(box, time) {
56
57
  * reads darker on a light ground, brighter on a dark one), no stems/outline.
57
58
  * - **`none`** — the `q1→q3` box fill + outline only, no spread marks.
58
59
  *
59
- * Then, if `showMedian`, the median line across the box on top. Fills are
60
- * bracketed by `save`/`restore` so their `globalAlpha` doesn't leak.
60
+ * **Range-only** (`box.hasBox === false` no `q1`/`q3`): there's no body, so
61
+ * `whisker` draws **one** full `lower→upper` stem with caps, `solid` draws just
62
+ * the outer bar, and `none` draws **nothing** (no body + no spread ⇒ empty — pick
63
+ * `whisker`/`solid` for a range-only box). `showMedian` is a no-op when the box
64
+ * carries no `median` (`hasMedian === false`).
61
65
  *
62
- * **Gap-aware**: a key with any quantile non-finite is skipped entirely (no
63
- * partial box) the same contract as a band gap.
66
+ * Then, if `showMedian` (and a median is present), the median line on top. Fills
67
+ * are bracketed by `save`/`restore` so their `globalAlpha` doesn't leak.
68
+ * `offsetPx` shifts every mark in pixel space (for pairing same-key marks);
69
+ * `capWidthPx` sets a fixed whisker-cap width (else half the box width — a small
70
+ * fixed cap keeps paired offset marks' T-bars from overlapping), clamped to the
71
+ * box width.
72
+ *
73
+ * **Gap-aware**: a key whose present quantiles aren't all finite is skipped
74
+ * entirely (no partial box) — the same contract as a band gap.
64
75
  *
65
76
  * O(N) over the keys, a fixed number of path ops each — no per-key allocation
66
77
  * beyond the `barSpanPx` tuple.
67
78
  */
68
- export function drawBox(ctx, box, xScale, yScale, style, gapPx = 0, minWidthPx = 1, shape = 'whisker', showMedian = true) {
79
+ export function drawBox(ctx, box, xScale, yScale, style, gapPx = 0, minWidthPx = 1, shape = 'whisker', showMedian = true, offsetPx = 0, capWidthPx) {
80
+ // A range-only box (bid→ask segment) has no body / median; the whisker (or the
81
+ // solid bar) runs the full lower→upper. Flags default true (a full box).
82
+ const hasBox = box.hasBox !== false;
83
+ const drawMedian = showMedian && box.hasMedian !== false;
69
84
  for (let i = 0; i < box.length; i += 1) {
70
85
  if (!isFiniteBox(box, i))
71
86
  continue;
72
- const [x0, x1] = barSpanPx(box.x[i], box.xEnd[i], xScale, gapPx, minWidthPx);
87
+ const [span0, span1] = barSpanPx(box.x[i], box.xEnd[i], xScale, gapPx, minWidthPx);
88
+ // `offsetPx` nudges the whole mark in pixel space (zoom-stable) — for pairing
89
+ // same-key marks (call/put at one strike) side by side without overlap.
90
+ const x0 = span0 + offsetPx;
91
+ const x1 = span1 + offsetPx;
73
92
  const mid = (x0 + x1) / 2;
74
93
  const yLower = yScale(box.lower[i]);
75
- const yQ1 = yScale(box.q1[i]);
76
- const yMedian = yScale(box.median[i]);
77
- const yQ3 = yScale(box.q3[i]);
78
94
  const yUpper = yScale(box.upper[i]);
95
+ // q1/q3 are NaN on a range-only box — read them only when there's a body.
96
+ const yQ1 = hasBox ? yScale(box.q1[i]) : 0;
97
+ const yQ3 = hasBox ? yScale(box.q3[i]) : 0;
79
98
  if (shape === 'solid') {
80
- // Candlestick: a light outer bar over the full lower→upper spread, then a
81
- // more-prominent inner q1→q3 box on top (same fill at rising opacity, so the
82
- // inner reads darker on a light ground / brighter on a dark one). No stems,
83
- // no outline.
99
+ // Candlestick: a light outer bar over the full lower→upper spread, then
100
+ // when there's a body — a more-prominent inner q1→q3 box on top (same fill
101
+ // at rising opacity). No stems, no outline.
84
102
  ctx.save();
85
103
  ctx.fillStyle = style.fill;
86
104
  ctx.globalAlpha = style.fillOpacity;
87
105
  ctx.fillRect(x0, yUpper, x1 - x0, yLower - yUpper);
88
- ctx.globalAlpha = Math.min(1, style.fillOpacity * 2);
89
- ctx.fillRect(x0, yQ3, x1 - x0, yQ1 - yQ3);
106
+ if (hasBox) {
107
+ ctx.globalAlpha = Math.min(1, style.fillOpacity * 2);
108
+ ctx.fillRect(x0, yQ3, x1 - x0, yQ1 - yQ3);
109
+ }
90
110
  ctx.restore();
91
111
  }
92
112
  else {
93
- // `whisker` / `none`: the graded q1→q3 box fill + outline.
94
- ctx.save();
95
- ctx.fillStyle = style.fill;
96
- ctx.globalAlpha = style.fillOpacity;
97
- ctx.fillRect(x0, yQ3, x1 - x0, yQ1 - yQ3);
98
- ctx.restore();
99
- ctx.strokeStyle = style.stroke;
100
- ctx.lineWidth = style.strokeWidth;
101
- ctx.strokeRect(x0, yQ3, x1 - x0, yQ1 - yQ3);
113
+ // `whisker` / `none`: the graded q1→q3 box fill + outline (body only).
114
+ if (hasBox) {
115
+ ctx.save();
116
+ ctx.fillStyle = style.fill;
117
+ ctx.globalAlpha = style.fillOpacity;
118
+ ctx.fillRect(x0, yQ3, x1 - x0, yQ1 - yQ3);
119
+ ctx.restore();
120
+ ctx.strokeStyle = style.stroke;
121
+ ctx.lineWidth = style.strokeWidth;
122
+ ctx.strokeRect(x0, yQ3, x1 - x0, yQ1 - yQ3);
123
+ }
102
124
  if (shape === 'whisker') {
103
- // Whiskers: a stem from each box edge to the whisker end, with a cap.
104
- const capHalf = ((x1 - x0) * WHISKER_CAP_FRACTION) / 2;
125
+ // Whiskers with end-caps. With a body: two stems (q3→upper, q1→lower).
126
+ // Range-only (no body): one stem spanning the full lower→upper.
127
+ // Cap half-width: an explicit `capWidthPx` (a fixed pixel cap — for
128
+ // pairing offset marks without their T-bars overlapping) else a fraction
129
+ // of the box width (responsive default). Never wider than the box.
130
+ const capHalf = capWidthPx !== undefined
131
+ ? Math.min(capWidthPx, x1 - x0) / 2
132
+ : ((x1 - x0) * WHISKER_CAP_FRACTION) / 2;
105
133
  ctx.strokeStyle = style.whisker;
106
134
  ctx.lineWidth = style.whiskerWidth;
107
135
  ctx.beginPath();
108
- // Upper: stem q3 upper, cap at upper.
109
- ctx.moveTo(mid, yQ3);
136
+ // Upper stem: from the box top (q3) or, range-only, from lower.
137
+ ctx.moveTo(mid, hasBox ? yQ3 : yLower);
110
138
  ctx.lineTo(mid, yUpper);
111
139
  ctx.moveTo(mid - capHalf, yUpper);
112
140
  ctx.lineTo(mid + capHalf, yUpper);
113
- // Lower: stem q1 lower, cap at lower.
114
- ctx.moveTo(mid, yQ1);
115
- ctx.lineTo(mid, yLower);
141
+ // Lower cap (and, with a body, the lower stem q1→lower).
142
+ if (hasBox) {
143
+ ctx.moveTo(mid, yQ1);
144
+ ctx.lineTo(mid, yLower);
145
+ }
116
146
  ctx.moveTo(mid - capHalf, yLower);
117
147
  ctx.lineTo(mid + capHalf, yLower);
118
148
  ctx.stroke();
119
149
  }
120
150
  }
121
- // The median line across the box, on top — always optional.
122
- if (showMedian) {
151
+ // The median line across the box, on top — drawn only when the box carries a
152
+ // median column and `showMedian` is on.
153
+ if (drawMedian) {
154
+ const yMedian = yScale(box.median[i]);
123
155
  ctx.strokeStyle = style.median;
124
156
  ctx.lineWidth = style.medianWidth;
125
157
  ctx.beginPath();
@@ -129,12 +161,24 @@ export function drawBox(ctx, box, xScale, yScale, style, gapPx = 0, minWidthPx =
129
161
  }
130
162
  }
131
163
  }
132
- /** All five quantiles finite at `i` — i.e. this key is drawn. */
164
+ /**
165
+ * This key is drawable — the quantiles it actually carries are all finite at `i`.
166
+ * `lower`/`upper` (the whisker reach) are always required; `q1`/`q3` only when the
167
+ * box has a body (`hasBox !== false`), `median` only when it has a centre line
168
+ * (`hasMedian !== false`). So a **range-only** box (bid→ask, no body/median) draws
169
+ * wherever `lower`/`upper` are finite, and a full box still needs all five.
170
+ */
133
171
  export function isFiniteBox(box, i) {
134
- return (Number.isFinite(box.lower[i]) &&
135
- Number.isFinite(box.q1[i]) &&
136
- Number.isFinite(box.median[i]) &&
137
- Number.isFinite(box.q3[i]) &&
138
- Number.isFinite(box.upper[i]));
172
+ if (!Number.isFinite(box.lower[i]) || !Number.isFinite(box.upper[i])) {
173
+ return false;
174
+ }
175
+ if (box.hasBox !== false &&
176
+ (!Number.isFinite(box.q1[i]) || !Number.isFinite(box.q3[i]))) {
177
+ return false;
178
+ }
179
+ if (box.hasMedian !== false && !Number.isFinite(box.median[i])) {
180
+ return false;
181
+ }
182
+ return true;
139
183
  }
140
184
  //# sourceMappingURL=box.js.map
package/dist/context.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ScaleLinear, ScaleTime } from 'd3-scale';
2
2
  import type { ChartTheme } from './theme.js';
3
3
  import type { AxisFormat } from './format.js';
4
- import type { Interval, TimeRange } from 'pond-ts';
4
+ import type { Interval } from 'pond-ts';
5
5
  import type { TradingTimeScale, DiscontinuityProvider } from './tradingTimeScale.js';
6
6
  import type { ScaleBand } from './bandScale.js';
7
7
  /**
@@ -75,22 +75,28 @@ export interface ContainerFrame {
75
75
  */
76
76
  readonly cursorBuckets: readonly Interval[] | undefined;
77
77
  /**
78
- * The `region`-cursor **drag anchor** (epoch ms), or `null` when not dragging.
79
- * A drag on a region cursor (only when {@link onRegionSelect} is set) records
80
- * the press time here; the band then spans from the anchor's bucket to the
81
- * pointer's bucket (extending bucket by bucket). Cleared on release.
78
+ * The `region`-cursor **drag anchor** in axis units (epoch ms on a time axis,
79
+ * the axis value on a value axis), or `null` when not dragging. A drag on a
80
+ * region cursor (only when {@link onRegionSelect} is set) records the press
81
+ * position here; the band then spans from the anchor's bucket to the pointer's
82
+ * bucket (extending bucket by bucket), or freeform when there are no buckets.
83
+ * Cleared on release.
82
84
  */
83
85
  readonly regionAnchor: number | null;
84
86
  /** Set / clear the region-drag anchor (see {@link regionAnchor}). */
85
- setRegionAnchor(time: number | null): void;
87
+ setRegionAnchor(value: number | null): void;
86
88
  /**
87
89
  * One-shot callback fired when a `region`-cursor **drag** is released, with the
88
- * selected `[start, end)` `TimeRange` (snapped to the `cursorSequence` buckets).
90
+ * selected `[lo, hi]` span in **axis units** — epoch ms on a time axis, the axis
91
+ * value on a value axis (snapped to the `cursorSequence` buckets when present,
92
+ * else the raw drag span). The neutral numeric pair mirrors the container's
93
+ * polymorphic `range` input (which never takes the axis *kind* from its value);
94
+ * a time-axis consumer who wants a `TimeRange` constructs one from the pair.
89
95
  * Providing it is what makes the region cursor **draggable**; the cursor does
90
96
  * not keep the range (it reverts to the single-bucket highlight). Typical use:
91
- * zoom the view to the returned range.
97
+ * zoom the view, or map the span onto a subscription's range params.
92
98
  */
93
- readonly onRegionSelect: ((range: TimeRange) => void) | undefined;
99
+ readonly onRegionSelect: ((range: readonly [number, number]) => void) | undefined;
94
100
  /**
95
101
  * Require a modifier key held to start a region-drag — set to `'shift'` to make
96
102
  * plain drag **pan** and **shift**-drag select, when `panZoom` is on. Only
@@ -369,6 +375,17 @@ export interface RowLayer {
369
375
  * must agree on this list (a mix is an error), the same way {@link xKind} must.
370
376
  */
371
377
  xCategories?(): readonly string[] | null;
378
+ /**
379
+ * A bar/histogram layer's bar `[begin, end)` spans, as pond `Interval`s — the
380
+ * **region cursor's snap buckets**. When present (and no `cursorSequence` is
381
+ * set), a region drag snaps bar by bar and a hover highlights the bar under the
382
+ * pointer, so a histogram gets bin-aligned selection for free. Only a
383
+ * **vertical** bar layer on a **continuous** (time / value) x axis publishes
384
+ * them — a horizontal chart puts the value on x (snapping counts is meaningless)
385
+ * and a **category** (ordinal-slot) axis is excluded from the region cursor.
386
+ * `null` / absent otherwise.
387
+ */
388
+ binIntervals?(): readonly Interval[] | null;
372
389
  /**
373
390
  * The layer's value(s) at `time` — the nearest sample — for the scrub tracker:
374
391
  * one for a line, two (lower/upper) for a band, empty at a gap. Each carries
@@ -442,6 +459,8 @@ export interface TrackerSource {
442
459
  xExtent(): readonly [number, number] | null;
443
460
  /** A `'category'` source's ordered category names (see {@link RowLayer.xCategories}). */
444
461
  xCategories?(): readonly string[] | null;
462
+ /** A bar/histogram source's bar `[begin, end)` spans (see {@link RowLayer.binIntervals}). */
463
+ binIntervals?(): readonly Interval[] | null;
445
464
  }
446
465
  /**
447
466
  * One selection — what {@link RowLayer.hitTest} returns and `onSelect` reports.