@pond-ts/charts 0.40.0 → 0.42.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/BarChart.js CHANGED
@@ -1,49 +1,53 @@
1
1
  import { useContext, useEffect, useMemo } from 'react';
2
2
  import { ValueSeries } from 'pond-ts';
3
- import { barsFromTimeSeries, barsFromValueSeries } from './data.js';
4
- import { barAt, barExtent, barIndexAtTime, drawBars, resolveBarBaseline, } from './bars.js';
3
+ import { barsFromTimeSeries, barsFromValueSeries, stacksFromBins, stacksFromColumns, stacksFromGroups, } from './data.js';
4
+ import { barAt, barExtent, barIndexAtTime, drawBars, drawStacks, resolveBarBaseline, stackAt, stackBinExtent, stackValueExtent, } from './bars.js';
5
5
  import { ContainerContext, LayersContext, } from './context.js';
6
6
  import { useSlotKey } from './use-slot-key.js';
7
7
  /**
8
- * A bar draw layer: one rectangle per event, spanning the key's `[begin, end]`
9
- * (inset by `gap`) from the axis baseline to a numeric `column`'s value. Reads
10
- * the key endpoints + column into a {@link BarSeries}, registers into the
11
- * enclosing {@link Layers} (scaling against its `axis`), and renders nothing to
12
- * the DOM the row draws it. A gap (missing value) is skipped (no bar).
8
+ * A bar / histogram draw layer. In its simplest form, one rectangle per event
9
+ * spanning the key's `[begin, end]` from the axis baseline to a numeric
10
+ * `column`'s value (see below). It also draws **stacked** bars (a group-by
11
+ * dimension segments, `columns` / a `Map` series / `bins`) and **horizontal**
12
+ * bars (`orientation='horizontal'`, bins on the y axis) first-class histogram
13
+ * support. Registers into the enclosing {@link Layers} and renders nothing to the
14
+ * DOM; the row draws it.
13
15
  *
14
- * **Baseline.** Bars rest on the zero line when the axis domain spans zero (the
15
- * common all-positive auto-fit case {@link barExtent} pulls `0` into the
16
- * domain), or on the axis floor when an explicit `<YAxis min={…}>` sits above
17
- * zero (see {@link resolveBarBaseline}).
16
+ * **Data sources.** A time / value `TimeSeries` or `ValueSeries` (`column`), a
17
+ * wide series or `bins` array (`columns`), or a `Map<group, TimeSeries>`
18
+ * (`column`) the last three stack. Every shape composes from pond's own
19
+ * aggregation (`aggregate` / `byColumn` / `partitionBy`); the histogram guide
20
+ * has the recipes.
18
21
  *
19
- * **Interaction.** Hover joins the tracker (`sampleAt` the value of the bar
20
- * **under the cursor**) and lights that bar (hover-highlight). Click selects the
21
- * hit bar (`hitTest`); the matching bar — same key **and** this series' `label`,
22
- * so two series sharing a timestamp don't both light up — draws highlighted
23
- * (outlined for the committed select, fill-only for the transient hover). Both
24
- * resolve by **containment**: the tracker by the bar's `[begin, end]` time span
25
- * (`barIndexAtTime`), the click by the bar's pixel rect (`barAt`) — so the
26
- * readout reads the same bar you click, even across a wide bucket (they differ
27
- * only by the `gap` inset, where the pixel rect is narrower than the span).
22
+ * **Baseline (single, vertical).** Bars rest on the zero line when the axis
23
+ * domain spans zero, or on the axis floor when an explicit `<YAxis min>` sits
24
+ * above zero (see {@link resolveBarBaseline}).
28
25
  *
29
- * Both channels are also **controllable from outside** the chart via the
30
- * container: `selected`/`onSelect` (committed) and `hovered`/`onHover` (transient)
31
- * pass either to pin the lit/selected bar from a legend or list row, and read
32
- * the callback to mirror a bar-originated hover/click out-of-band. Symmetric pair,
33
- * keyed by the same {@link SelectInfo} identity.
26
+ * **Baseline (stacked).** A stack is **cumulative from value 0** the segments
27
+ * sum upward from the zero line, so its value axis **must include 0**. The
28
+ * auto-fit guarantees this: {@link stackValueExtent} always returns `[0, maxTotal]`.
29
+ * An explicit `<YAxis min>` **above** 0 is therefore unsupported for a stack — it
30
+ * would hide the bottom of the cumulative column; only the portion above the floor
31
+ * draws (clipped cleanly at the plot floor, as any bar below an explicit floor is).
32
+ * Segment values are assumed **non-negative** (a negative or zero segment is
33
+ * skipped — diverging stacks are out of scope).
34
34
  *
35
- * **Value axis** bars also scale on a value axis when fed a `ValueSeries`
36
- * (`series.byValue('dist')`): estela's distance-domain splits/laps, one bar per
37
- * segment over a monotonic axis. A `ValueSeries` is point-keyed, so the span is
38
- * neighbour-derived like a point `TimeSeries` (see {@link barsFromValueSeries}).
35
+ * **Interaction (opt-in via `id`).** Hover lights the bar / segment under the
36
+ * cursor (hit-tested by pixel rect, so it works in both orientations); click
37
+ * selects it (outlined). A stacked segment's identity is `(id, key = bin begin,
38
+ * label = group)`. Both channels are controllable from outside via the container
39
+ * (`selected`/`onSelect`, `hovered`/`onHover`). The in-chart `flag`/`crosshair`
40
+ * value cursor is single-series-vertical only.
39
41
  *
40
42
  * ```tsx
41
43
  * <Layers>
42
44
  * <BarChart series={hourlyVolume} column="count" />
45
+ * <BarChart series={byHost} column="n" colors={{ web1: '#…' }} />
46
+ * <BarChart bins={powerDist} column="seconds" orientation="horizontal" ordinal />
43
47
  * </Layers>
44
48
  * ```
45
49
  */
46
- export function BarChart({ series, column, as: semantic, axis, gap, index = 0, }) {
50
+ export function BarChart({ series, bins, column, columns, as: semantic, colors, orientation = 'vertical', ordinal = false, id, axis, gap, index = 0, }) {
47
51
  const container = useContext(ContainerContext);
48
52
  if (container === null) {
49
53
  throw new Error('<BarChart> must be rendered inside a <ChartContainer>');
@@ -52,98 +56,231 @@ export function BarChart({ series, column, as: semantic, axis, gap, index = 0, }
52
56
  if (layers === null) {
53
57
  throw new Error('<BarChart> must be rendered inside a <Layers>');
54
58
  }
55
- const bs = useMemo(() => series instanceof ValueSeries
56
- ? barsFromValueSeries(series, column)
57
- : barsFromTimeSeries(series, column), [series, column]);
58
- // Styling: semantic identifier theme bar style. The single styling channel.
59
+ // Validate the data-source / value-column combination up front (throws are
60
+ // stable across renders, so no need to memoize them).
61
+ if ((series === undefined) === (bins === undefined)) {
62
+ throw new Error('<BarChart> needs exactly one of `series` or `bins`');
63
+ }
64
+ const isMap = series instanceof Map;
65
+ if (isMap && columns !== undefined) {
66
+ throw new Error('<BarChart> with a `Map` series stacks its groups — use `column` (the shared value column), not `columns`');
67
+ }
68
+ if (column !== undefined && columns !== undefined) {
69
+ throw new Error('<BarChart> takes `column` or `columns`, not both');
70
+ }
71
+ // The single series' semantic label (its identity for the readout + selection):
72
+ // the `as` role, else the value column. Used only on the single path.
73
+ const label = semantic ?? column ?? id ?? 'value';
74
+ // Build the chart-ready data view. Single-series *vertical* stays on the
75
+ // original BarSeries path (its pixels are unchanged); everything else — any
76
+ // stack, any horizontal — builds a StackedBarSeries (G === 1 for a single
77
+ // horizontal bar) so one oriented draw path covers it.
78
+ const shape = useMemo(() => {
79
+ if (bins !== undefined) {
80
+ const cols = columns ?? (column !== undefined ? [column] : undefined);
81
+ if (cols === undefined) {
82
+ throw new Error('<BarChart bins> needs `column` or `columns`');
83
+ }
84
+ return { kind: 'stacked', ss: stacksFromBins(bins, cols, { ordinal }) };
85
+ }
86
+ if (isMap) {
87
+ if (column === undefined) {
88
+ throw new Error('<BarChart> with a `Map` series needs `column`');
89
+ }
90
+ return {
91
+ kind: 'stacked',
92
+ ss: stacksFromGroups(series, column),
93
+ };
94
+ }
95
+ const s = series;
96
+ if (columns !== undefined) {
97
+ return { kind: 'stacked', ss: stacksFromColumns(s, columns) };
98
+ }
99
+ if (column === undefined) {
100
+ throw new Error('<BarChart> needs `column` or `columns`');
101
+ }
102
+ if (orientation === 'horizontal') {
103
+ // Single horizontal bar: route through the stacked path (G === 1), naming
104
+ // the one group with the series' label so selection matches on it.
105
+ const ss = stacksFromColumns(s, [column]);
106
+ return { kind: 'stacked', ss: { ...ss, groups: [label] } };
107
+ }
108
+ return {
109
+ kind: 'single',
110
+ bs: s instanceof ValueSeries
111
+ ? barsFromValueSeries(s, column)
112
+ : barsFromTimeSeries(s, column),
113
+ };
114
+ }, [series, bins, column, columns, ordinal, orientation, isMap, label]);
115
+ // The bin axis kind (time vs value) — a `TimeSeries`/`Map` bins on time, a
116
+ // `ValueSeries`/`bins`-array on a value axis. For a vertical histogram this is
117
+ // the shared x-kind; a horizontal one puts the *value* on x (always 'value')
118
+ // and the bin axis on a linear y.
119
+ const binAxisKind = bins !== undefined
120
+ ? 'value'
121
+ : isMap
122
+ ? 'time'
123
+ : series instanceof ValueSeries
124
+ ? 'value'
125
+ : 'time';
59
126
  const { bar } = container.theme;
60
- const style = (semantic !== undefined ? bar[semantic] : undefined) ?? bar.default;
61
- // Series identity for the readout + selection match (the `as` role, else the
62
- // column name).
63
- const label = semantic ?? column;
64
- // The gap prop overrides the theme default; otherwise the style carries it.
65
- const gapPx = gap ?? style.gap;
66
- // The current selection, narrowed to what the highlight match needs (key +
67
- // label). Read here so a selection change re-registers the layer (in the deps)
68
- // the data canvas repaints with the highlight. Infrequent (a click).
127
+ // Single-series style: the `as` role theme bar style (the single channel).
128
+ const singleStyle = (semantic !== undefined ? bar[semantic] : undefined) ?? bar.default;
129
+ const gapPx = gap ?? bar.default.gap;
130
+ // The stacked path's bar-thickness floor comes from `bar.default` (not the `as`
131
+ // role `as` is single-series only), matching how `gapPx` sources its default.
132
+ const stackMinWidth = bar.default.minWidth;
133
+ // Stacked style: per-group fills (colors override theme role default),
134
+ // plus the shared opacity / outline from the default bar style. Memoized on the
135
+ // groups + colours so a selection change doesn't rebuild it.
136
+ const groups = shape.kind === 'stacked' ? shape.ss.groups : undefined;
137
+ const stackStyle = useMemo(() => {
138
+ const base = bar.default;
139
+ const fills = (groups ?? []).map((g) => colors?.[g] ?? (bar[g] ?? base).fill);
140
+ return { fills, opacity: base.opacity, outlineWidth: base.outlineWidth };
141
+ }, [bar, groups, colors]);
142
+ // The current selection / hover, narrowed to the identity the highlight match
143
+ // needs. For a stack that's (id, key, label = group); the single path uses just
144
+ // (id, key). Read here so a change re-registers the layer → the canvas repaints.
69
145
  const selected = container.selected;
70
- const selection = useMemo(() => selected === null ? null : { key: selected.key, label: selected.label }, [selected]);
71
- // The transient hover-highlight, narrowed to the match key (key + label) like
72
- // the selection. Read here so a hover change re-registers the layer → the data
73
- // canvas repaints with the lit bar. Deduped in the container, so this only
74
- // fires on a bar transition (not every pointer move).
75
146
  const hoveredMark = container.hovered;
147
+ const selection = useMemo(() => selected === null
148
+ ? null
149
+ : { id: selected.id, key: selected.key, label: selected.label }, [selected]);
76
150
  const hover = useMemo(() => hoveredMark === null
77
151
  ? null
78
- : { key: hoveredMark.key, label: hoveredMark.label }, [hoveredMark]);
79
- const entry = useMemo(() => ({
80
- layer: {
81
- yExtent: () => barExtent(bs),
82
- // The container infers the shared x scale's kind from its layers — a
83
- // ValueSeries bars on a value axis, a TimeSeries on time.
84
- xKind: series instanceof ValueSeries ? 'value' : 'time',
85
- xExtent: () => bs.length === 0 ? null : [bs.begin[0], bs.end[bs.length - 1]],
86
- sampleAt: (time) => {
87
- // The flag belongs to the bar **under the cursor** — the bar whose
88
- // span `[begin, end]` contains `time` (barIndexAtTime), NOT
89
- // nearest-by-begin (which flips to the next bar past a wide bar's
90
- // midpoint, landing the flag on the wrong bar). For a point key the
91
- // span is the neighbour-derived Voronoi cell (`barsFromTimeSeries`
92
- // widens `begin === end` into one), so the cells tile the axis and a
93
- // moving cursor always lands in one. Before the first / after the last
94
- // bar → no readout, matching the line/area tracker.
95
- if (bs.length === 0)
96
- return [];
97
- const i = barIndexAtTime(bs, time);
98
- if (i < 0)
99
- return [];
100
- const v = bs.y[i];
101
- if (!Number.isFinite(v))
102
- return []; // a gap bar (missing value) reads nothing
103
- // Anchor at the bar's **top-centre** (RFC): the span's centre time
104
- // `(begin + end) / 2` (the bucket mid for an interval key; the Voronoi
105
- // cell centre — ~on the point — for a point key), at `yScale(value)` =
106
- // the bar top. A tall bar (top above the flag stack) drops the staff for
107
- // free (the shared `s.py > stackBottom` rule).
108
- return [
109
- {
110
- x: (bs.begin[i] + bs.end[i]) / 2,
111
- value: v,
112
- color: style.fill,
113
- label,
152
+ : {
153
+ id: hoveredMark.id,
154
+ key: hoveredMark.key,
155
+ label: hoveredMark.label,
156
+ }, [hoveredMark]);
157
+ const entry = useMemo(() => {
158
+ // ── Single-series, vertical: the original bar path, pixels unchanged. ──
159
+ if (shape.kind === 'single') {
160
+ const bs = shape.bs;
161
+ return {
162
+ layer: {
163
+ yExtent: () => barExtent(bs),
164
+ xKind: binAxisKind,
165
+ xExtent: () => bs.length === 0 ? null : [bs.begin[0], bs.end[bs.length - 1]],
166
+ sampleAt: (time) => {
167
+ if (bs.length === 0)
168
+ return [];
169
+ const i = barIndexAtTime(bs, time);
170
+ if (i < 0)
171
+ return [];
172
+ const v = bs.y[i];
173
+ if (!Number.isFinite(v))
174
+ return [];
175
+ return [
176
+ {
177
+ x: (bs.begin[i] + bs.end[i]) / 2,
178
+ value: v,
179
+ color: singleStyle.fill,
180
+ label,
181
+ },
182
+ ];
114
183
  },
115
- ];
116
- },
117
- hitTest: (px, py, xScale, yScale) => {
118
- const baseline = resolveBarBaseline(yScale);
119
- const hit = barAt(bs, px, py, xScale, yScale, baseline, gapPx, style.minWidth);
120
- if (hit === null)
121
- return null;
122
- const [, begin, value] = hit;
123
- // key = the bar's begin (its stable identity); colour = the resolved
124
- // fill; label = this series' identity (so the highlight targets the
125
- // exact clicked series, not another sharing the timestamp).
126
- return { key: begin, value, color: style.fill, label };
184
+ ...(id === undefined
185
+ ? {}
186
+ : {
187
+ hitTest: (px, py, xScale, yScale) => {
188
+ const baseline = resolveBarBaseline(yScale);
189
+ const hit = barAt(bs, px, py, xScale, yScale, baseline, gapPx, singleStyle.minWidth);
190
+ if (hit === null)
191
+ return null;
192
+ const [, begin, value] = hit;
193
+ return {
194
+ id,
195
+ key: begin,
196
+ value,
197
+ color: singleStyle.fill,
198
+ label,
199
+ };
200
+ },
201
+ }),
202
+ draw: (ctx, xScale, yScale) => drawBars(ctx, bs, xScale, yScale, singleStyle, resolveBarBaseline(yScale), gapPx, id, selection, hover),
203
+ },
204
+ axisId: axis,
205
+ index,
206
+ };
207
+ }
208
+ // ── Stacked (or single horizontal): the oriented, transposed draw path. ──
209
+ const ss = shape.ss;
210
+ const binExtent = () => stackBinExtent(ss);
211
+ const valueExtent = () => stackValueExtent(ss);
212
+ const vertical = orientation === 'vertical';
213
+ return {
214
+ layer: {
215
+ // Horizontal puts the value on the shared x (always 'value'); vertical
216
+ // keeps the bin axis on x. The bin axis on the *other* side is a linear
217
+ // numeric scale either way (time ms label via <YAxis ticks>).
218
+ xKind: vertical ? binAxisKind : 'value',
219
+ xExtent: vertical ? binExtent : valueExtent,
220
+ yExtent: vertical ? valueExtent : binExtent,
221
+ // No x-scrub flag for a stack / horizontal chart — hover + click read it
222
+ // out instead (the flag is single-series-vertical only).
223
+ sampleAt: () => [],
224
+ ...(id === undefined
225
+ ? {}
226
+ : {
227
+ hitTest: (px, py, xScale, yScale) => {
228
+ const hit = stackAt(ss, px, py, orientation, xScale, yScale, gapPx, stackMinWidth);
229
+ if (hit === null)
230
+ return null;
231
+ const [, g, begin, name, value] = hit;
232
+ return {
233
+ id,
234
+ key: begin,
235
+ value,
236
+ color: stackStyle.fills[g],
237
+ label: name,
238
+ };
239
+ },
240
+ }),
241
+ draw: (ctx, xScale, yScale) => drawStacks(ctx, ss, orientation, xScale, yScale, stackStyle, gapPx, stackMinWidth, id, selection, hover),
127
242
  },
128
- draw: (ctx, xScale, yScale) => drawBars(ctx, bs, xScale, yScale, style, resolveBarBaseline(yScale), gapPx, label, selection, hover),
129
- },
130
- axisId: axis,
243
+ axisId: axis,
244
+ index,
245
+ };
246
+ }, [
247
+ shape,
248
+ binAxisKind,
249
+ orientation,
250
+ singleStyle,
251
+ stackStyle,
252
+ label,
253
+ id,
254
+ gapPx,
255
+ stackMinWidth,
256
+ selection,
257
+ hover,
258
+ axis,
131
259
  index,
132
- }), [bs, series, column, style, label, gapPx, selection, hover, axis, index]);
133
- // A stable per-instance slot (see useSlotKey) keeps this layer's z-position
134
- // fixed across series/style/selection updates (no jump to the front).
260
+ ]);
261
+ // A stable per-instance slot keeps this layer's z-position fixed across data /
262
+ // style / selection updates (see useSlotKey).
135
263
  const slot = useSlotKey();
136
264
  useEffect(() => () => layers.unregisterLayer(slot), [layers, slot]);
137
265
  useEffect(() => {
138
266
  layers.registerLayer(slot, entry);
139
267
  }, [layers, slot, entry]);
140
- // Also a tracker source: the container fans in this series' value at the
141
- // cursor for the (outside-the-chart) readout.
268
+ // Also a tracker source: the container fans in this layer's value at the cursor
269
+ // for the (outside-the-chart) readout. A stacked / horizontal layer's sampleAt
270
+ // returns nothing, so it contributes no flag but still registers cleanly.
142
271
  const { registerTrackerSource, unregisterTrackerSource } = container;
143
272
  useEffect(() => () => unregisterTrackerSource(slot), [unregisterTrackerSource, slot]);
144
273
  useEffect(() => {
145
274
  registerTrackerSource(slot, entry.layer);
146
275
  }, [registerTrackerSource, slot, entry.layer]);
276
+ // Advertise selectability (only when an `id` was given).
277
+ const { registerSelectable, unregisterSelectable } = container;
278
+ useEffect(() => {
279
+ if (id === undefined)
280
+ return;
281
+ registerSelectable(slot);
282
+ return () => unregisterSelectable(slot);
283
+ }, [registerSelectable, unregisterSelectable, slot, id]);
147
284
  return null;
148
285
  }
149
286
  //# sourceMappingURL=BarChart.js.map
@@ -0,0 +1,93 @@
1
+ import type { SeriesSchema, TimeSeries } from 'pond-ts';
2
+ import { type CandleVariant, type ColorBy } from './ohlc.js';
3
+ export interface CandlestickProps<S extends SeriesSchema> {
4
+ /**
5
+ * The source series. **Point-keyed** (`time`) raw OHLCV feeds straight in —
6
+ * each candle's slot is derived from neighbour spacing (see
7
+ * {@link ohlcFromTimeSeries}), no `aggregate` pass needed. An **interval /
8
+ * timeRange**-keyed series (an `aggregate` rollup — weekly / monthly bars) uses
9
+ * the key's own `[begin, end)` as the slot. The chart infers the x-kind from
10
+ * the data; there's no axis-type prop.
11
+ */
12
+ series: TimeSeries<S>;
13
+ /** Opening-price column. **Omitted ⇒ `'open'`.** */
14
+ open?: string;
15
+ /** Session-high column. **Omitted ⇒ `'high'`.** */
16
+ high?: string;
17
+ /** Session-low column. **Omitted ⇒ `'low'`.** */
18
+ low?: string;
19
+ /** Closing-price column. **Omitted ⇒ `'close'`.** */
20
+ close?: string;
21
+ /**
22
+ * The series' semantic identifier — what the data _is_ (e.g. a ticker). The
23
+ * theme maps it to a {@link CandleStyle} (`theme.candle[as] ??
24
+ * theme.candle.default`). **Omitted ⇒ the `default` candle style.** It's also
25
+ * the tracker/readout label for the series (the primary `close` pill keys on
26
+ * `as`, not the raw column name).
27
+ */
28
+ as?: string;
29
+ /**
30
+ * Which `<YAxis>` (by its `id`) this candle scales against — the *scale*, where
31
+ * `as` picks the *style*. **Omitted ⇒ the row's default axis.**
32
+ */
33
+ axis?: string;
34
+ /**
35
+ * How each mark renders — `'candle'` (default; filled body + wick), `'bar'`
36
+ * (OHLC tick bar), or `'hollow'` (rising hollow / falling filled). See
37
+ * {@link CandleVariant}.
38
+ */
39
+ variant?: CandleVariant;
40
+ /**
41
+ * What drives the colour — `'direction'` (default; rising / falling / doji off
42
+ * open vs close, the market convention) or `'series'` (one colour off the `as`
43
+ * role, no green/red). See {@link ColorBy}.
44
+ */
45
+ colorBy?: ColorBy;
46
+ /**
47
+ * Total horizontal inset between adjacent candles in px (half each side), so
48
+ * they breathe — see `barSpanPx`. **Omitted ⇒ `0`** (the body already insets to
49
+ * `style.bodyWidth` of the slot). A candle narrower than 1px after the inset
50
+ * collapses to a 1px mark, so a thin slot stays visible.
51
+ */
52
+ gap?: number;
53
+ /**
54
+ * Fan the **full O/H/L/C** to the tracker readout (four value pills) instead of
55
+ * the default single `close` pill. **Omitted ⇒ `false`** — close is "the price"
56
+ * for a compact legend; the full quote is opt-in for a dense hover readout.
57
+ */
58
+ showOHLC?: boolean;
59
+ /**
60
+ * @internal Declaration position among the `<Layers>` children, injected by
61
+ * `Layers` so z-order follows JSX order. Do not set.
62
+ */
63
+ index?: number;
64
+ }
65
+ /**
66
+ * A first-class OHLC **candlestick** draw layer — the financial sibling of
67
+ * {@link BoxPlot}. Reads four price columns (`open`/`high`/`low`/`close`) of
68
+ * `series` into an {@link OhlcSeries} and draws one candle per key: the
69
+ * `open→close` body (direction-coloured) and the `high–low` wick, over the key's
70
+ * slot x-span. Derives the body extents itself (`min`/`max` of open/close) — the
71
+ * consumer never runs a `withColumn` precompute. Registers into the enclosing
72
+ * {@link Layers}; renders nothing to the DOM — the row draws it. Gap-aware (a key
73
+ * missing any price draws nothing).
74
+ *
75
+ * **Draws only** — windowing stays upstream: raw daily OHLCV is a point-keyed
76
+ * `TimeSeries` fed straight in, and a weekly / monthly bar is the identical call
77
+ * on an `aggregate(Sequence.calendar('week'), …)` rollup (interval-keyed). This
78
+ * supersedes `BoxPlot shape='solid'` for OHLC (which needed a quantile remap, a
79
+ * body precompute, two overlaid layers for green/red, and a column-name tracker).
80
+ *
81
+ * **Cursor.** Unlike `BoxPlot`, a candle **participates in the crosshair x-snap**
82
+ * (it exposes plain `sampleAt`, not a consolidated `cursorFlag`), so the reticle
83
+ * lands on candles. The readout keys on `as` and shows `close` by default; pass
84
+ * `showOHLC` for the full four-pill quote.
85
+ *
86
+ * ```tsx
87
+ * <Layers>
88
+ * <Candlestick series={daily} as="AAPL" />
89
+ * </Layers>
90
+ * ```
91
+ */
92
+ export declare function Candlestick<S extends SeriesSchema>({ series, open, high, low, close, as: semantic, axis, variant, colorBy, gap, showOHLC, index, }: CandlestickProps<S>): null;
93
+ //# sourceMappingURL=Candlestick.d.ts.map
@@ -0,0 +1,102 @@
1
+ import { useContext, useEffect, useMemo } from 'react';
2
+ import { ohlcFromTimeSeries } from './data.js';
3
+ import { drawCandles, isFiniteOhlc, ohlcExtent, ohlcIndexAtTime, resolveCandleStyle, } from './ohlc.js';
4
+ import { ContainerContext, LayersContext, } from './context.js';
5
+ import { useSlotKey } from './use-slot-key.js';
6
+ /**
7
+ * A first-class OHLC **candlestick** draw layer — the financial sibling of
8
+ * {@link BoxPlot}. Reads four price columns (`open`/`high`/`low`/`close`) of
9
+ * `series` into an {@link OhlcSeries} and draws one candle per key: the
10
+ * `open→close` body (direction-coloured) and the `high–low` wick, over the key's
11
+ * slot x-span. Derives the body extents itself (`min`/`max` of open/close) — the
12
+ * consumer never runs a `withColumn` precompute. Registers into the enclosing
13
+ * {@link Layers}; renders nothing to the DOM — the row draws it. Gap-aware (a key
14
+ * missing any price draws nothing).
15
+ *
16
+ * **Draws only** — windowing stays upstream: raw daily OHLCV is a point-keyed
17
+ * `TimeSeries` fed straight in, and a weekly / monthly bar is the identical call
18
+ * on an `aggregate(Sequence.calendar('week'), …)` rollup (interval-keyed). This
19
+ * supersedes `BoxPlot shape='solid'` for OHLC (which needed a quantile remap, a
20
+ * body precompute, two overlaid layers for green/red, and a column-name tracker).
21
+ *
22
+ * **Cursor.** Unlike `BoxPlot`, a candle **participates in the crosshair x-snap**
23
+ * (it exposes plain `sampleAt`, not a consolidated `cursorFlag`), so the reticle
24
+ * lands on candles. The readout keys on `as` and shows `close` by default; pass
25
+ * `showOHLC` for the full four-pill quote.
26
+ *
27
+ * ```tsx
28
+ * <Layers>
29
+ * <Candlestick series={daily} as="AAPL" />
30
+ * </Layers>
31
+ * ```
32
+ */
33
+ export function Candlestick({ series, open = 'open', high = 'high', low = 'low', close = 'close', as: semantic, axis, variant = 'candle', colorBy = 'direction', gap = 0, showOHLC = false, index = 0, }) {
34
+ const container = useContext(ContainerContext);
35
+ if (container === null) {
36
+ throw new Error('<Candlestick> must be rendered inside a <ChartContainer>');
37
+ }
38
+ const layers = useContext(LayersContext);
39
+ if (layers === null) {
40
+ throw new Error('<Candlestick> must be rendered inside a <Layers>');
41
+ }
42
+ const ohlc = useMemo(() => ohlcFromTimeSeries(series, { open, high, low, close }), [series, open, high, low, close]);
43
+ // Styling: semantic identifier → theme candle style. The single styling channel.
44
+ const { candle } = container.theme;
45
+ const style = (semantic !== undefined ? candle[semantic] : undefined) ?? candle.default;
46
+ // Series identity for the readout (the `as` role, else the close column name) —
47
+ // the primary `close` pill keys on this, like every other layer.
48
+ const label = semantic ?? close;
49
+ const entry = useMemo(() => ({
50
+ layer: {
51
+ yExtent: () => ohlcExtent(ohlc),
52
+ xKind: 'time',
53
+ xExtent: () => ohlc.length === 0 ? null : [ohlc.x[0], ohlc.xEnd[ohlc.length - 1]],
54
+ sampleAt: (time) => {
55
+ // The readout reads the candle **under the cursor** (containment span,
56
+ // not nearest-by-begin), anchored at the slot centre. Outside every
57
+ // candle → no readout. No `cursorFlag`: the samples flow through the
58
+ // normal per-series tracker path, which is also what keeps the candle
59
+ // in the crosshair x-snap (BoxPlot's cursorFlag opts out of both).
60
+ if (ohlc.length === 0)
61
+ return [];
62
+ const i = ohlcIndexAtTime(ohlc, time);
63
+ if (i < 0 || !isFiniteOhlc(ohlc, i))
64
+ return [];
65
+ const at = (ohlc.x[i] + ohlc.xEnd[i]) / 2;
66
+ const { body, wick } = resolveCandleStyle(style, ohlc.open[i], ohlc.close[i], colorBy);
67
+ if (!showOHLC) {
68
+ // Default: `close` is "the price", keyed on the series id.
69
+ return [{ x: at, value: ohlc.close[i], color: body, label }];
70
+ }
71
+ // Opt-in full quote: four value pills (body colour for open/close, wick
72
+ // colour for the high/low extremes). Each is a value-only axis pill.
73
+ const samples = [
74
+ { x: at, value: ohlc.high[i], color: wick, label: 'high' },
75
+ { x: at, value: ohlc.open[i], color: body, label: 'open' },
76
+ { x: at, value: ohlc.close[i], color: body, label: 'close' },
77
+ { x: at, value: ohlc.low[i], color: wick, label: 'low' },
78
+ ];
79
+ return samples;
80
+ },
81
+ draw: (ctx, xScale, yScale) => drawCandles(ctx, ohlc, xScale, yScale, style, variant, colorBy, gap),
82
+ },
83
+ axisId: axis,
84
+ index,
85
+ }), [ohlc, style, label, variant, colorBy, gap, showOHLC, axis, index]);
86
+ // Stable per-instance slot (see useSlotKey): keeps this candle layer's
87
+ // z-position + identity across prop updates; the injected index drives the sort.
88
+ const slot = useSlotKey();
89
+ useEffect(() => () => layers.unregisterLayer(slot), [layers, slot]);
90
+ useEffect(() => {
91
+ layers.registerLayer(slot, entry);
92
+ }, [layers, slot, entry]);
93
+ // Also a tracker source: the container fans in this series' OHLC at the cursor
94
+ // for the (outside-the-chart) readout.
95
+ const { registerTrackerSource, unregisterTrackerSource } = container;
96
+ useEffect(() => () => unregisterTrackerSource(slot), [unregisterTrackerSource, slot]);
97
+ useEffect(() => {
98
+ registerTrackerSource(slot, entry.layer);
99
+ }, [registerTrackerSource, slot, entry.layer]);
100
+ return null;
101
+ }
102
+ //# sourceMappingURL=Candlestick.js.map