@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/data.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { ValueSeries } from 'pond-ts';
1
2
  /**
2
3
  * Read a numeric column into a `Float64Array`, missing cells as `NaN`.
3
4
  *
@@ -170,6 +171,41 @@ export function boxFromTimeSeries(series, columns) {
170
171
  length: series.length,
171
172
  };
172
173
  }
174
+ /**
175
+ * Build an {@link OhlcSeries} from a pond `TimeSeries` — four numeric price
176
+ * columns (`open`/`high`/`low`/`close`) plus the candle's horizontal slot.
177
+ *
178
+ * **Key-shape aware, like {@link barsFromTimeSeries}.** An **interval /
179
+ * timeRange**-keyed series (an `aggregate` rollup — weekly / monthly bars) uses
180
+ * the key's own `[begin, end)` as the slot. A **point**-keyed (`time`) series —
181
+ * raw daily OHLCV — has `begin === end` (zero width), so the slot is derived from
182
+ * neighbour spacing (each candle centred on its timestamp, reaching halfway to
183
+ * each neighbour; see {@link neighbourSpans}). This is the ergonomic win over the
184
+ * interval-only {@link boxFromTimeSeries}: raw OHLC feeds straight in with no
185
+ * `aggregate` pass.
186
+ *
187
+ * A key with any of the four prices missing reads as a gap (the candle draws
188
+ * nothing). Detected by `keyColumn().kind === 'time'`.
189
+ *
190
+ * @throws RangeError if any price column does not exist.
191
+ * @throws TypeError if any price column is not a numeric column.
192
+ */
193
+ export function ohlcFromTimeSeries(series, columns) {
194
+ const open = readNumericColumn(series, columns.open);
195
+ const high = readNumericColumn(series, columns.high);
196
+ const low = readNumericColumn(series, columns.low);
197
+ const close = readNumericColumn(series, columns.close);
198
+ const n = series.length;
199
+ if (series.keyColumn().kind !== 'time') {
200
+ // Interval / timeRange: the key's own endpoints are the candle slot.
201
+ const { begin, end } = keyBeginEnd(series);
202
+ return { x: begin, xEnd: end, open, high, low, close, length: n };
203
+ }
204
+ // Point key (begin === end): synthesize the slot from neighbour spacing so raw
205
+ // daily OHLCV renders as contiguous candles without a pre-key to intervals.
206
+ const { begin, end } = neighbourSpans(series.keyColumn().begin, n);
207
+ return { x: begin, xEnd: end, open, high, low, close, length: n };
208
+ }
173
209
  /**
174
210
  * Per-row begin/end buffers for the key column, each aligned to the logical
175
211
  * length (zero-copy views). For an interval / timeRange key these are the key's
@@ -184,6 +220,30 @@ function keyBeginEnd(series) {
184
220
  // (point-in-time), which the caller's point-key fallback replaces.
185
221
  return { begin: key.begin.subarray(0, n), end: key.end.subarray(0, n) };
186
222
  }
223
+ /**
224
+ * Synthesize per-point `[begin, end]` spans from a monotonic axis buffer by
225
+ * **neighbour spacing**: each point is centred on its own value and reaches
226
+ * halfway to each neighbour (a Voronoi cell on the axis). The first / last points
227
+ * mirror their single adjacent gap so the end cells match their interior width; a
228
+ * lone point (length 1) keeps zero width (the renderer's `minWidth` floor takes
229
+ * over). Shared by the point-keyed `TimeSeries` bars, the `ValueSeries` bars, and
230
+ * the point-keyed OHLC reader. `axis` is a zero-copy key buffer (must not be
231
+ * mutated) — fresh output buffers are allocated.
232
+ */
233
+ function neighbourSpans(axis, n) {
234
+ const begin = new Float64Array(n);
235
+ const end = new Float64Array(n);
236
+ for (let i = 0; i < n; i += 1) {
237
+ const x = axis[i];
238
+ // Half-gap to the previous neighbour (mirror the next gap at the left edge).
239
+ const prevGap = i > 0 ? x - axis[i - 1] : i + 1 < n ? axis[i + 1] - x : 0;
240
+ // Half-gap to the next neighbour (mirror the previous gap at the right edge).
241
+ const nextGap = i + 1 < n ? axis[i + 1] - x : i > 0 ? x - axis[i - 1] : 0;
242
+ begin[i] = x - prevGap / 2;
243
+ end[i] = x + nextGap / 2;
244
+ }
245
+ return { begin, end };
246
+ }
187
247
  /**
188
248
  * Build a {@link BarSeries} from a pond `TimeSeries` — one bar per event, the
189
249
  * key's `[begin, end]` as the x-span and `column` as the height.
@@ -215,20 +275,8 @@ export function barsFromTimeSeries(series, column) {
215
275
  return { begin, end, y, length: n };
216
276
  }
217
277
  // Point key (begin === end): synthesize a span from neighbour spacing so the
218
- // bars have width. Copy into fresh buffers — the key's begin buffer is shared
219
- // (zero-copy) and must not be mutated.
220
- const src = series.keyColumn().begin;
221
- const begin = new Float64Array(n);
222
- const end = new Float64Array(n);
223
- for (let i = 0; i < n; i += 1) {
224
- const t = src[i];
225
- // Half-gap to the previous point (mirror the next gap at the left edge).
226
- const prevGap = i > 0 ? t - src[i - 1] : i + 1 < n ? src[i + 1] - t : 0;
227
- // Half-gap to the next point (mirror the previous gap at the right edge).
228
- const nextGap = i + 1 < n ? src[i + 1] - t : i > 0 ? t - src[i - 1] : 0;
229
- begin[i] = t - prevGap / 2;
230
- end[i] = t + nextGap / 2;
231
- }
278
+ // bars have width (see neighbourSpans).
279
+ const { begin, end } = neighbourSpans(series.keyColumn().begin, n);
232
280
  return { begin, end, y, length: n };
233
281
  }
234
282
  /**
@@ -253,20 +301,155 @@ export function barsFromTimeSeries(series, column) {
253
301
  export function barsFromValueSeries(series, column) {
254
302
  const y = readValueColumn(series, column);
255
303
  const n = series.length;
256
- // axisValues() is the monotonic key buffer (zero-copy) must not be mutated,
257
- // so synthesise the spans into fresh buffers.
258
- const ax = series.axisValues();
304
+ // axisValues() is the monotonic key buffer (zero-copy); neighbourSpans reads it
305
+ // and allocates fresh span buffers (never mutates the source).
306
+ const { begin, end } = neighbourSpans(series.axisValues(), n);
307
+ return { begin, end, y, length: n };
308
+ }
309
+ /**
310
+ * The per-bin `[begin, end]` slots for a `TimeSeries`, key-shape aware — the same
311
+ * rule {@link barsFromTimeSeries} applies: an interval / timeRange key uses its
312
+ * own endpoints; a point (`time`) key synthesizes a span from neighbour spacing
313
+ * (see {@link neighbourSpans}). Shared by the stacked readers so a stack draws
314
+ * true bucket spans over an `aggregate` rollup and contiguous bars over a raw
315
+ * point series.
316
+ */
317
+ function seriesSlots(series) {
318
+ const n = series.length;
319
+ if (series.keyColumn().kind !== 'time') {
320
+ return keyBeginEnd(series);
321
+ }
322
+ return neighbourSpans(series.keyColumn().begin, n);
323
+ }
324
+ /**
325
+ * Build a {@link StackedBarSeries} from a **`Map` of grouped series** — one series
326
+ * per stack group. This is the natural reader for pond's grouped-aggregate output:
327
+ * `series.partitionBy('host', { groups }).aggregate(Sequence.every('5m'), { n: 'count' }).toMap()`
328
+ * yields a `Map<host, TimeSeries>`, one interval-keyed series per host. The stack
329
+ * order (`groups`, bottom → top) is the map's **insertion order** (stable when you
330
+ * pass `partitionBy`'s `{ groups }` option).
331
+ *
332
+ * **Aligned by bucket key, not by index.** Each partition's `aggregate` spans only
333
+ * *its own* events' range, so the groups generally have **different** grids (host A
334
+ * might have buckets 0–8, host B buckets 3–9). This reader takes the **union** of
335
+ * every group's `[begin, end)` slots (ascending) and places each group's `column`
336
+ * value at the matching `begin`; a bucket a group is missing reads as a gap
337
+ * (`NaN`, contributing nothing to that stack). So the segments always line up on
338
+ * the real bucket, never on a positional accident. (Pass `aggregate`'s
339
+ * `{ range }` option if you want every group padded to one dense grid — the union
340
+ * is then that grid.) When two groups carry the **same `begin`**, the first
341
+ * group's `end` sets that slot's width — correct for the uniform-width buckets
342
+ * `aggregate` / `pivotByGroup` produce (all groups share the grid width), which is
343
+ * the intended input.
344
+ *
345
+ * @throws Error if `groups` is empty.
346
+ * @throws RangeError / TypeError (via {@link readNumericColumn}) if `column` is
347
+ * missing or non-numeric in any member.
348
+ */
349
+ export function stacksFromGroups(groups, column) {
350
+ const names = [...groups.keys()];
351
+ if (names.length === 0) {
352
+ throw new Error('stacksFromGroups: `groups` map is empty');
353
+ }
354
+ const series = [...groups.values()];
355
+ const G = names.length;
356
+ // Union of all groups' slots, keyed by begin (each begin → its end).
357
+ const ends = new Map();
358
+ const perGroupSlots = series.map((s) => seriesSlots(s));
359
+ for (let g = 0; g < G; g += 1) {
360
+ const { begin, end } = perGroupSlots[g];
361
+ for (let i = 0; i < series[g].length; i += 1) {
362
+ if (!ends.has(begin[i]))
363
+ ends.set(begin[i], end[i]);
364
+ }
365
+ }
366
+ const begins = [...ends.keys()].sort((a, b) => a - b);
367
+ const n = begins.length;
368
+ const beginArr = new Float64Array(n);
369
+ const endArr = new Float64Array(n);
370
+ const slotOf = new Map();
371
+ for (let i = 0; i < n; i += 1) {
372
+ beginArr[i] = begins[i];
373
+ endArr[i] = ends.get(begins[i]);
374
+ slotOf.set(begins[i], i);
375
+ }
376
+ const values = new Float64Array(n * G);
377
+ values.fill(NaN);
378
+ for (let g = 0; g < G; g += 1) {
379
+ const { begin } = perGroupSlots[g];
380
+ const col = readNumericColumn(series[g], column);
381
+ for (let i = 0; i < series[g].length; i += 1) {
382
+ const slot = slotOf.get(begin[i]);
383
+ if (slot !== undefined)
384
+ values[slot * G + g] = col[i];
385
+ }
386
+ }
387
+ return { begin: beginArr, end: endArr, groups: names, values, length: n };
388
+ }
389
+ /**
390
+ * Build a {@link StackedBarSeries} from a **wide** series — one numeric column
391
+ * per stack group. This is the reader for pond's `pivotByGroup` output (long →
392
+ * wide reshape: each group value becomes its own column), or any series that is
393
+ * already wide (e.g. `in` / `out` traffic). `columns` names the segment columns
394
+ * **bottom → top**; a `ValueSeries` bins on its value axis (neighbour-spaced
395
+ * slots), a `TimeSeries` on its key (interval spans or neighbour-spaced points).
396
+ *
397
+ * @throws RangeError / TypeError if any column is missing or non-numeric.
398
+ */
399
+ export function stacksFromColumns(series, columns) {
400
+ const n = series.length;
401
+ const G = columns.length;
402
+ const isValue = series instanceof ValueSeries;
403
+ const { begin, end } = isValue
404
+ ? neighbourSpans(series.axisValues(), n)
405
+ : seriesSlots(series);
406
+ const values = new Float64Array(n * G);
407
+ for (let g = 0; g < G; g += 1) {
408
+ const col = isValue
409
+ ? readValueColumn(series, columns[g])
410
+ : readNumericColumn(series, columns[g]);
411
+ for (let i = 0; i < n; i += 1) {
412
+ values[i * G + g] = col[i];
413
+ }
414
+ }
415
+ return { begin, end, groups: columns, values, length: n };
416
+ }
417
+ /**
418
+ * Build a {@link StackedBarSeries} from **`byColumn` bin records** — the array of
419
+ * `{ start, end, …aggregates }` a value-band aggregation returns
420
+ * (`series.byColumn('power', { width: 20 }, { seconds: { from: 'dt', using: 'sum' } })`).
421
+ * `columns` names the aggregate field(s) to draw as segments (`['seconds']` for a
422
+ * plain distribution; several for a stacked value-band histogram).
423
+ *
424
+ * By default each bin keeps its real numeric `[start, end]` edges — a true value
425
+ * axis (power W, risk %). Pass `{ ordinal: true }` for uniform unit slots
426
+ * (`[i, i+1]`) when the bins are **categories** whose numeric width shouldn't
427
+ * distort the layout (heart-rate zones); label them with `<YAxis ticks>`.
428
+ *
429
+ * A missing / non-finite aggregate reads as a gap (`NaN`).
430
+ */
431
+ export function stacksFromBins(bins, columns, options = {}) {
432
+ const n = bins.length;
433
+ const G = columns.length;
259
434
  const begin = new Float64Array(n);
260
435
  const end = new Float64Array(n);
436
+ const values = new Float64Array(n * G);
261
437
  for (let i = 0; i < n; i += 1) {
262
- const x = ax[i];
263
- // Half-gap to the previous neighbour (mirror the next gap at the left edge).
264
- const prevGap = i > 0 ? x - ax[i - 1] : i + 1 < n ? ax[i + 1] - x : 0;
265
- // Half-gap to the next neighbour (mirror the previous gap at the right edge).
266
- const nextGap = i + 1 < n ? ax[i + 1] - x : i > 0 ? x - ax[i - 1] : 0;
267
- begin[i] = x - prevGap / 2;
268
- end[i] = x + nextGap / 2;
438
+ const bin = bins[i];
439
+ if (options.ordinal) {
440
+ begin[i] = i;
441
+ end[i] = i + 1;
442
+ }
443
+ else {
444
+ begin[i] = bin.start;
445
+ end[i] = bin.end;
446
+ }
447
+ const fields = bin;
448
+ for (let g = 0; g < G; g += 1) {
449
+ const v = fields[columns[g]];
450
+ values[i * G + g] = typeof v === 'number' && Number.isFinite(v) ? v : NaN;
451
+ }
269
452
  }
270
- return { begin, end, y, length: n };
453
+ return { begin, end, groups: columns, values, length: n };
271
454
  }
272
455
  //# sourceMappingURL=data.js.map
package/dist/grid.d.ts CHANGED
@@ -8,4 +8,18 @@
8
8
  * data layers that draw next.
9
9
  */
10
10
  export declare function drawGrid(ctx: CanvasRenderingContext2D, xTicks: readonly number[], yTicks: readonly number[], width: number, height: number, color: string, dash: readonly number[]): void;
11
+ /**
12
+ * Greedily thin an **ascending** list of pixel positions so no two kept lines
13
+ * are closer than `minGap` px — keeps the axis from crowding when collapse
14
+ * points are dense (e.g. a divider at every daily candle). Keeps the first of
15
+ * each cluster.
16
+ */
17
+ export declare function thinPixels(xs: readonly number[], minGap: number): number[];
18
+ /**
19
+ * Stroke **session dividers** — solid vertical lines at each `xs` pixel, spanning
20
+ * the plot height. Drawn a touch stronger than the dashed gridlines (solid, so a
21
+ * session/day boundary reads as structural, not just another tick) at the
22
+ * discontinuity provider's collapse points (see `DiscontinuityProvider.boundaries`).
23
+ */
24
+ export declare function drawDividers(ctx: CanvasRenderingContext2D, xs: readonly number[], height: number, color: string): void;
11
25
  //# sourceMappingURL=grid.d.ts.map
package/dist/grid.js CHANGED
@@ -26,4 +26,40 @@ export function drawGrid(ctx, xTicks, yTicks, width, height, color, dash) {
26
26
  ctx.stroke();
27
27
  ctx.restore();
28
28
  }
29
+ /**
30
+ * Greedily thin an **ascending** list of pixel positions so no two kept lines
31
+ * are closer than `minGap` px — keeps the axis from crowding when collapse
32
+ * points are dense (e.g. a divider at every daily candle). Keeps the first of
33
+ * each cluster.
34
+ */
35
+ export function thinPixels(xs, minGap) {
36
+ const out = [];
37
+ for (const x of xs) {
38
+ if (out.length === 0 || x - out[out.length - 1] >= minGap)
39
+ out.push(x);
40
+ }
41
+ return out;
42
+ }
43
+ /**
44
+ * Stroke **session dividers** — solid vertical lines at each `xs` pixel, spanning
45
+ * the plot height. Drawn a touch stronger than the dashed gridlines (solid, so a
46
+ * session/day boundary reads as structural, not just another tick) at the
47
+ * discontinuity provider's collapse points (see `DiscontinuityProvider.boundaries`).
48
+ */
49
+ export function drawDividers(ctx, xs, height, color) {
50
+ if (xs.length === 0)
51
+ return;
52
+ ctx.save();
53
+ ctx.strokeStyle = color;
54
+ ctx.lineWidth = 1;
55
+ ctx.setLineDash([]);
56
+ ctx.beginPath();
57
+ for (const x of xs) {
58
+ const px = Math.round(x) + 0.5;
59
+ ctx.moveTo(px, 0);
60
+ ctx.lineTo(px, height);
61
+ }
62
+ ctx.stroke();
63
+ ctx.restore();
64
+ }
29
65
  //# sourceMappingURL=grid.js.map
package/dist/index.d.ts CHANGED
@@ -42,18 +42,24 @@ export { BoxPlot } from './BoxPlot.js';
42
42
  export type { BoxPlotProps } from './BoxPlot.js';
43
43
  export { BarChart } from './BarChart.js';
44
44
  export type { BarChartProps } from './BarChart.js';
45
+ export { Candlestick } from './Candlestick.js';
46
+ export type { CandlestickProps } from './Candlestick.js';
47
+ export type { CandleVariant, ColorBy } from './ohlc.js';
48
+ export { scaleTradingTime } from './tradingTimeScale.js';
49
+ export type { TradingTimeScale, DiscontinuityProvider, } from './tradingTimeScale.js';
45
50
  export { Region, Baseline, Marker } from './annotations.js';
46
51
  export type { RegionProps, BaselineProps, MarkerProps } from './annotations.js';
47
52
  export type { AnnotationKind, CreateSpec } from './context.js';
48
53
  export { YAxisIndicator, createLiveValue } from './indicators.js';
49
54
  export type { YAxisIndicatorProps, LiveValue } from './indicators.js';
50
- export { fromTimeSeries, bandFromTimeSeries, boxFromTimeSeries, barsFromTimeSeries, } from './data.js';
51
- export type { ChartSeries, BandSeries, BoxSeries, BoxColumns, BarSeries, } from './data.js';
55
+ export { fromTimeSeries, bandFromTimeSeries, boxFromTimeSeries, barsFromTimeSeries, ohlcFromTimeSeries, stacksFromGroups, stacksFromColumns, stacksFromBins, } from './data.js';
56
+ export type { ChartSeries, BandSeries, BoxSeries, BoxColumns, BarSeries, OhlcSeries, OhlcColumns, StackedBarSeries, BinRecord, StacksFromBinsOptions, } from './data.js';
57
+ export type { Orientation } from './bars.js';
52
58
  export type { RadiusEncoding, ColorEncoding } from './encoding.js';
53
59
  export type { Curve } from './curve.js';
54
60
  export type { GapMode } from './gaps.js';
55
61
  export { defaultTheme, estelaTheme } from './theme.js';
56
- export type { ChartTheme, LineStyle, BandStyle, AreaStyle, ScatterStyle, BoxStyle, BarStyle, } from './theme.js';
62
+ export type { ChartTheme, LineStyle, BandStyle, AreaStyle, ScatterStyle, BoxStyle, CandleStyle, BarStyle, } from './theme.js';
57
63
  export { cssVarTheme } from './css-theme.js';
58
64
  export type { ChartThemeOverrides, VarReader } from './css-theme.js';
59
65
  export { useChartTheme } from './useChartTheme.js';
package/dist/index.js CHANGED
@@ -29,13 +29,18 @@ export { AreaChart } from './AreaChart.js';
29
29
  export { ScatterChart } from './ScatterChart.js';
30
30
  export { BoxPlot } from './BoxPlot.js';
31
31
  export { BarChart } from './BarChart.js';
32
+ export { Candlestick } from './Candlestick.js';
33
+ export { scaleTradingTime } from './tradingTimeScale.js';
32
34
  // Annotations — user-authored marks in the turquoise register (distinct from the
33
35
  // data): a shaded span, a horizontal value line, a vertical x line.
34
36
  export { Region, Baseline, Marker } from './annotations.js';
35
37
  // Axis indicators — a value pill pinned to an axis edge (the ChartIQ live tag).
36
38
  // `createLiveValue` is the high-frequency, isolated-repaint update path.
37
39
  export { YAxisIndicator, createLiveValue } from './indicators.js';
38
- export { fromTimeSeries, bandFromTimeSeries, boxFromTimeSeries, barsFromTimeSeries, } from './data.js';
40
+ export { fromTimeSeries, bandFromTimeSeries, boxFromTimeSeries, barsFromTimeSeries, ohlcFromTimeSeries,
41
+ // Stacked / histogram readers — assemble a StackedBarSeries from pond's own
42
+ // aggregation output: a Map of grouped series, a wide series, or byColumn bins.
43
+ stacksFromGroups, stacksFromColumns, stacksFromBins, } from './data.js';
39
44
  export { defaultTheme, estelaTheme } from './theme.js';
40
45
  // CSS-custom-property → ChartTheme bridge: build a theme from a design system's
41
46
  // tokens (`cssVarTheme`), and a hook that re-resolves it on a `data-theme`
package/dist/ohlc.d.ts ADDED
@@ -0,0 +1,81 @@
1
+ import type { OhlcSeries } from './data.js';
2
+ import type { Scale } from './line.js';
3
+ import type { CandleStyle } from './theme.js';
4
+ /**
5
+ * How an OHLC mark renders (pjm17971's fork 2 — bundled as one component, like
6
+ * {@link BoxShape}, not split into a separate `<OHLCBar>`):
7
+ *
8
+ * - **`candle`** (default) — a filled `open→close` body with a `high–low` wick.
9
+ * - **`bar`** — an OHLC tick bar: a `high–low` stem with a left tick at `open`
10
+ * and a right tick at `close`, no body.
11
+ * - **`hollow`** — like `candle`, but a **rising** candle (close > open) draws a
12
+ * *hollow* (outlined) body and a **falling / doji** one a filled body.
13
+ */
14
+ export type CandleVariant = 'candle' | 'bar' | 'hollow';
15
+ /**
16
+ * What drives a candle's colour:
17
+ *
18
+ * - **`direction`** (default, market convention) — `rising` when close > open,
19
+ * `falling` when close < open, `neutral` when equal (a doji).
20
+ * - **`series`** — one colour off the `as` role (the style's `rising` pair),
21
+ * *no* green/red. Keeps "colour = series" when a candle sits beside coloured
22
+ * lines and the up/down split would read as a second, conflicting encoding.
23
+ */
24
+ export type ColorBy = 'direction' | 'series';
25
+ /**
26
+ * The `[min, max]` vertical extent of the **drawn** candles — the lowest `low`
27
+ * and highest `high` over keys where **all four** prices are finite — or `null`
28
+ * if none are. Gap keys (any price `NaN`) are excluded, matching what
29
+ * {@link drawCandles} draws, so they don't drag the y-domain.
30
+ *
31
+ * Only `low`/`high` bound the extent: they are the outermost reach of a candle,
32
+ * so `open`/`close` lie within `[low, high]` for any well-formed OHLC row and
33
+ * never widen it. (A malformed row where, say, `close > high` would clip — an
34
+ * upstream data error, not the chart's to paper over.)
35
+ */
36
+ export declare function ohlcExtent(ohlc: OhlcSeries): [number, number] | null;
37
+ /**
38
+ * The index of the candle whose slot `[x, xEnd]` contains `time` — the candle
39
+ * **under the cursor** — or `-1` if `time` is in no slot. Containment (the box
40
+ * analog {@link boxIndexAtTime}), not nearest-by-`begin` (which flips to the next
41
+ * candle past a wide one's midpoint). Candles are sorted by `x`; at a shared edge
42
+ * the left candle wins. A gap candle (some price non-finite) still owns its span
43
+ * here; the caller drops it on the finiteness check. O(N) over the candles
44
+ * (view-scale).
45
+ */
46
+ export declare function ohlcIndexAtTime(ohlc: OhlcSeries, time: number): number;
47
+ /** All four prices finite at `i` — i.e. this candle is drawn. */
48
+ export declare function isFiniteOhlc(ohlc: OhlcSeries, i: number): boolean;
49
+ /**
50
+ * Resolve the `{ body, wick }` colours for one candle from its `open`/`close`
51
+ * and the {@link ColorBy} mode. `direction` picks `rising` (close > open) /
52
+ * `falling` (close < open) / `neutral` (equal — a doji, falling back to `rising`
53
+ * when the style omits it); `series` always returns `rising` (one colour, no
54
+ * up/down split). The single source of the colour decision, shared by
55
+ * {@link drawCandles} and `<Candlestick>`'s tracker readouts so the pill colour
56
+ * matches the mark.
57
+ */
58
+ export declare function resolveCandleStyle(style: CandleStyle, open: number, close: number, colorBy: ColorBy): {
59
+ body: string;
60
+ wick: string;
61
+ };
62
+ /**
63
+ * Draw one candle per key of `ohlc`, mapping data→pixels through
64
+ * `xScale`/`yScale`. The OHLC sibling of {@link drawBox}: each key gets its own
65
+ * mark over its slot x-span (`barSpanPx`, inset by `gapPx` so adjacent candles
66
+ * breathe), in the chosen {@link CandleVariant}, coloured per {@link ColorBy}.
67
+ *
68
+ * The body extents are derived here (`min`/`max` of open/close) — the consumer
69
+ * never precomputes them. A doji (open === close) draws a {@link MIN_BODY_HEIGHT_PX}
70
+ * body so it stays visible. The body is a fraction (`style.bodyWidth`, default
71
+ * {@link DEFAULT_BODY_WIDTH}) of the slot, centred; the wick / OHLC-bar stem sits
72
+ * at the slot centre.
73
+ *
74
+ * **Gap-aware**: a key with any price non-finite is skipped entirely (no partial
75
+ * candle) — the same contract as a box / band gap.
76
+ *
77
+ * O(N) over the keys, a fixed number of path ops each — no per-key allocation
78
+ * beyond the `barSpanPx` tuple.
79
+ */
80
+ export declare function drawCandles(ctx: CanvasRenderingContext2D, ohlc: OhlcSeries, xScale: Scale, yScale: Scale, style: CandleStyle, variant?: CandleVariant, colorBy?: ColorBy, gapPx?: number, minWidthPx?: number): void;
81
+ //# sourceMappingURL=ohlc.d.ts.map
package/dist/ohlc.js ADDED
@@ -0,0 +1,153 @@
1
+ import { barSpanPx } from './range.js';
2
+ /** Default body width as a fraction of the candle slot when the style omits one. */
3
+ const DEFAULT_BODY_WIDTH = 0.8;
4
+ /** Minimum body height in px so a doji (open === close) still shows a mark. */
5
+ const MIN_BODY_HEIGHT_PX = 1;
6
+ /**
7
+ * The `[min, max]` vertical extent of the **drawn** candles — the lowest `low`
8
+ * and highest `high` over keys where **all four** prices are finite — or `null`
9
+ * if none are. Gap keys (any price `NaN`) are excluded, matching what
10
+ * {@link drawCandles} draws, so they don't drag the y-domain.
11
+ *
12
+ * Only `low`/`high` bound the extent: they are the outermost reach of a candle,
13
+ * so `open`/`close` lie within `[low, high]` for any well-formed OHLC row and
14
+ * never widen it. (A malformed row where, say, `close > high` would clip — an
15
+ * upstream data error, not the chart's to paper over.)
16
+ */
17
+ export function ohlcExtent(ohlc) {
18
+ let min = Infinity;
19
+ let max = -Infinity;
20
+ for (let i = 0; i < ohlc.length; i += 1) {
21
+ if (!isFiniteOhlc(ohlc, i))
22
+ continue;
23
+ const lo = ohlc.low[i];
24
+ const hi = ohlc.high[i];
25
+ if (lo < min)
26
+ min = lo;
27
+ if (hi > max)
28
+ max = hi;
29
+ }
30
+ return min === Infinity ? null : [min, max];
31
+ }
32
+ /**
33
+ * The index of the candle whose slot `[x, xEnd]` contains `time` — the candle
34
+ * **under the cursor** — or `-1` if `time` is in no slot. Containment (the box
35
+ * analog {@link boxIndexAtTime}), not nearest-by-`begin` (which flips to the next
36
+ * candle past a wide one's midpoint). Candles are sorted by `x`; at a shared edge
37
+ * the left candle wins. A gap candle (some price non-finite) still owns its span
38
+ * here; the caller drops it on the finiteness check. O(N) over the candles
39
+ * (view-scale).
40
+ */
41
+ export function ohlcIndexAtTime(ohlc, time) {
42
+ for (let i = 0; i < ohlc.length; i += 1) {
43
+ if (time >= ohlc.x[i] && time <= ohlc.xEnd[i])
44
+ return i;
45
+ }
46
+ return -1;
47
+ }
48
+ /** All four prices finite at `i` — i.e. this candle is drawn. */
49
+ export function isFiniteOhlc(ohlc, i) {
50
+ return (Number.isFinite(ohlc.open[i]) &&
51
+ Number.isFinite(ohlc.high[i]) &&
52
+ Number.isFinite(ohlc.low[i]) &&
53
+ Number.isFinite(ohlc.close[i]));
54
+ }
55
+ /**
56
+ * Resolve the `{ body, wick }` colours for one candle from its `open`/`close`
57
+ * and the {@link ColorBy} mode. `direction` picks `rising` (close > open) /
58
+ * `falling` (close < open) / `neutral` (equal — a doji, falling back to `rising`
59
+ * when the style omits it); `series` always returns `rising` (one colour, no
60
+ * up/down split). The single source of the colour decision, shared by
61
+ * {@link drawCandles} and `<Candlestick>`'s tracker readouts so the pill colour
62
+ * matches the mark.
63
+ */
64
+ export function resolveCandleStyle(style, open, close, colorBy) {
65
+ if (colorBy === 'series')
66
+ return style.rising;
67
+ if (close > open)
68
+ return style.rising;
69
+ if (close < open)
70
+ return style.falling;
71
+ return style.neutral ?? style.rising;
72
+ }
73
+ /**
74
+ * Draw one candle per key of `ohlc`, mapping data→pixels through
75
+ * `xScale`/`yScale`. The OHLC sibling of {@link drawBox}: each key gets its own
76
+ * mark over its slot x-span (`barSpanPx`, inset by `gapPx` so adjacent candles
77
+ * breathe), in the chosen {@link CandleVariant}, coloured per {@link ColorBy}.
78
+ *
79
+ * The body extents are derived here (`min`/`max` of open/close) — the consumer
80
+ * never precomputes them. A doji (open === close) draws a {@link MIN_BODY_HEIGHT_PX}
81
+ * body so it stays visible. The body is a fraction (`style.bodyWidth`, default
82
+ * {@link DEFAULT_BODY_WIDTH}) of the slot, centred; the wick / OHLC-bar stem sits
83
+ * at the slot centre.
84
+ *
85
+ * **Gap-aware**: a key with any price non-finite is skipped entirely (no partial
86
+ * candle) — the same contract as a box / band gap.
87
+ *
88
+ * O(N) over the keys, a fixed number of path ops each — no per-key allocation
89
+ * beyond the `barSpanPx` tuple.
90
+ */
91
+ export function drawCandles(ctx, ohlc, xScale, yScale, style, variant = 'candle', colorBy = 'direction', gapPx = 0, minWidthPx = 1) {
92
+ const bodyFraction = style.bodyWidth ?? DEFAULT_BODY_WIDTH;
93
+ for (let i = 0; i < ohlc.length; i += 1) {
94
+ if (!isFiniteOhlc(ohlc, i))
95
+ continue;
96
+ const open = ohlc.open[i];
97
+ const close = ohlc.close[i];
98
+ const [x0, x1] = barSpanPx(ohlc.x[i], ohlc.xEnd[i], xScale, gapPx, minWidthPx);
99
+ const mid = (x0 + x1) / 2;
100
+ const bodyHalf = ((x1 - x0) * bodyFraction) / 2;
101
+ const bx0 = mid - bodyHalf;
102
+ const bodyW = bodyHalf * 2;
103
+ const yOpen = yScale(open);
104
+ const yHigh = yScale(ohlc.high[i]);
105
+ const yLow = yScale(ohlc.low[i]);
106
+ const yClose = yScale(close);
107
+ const { body, wick } = resolveCandleStyle(style, open, close, colorBy);
108
+ if (variant === 'bar') {
109
+ // OHLC bar: a high–low stem, a left tick at open, a right tick at close —
110
+ // all one colour (the `body` role), no filled body.
111
+ ctx.strokeStyle = body;
112
+ ctx.lineWidth = style.wickWidth;
113
+ ctx.beginPath();
114
+ ctx.moveTo(mid, yHigh); // stem
115
+ ctx.lineTo(mid, yLow);
116
+ ctx.moveTo(bx0, yOpen); // open tick (points left)
117
+ ctx.lineTo(mid, yOpen);
118
+ ctx.moveTo(mid, yClose); // close tick (points right)
119
+ ctx.lineTo(mid + bodyHalf, yClose);
120
+ ctx.stroke();
121
+ continue;
122
+ }
123
+ // candle / hollow: the high–low wick first (so the body overlaps it), then
124
+ // the open→close body.
125
+ ctx.strokeStyle = wick;
126
+ ctx.lineWidth = style.wickWidth;
127
+ ctx.beginPath();
128
+ ctx.moveTo(mid, yHigh);
129
+ ctx.lineTo(mid, yLow);
130
+ ctx.stroke();
131
+ // Body extents, with a doji floor so open === close still shows a mark.
132
+ let top = Math.min(yOpen, yClose);
133
+ let h = Math.abs(yClose - yOpen);
134
+ if (h < MIN_BODY_HEIGHT_PX) {
135
+ top -= (MIN_BODY_HEIGHT_PX - h) / 2;
136
+ h = MIN_BODY_HEIGHT_PX;
137
+ }
138
+ // `hollow`: a rising candle is outlined (hollow), a falling / doji one filled
139
+ // — the same strict-`>` boundary resolveCandleStyle uses (equality → neutral),
140
+ // so a doji's fill and its colour agree.
141
+ const hollow = variant === 'hollow' && close > open;
142
+ if (hollow) {
143
+ ctx.strokeStyle = body;
144
+ ctx.lineWidth = style.wickWidth;
145
+ ctx.strokeRect(bx0, top, bodyW, h);
146
+ }
147
+ else {
148
+ ctx.fillStyle = body;
149
+ ctx.fillRect(bx0, top, bodyW, h);
150
+ }
151
+ }
152
+ }
153
+ //# sourceMappingURL=ohlc.js.map
package/dist/scatter.d.ts CHANGED
@@ -44,13 +44,15 @@ export declare function scatterExtent(cs: ChartSeries): [number, number] | null;
44
44
  * @param labelAt optional per-point text label; `undefined` ⇒ no labels drawn.
45
45
  * @param font `theme.font` (family + size) for label text.
46
46
  * @param selected the container's current selection (or `null`).
47
- * @param seriesLabel this layer's series identity (`as` ?? column) — the
48
- * `label` half of the selection match.
47
+ * @param seriesId this layer's stable series identity (its `id` prop, or
48
+ * `undefined` when the layer isn't selectable) — the series half
49
+ * of the selection match. A point lights only when the selection's
50
+ * `id` matches, keyed to the sample by its `key`.
49
51
  */
50
52
  export declare function drawScatter(ctx: CanvasRenderingContext2D, cs: ChartSeries, xScale: Scale, yScale: Scale, style: ScatterStyle, encoding: ResolvedEncoding, keyAt: (i: number) => number, labelAt: ((i: number) => string | undefined) | undefined, font: {
51
53
  readonly family: string;
52
54
  readonly size: number;
53
- }, selected: SelectInfo | null, seriesLabel: string): void;
55
+ }, selected: SelectInfo | null, seriesId: string | undefined): void;
54
56
  /**
55
57
  * Hit-test plot-pixel `(qx, qy)` against the scatter's points — the topmost
56
58
  * point whose circle contains the click, or `null`. "Topmost" = the
@@ -59,12 +61,12 @@ export declare function drawScatter(ctx: CanvasRenderingContext2D, cs: ChartSeri
59
61
  *
60
62
  * A point's hit radius is its drawn radius (data-driven or base) — clicking the
61
63
  * visible disc selects it. Distance is compared squared (no `sqrt` in the loop).
62
- * Returns the point's {@link SelectInfo} with `key = keyAt(i)` (its event
63
- * `begin`), the encoded fill colour (so the readout swatch matches the mark),
64
- * and the series `label`.
64
+ * Returns the point's {@link SelectInfo} with the series `id` (the selection
65
+ * identity), `key = keyAt(i)` (its event `begin` click provenance), the encoded
66
+ * fill colour (so the readout swatch matches the mark), and the display `label`.
65
67
  *
66
68
  * Pure: takes the same `xScale`/`yScale` the row hands to `draw`, so it
67
69
  * unit-tests without a DOM (mirrors the `sampleAt` / `resolveSelection` split).
68
70
  */
69
- export declare function hitTestScatter(cs: ChartSeries, qx: number, qy: number, xScale: Scale, yScale: Scale, encoding: ResolvedEncoding, keyAt: (i: number) => number, seriesLabel: string): SelectInfo | null;
71
+ export declare function hitTestScatter(cs: ChartSeries, qx: number, qy: number, xScale: Scale, yScale: Scale, encoding: ResolvedEncoding, keyAt: (i: number) => number, id: string, seriesLabel: string): SelectInfo | null;
70
72
  //# sourceMappingURL=scatter.d.ts.map