@pond-ts/charts 0.39.0 → 0.41.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/CHANGELOG.md +69 -14
- package/dist/AreaChart.d.ts +4 -0
- package/dist/BandChart.d.ts +4 -0
- package/dist/BarChart.d.ts +10 -0
- package/dist/BarChart.js +6 -0
- package/dist/Candlestick.d.ts +93 -0
- package/dist/Candlestick.js +102 -0
- package/dist/ChartContainer.d.ts +26 -1
- package/dist/ChartContainer.js +29 -11
- package/dist/ChartRow.js +78 -2
- package/dist/LineChart.d.ts +5 -0
- package/dist/YAxis.d.ts +10 -3
- package/dist/data.d.ts +57 -0
- package/dist/data.js +64 -28
- package/dist/index.d.ts +6 -3
- package/dist/index.js +2 -1
- package/dist/ohlc.d.ts +81 -0
- package/dist/ohlc.js +153 -0
- package/dist/theme.d.ts +52 -0
- package/dist/theme.js +24 -0
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -8,7 +8,9 @@ The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`, and
|
|
|
8
8
|
them all. Pre-1.0: minor bumps may include new features and type-level changes;
|
|
9
9
|
patch bumps are strictly additive.
|
|
10
10
|
|
|
11
|
-
[Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.
|
|
11
|
+
[Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.41.0...HEAD
|
|
12
|
+
[0.41.0]: https://github.com/pjm17971/pond-ts/compare/v0.40.0...v0.41.0
|
|
13
|
+
[0.40.0]: https://github.com/pjm17971/pond-ts/compare/v0.39.0...v0.40.0
|
|
12
14
|
[0.39.0]: https://github.com/pjm17971/pond-ts/compare/v0.38.0...v0.39.0
|
|
13
15
|
[0.38.0]: https://github.com/pjm17971/pond-ts/compare/v0.37.0...v0.38.0
|
|
14
16
|
[0.37.0]: https://github.com/pjm17971/pond-ts/compare/v0.36.0...v0.37.0
|
|
@@ -37,21 +39,61 @@ patch bumps are strictly additive.
|
|
|
37
39
|
|
|
38
40
|
## [Unreleased]
|
|
39
41
|
|
|
42
|
+
## [0.41.0] — 2026-07-06
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- **`@pond-ts/charts`: `<Candlestick>` — a first-class OHLC mark** (Phase 1 of
|
|
47
|
+
the financial-charts RFC, Tidal-driven). `open`/`high`/`low`/`close` props
|
|
48
|
+
default to the conventional names (`<Candlestick series={s} />` for a standard
|
|
49
|
+
OHLCV series); draws-only (body extents derived per-mark); **point- or
|
|
50
|
+
interval-keyed** so raw daily OHLCV feeds straight in (no `aggregate`), while a
|
|
51
|
+
weekly/monthly rollup is the identical call. `variant: 'candle' | 'bar' |
|
|
52
|
+
'hollow'`, `colorBy: 'direction' | 'series'`, `gap`, and `showOHLC` (four-pill
|
|
53
|
+
O/H/L/C hover readout; default is a single `close` pill keyed on `as`).
|
|
54
|
+
Participates in the crosshair x-snap (unlike `BoxPlot`). Supersedes `BoxPlot
|
|
55
|
+
shape='solid'` for OHLC data.
|
|
56
|
+
- **`@pond-ts/charts`: `ohlcFromTimeSeries`** + the `OhlcSeries` / `OhlcColumns`
|
|
57
|
+
types — read four price columns into a chart-ready columnar view (exported
|
|
58
|
+
alongside the existing `*FromTimeSeries` builders).
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
|
|
62
|
+
- **`@pond-ts/charts`: `ChartTheme` gains a required `candle` slot** (a
|
|
63
|
+
`CandleStyle`: `rising`/`falling`/`neutral` body+wick pairs, `bodyWidth`,
|
|
64
|
+
`wickWidth`). `defaultTheme` and `estelaTheme` ship neutral, **unbranded**
|
|
65
|
+
up/down pairs — market green/red is a `cssVarTheme` overlay, not a library
|
|
66
|
+
default. **Breaking (type-level):** a hand-built `ChartTheme` that doesn't
|
|
67
|
+
derive from a shipped theme must add a `candle` slot to compile.
|
|
68
|
+
|
|
69
|
+
## [0.40.0] — 2026-07-05
|
|
70
|
+
|
|
71
|
+
A **core + charts** release from the estela `DataChart`-port friction wave.
|
|
72
|
+
`@pond-ts/react` and `@pond-ts/fit` carry no code changes — republished in
|
|
73
|
+
lock-step (peer ranges widen to `^0.40.0`).
|
|
74
|
+
|
|
75
|
+
### Added
|
|
76
|
+
|
|
77
|
+
- `pond-ts`: **`TimeSeries.fromColumns({ sort })`** — an opt-in `sort?: boolean`
|
|
78
|
+
(default `false`) that stable-sorts a columnar payload by key before
|
|
79
|
+
construction, the columnar counterpart of `fromJSON`'s `sort`. The default path
|
|
80
|
+
is unchanged: a decreasing key still throws (a backwards key on the trusted fast
|
|
81
|
+
door is a corruption signal, not silently accepted), and the `Float64Array`
|
|
82
|
+
zero-copy adoption is preserved when `sort` is unset. (#344)
|
|
83
|
+
- `@pond-ts/charts`: **controlled bar hover** — `<ChartContainer hovered
|
|
84
|
+
onHover>`, the transient-hover analog of the existing `selected` / `onSelect`
|
|
85
|
+
pair, keyed by the same `SelectInfo`. Pin a lit `<BarChart>` bar from a legend
|
|
86
|
+
or list row (`hovered`), or mirror a bar-originated hover out-of-band
|
|
87
|
+
(`onHover`); omit both for today's uncontrolled behavior. (#343)
|
|
88
|
+
|
|
40
89
|
### Fixed
|
|
41
90
|
|
|
42
|
-
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
mark's `onSelectAnnotation`. The plot now **defers** its pan pointer-capture
|
|
49
|
-
until the pointer actually moves past the drag slop, so a click (no drag) leaves
|
|
50
|
-
the pointer on the mark and its select fires, while a press-drag still pans
|
|
51
|
-
through and the tracker still hides once the pan commits. Resolves the
|
|
52
|
-
browser-dependent finding deferred from #308; adds
|
|
53
|
-
`e2e/annotations-panzoom.spec.ts`, the first real-pointer-event behavior e2e for
|
|
54
|
-
the annotation layer. (#309)
|
|
91
|
+
- `@pond-ts/charts`: **axis and layer registration are value-equality-guarded** —
|
|
92
|
+
a fresh-but-value-equal `ticks` / `format` / `byValue()`-projected `series`
|
|
93
|
+
reference no longer re-registers the axis/layer, fixing a "Maximum update depth
|
|
94
|
+
exceeded" loop on frequently re-rendering (scrub-driven) charts. The layer and
|
|
95
|
+
axis docs gain a memoize note for `format` / `series` (an inline `format`
|
|
96
|
+
closure still must be hoisted — a closure can't be value-compared). (#342)
|
|
55
97
|
|
|
56
98
|
## [0.39.0] — 2026-07-03
|
|
57
99
|
|
|
@@ -89,6 +131,19 @@ carry no code changes — republished in lock-step (peer ranges widen to `^0.39.
|
|
|
89
131
|
|
|
90
132
|
- `@pond-ts/charts`: the crosshair x-axis pill and marker pills read the axis's
|
|
91
133
|
own formatter (a value-axis / off-boundary time no longer shows a raw number).
|
|
134
|
+
- **Charts — click-to-select an annotation now works while `panZoom` is on.** A
|
|
135
|
+
_selectable but non-editable_ `<Region>` / `<Marker>` (one with no `onChange`)
|
|
136
|
+
lets its press bubble to the plot so a drag can pan _through_ it. The plot
|
|
137
|
+
captured the pointer on press to start the pan, and the browser then retargeted
|
|
138
|
+
the resulting `click` onto the plot (Pointer Events spec: a captured pointer's
|
|
139
|
+
compatibility mouse events fire on the capture target) — silently dropping the
|
|
140
|
+
mark's `onSelectAnnotation`. The plot now **defers** its pan pointer-capture
|
|
141
|
+
until the pointer actually moves past the drag slop, so a click (no drag) leaves
|
|
142
|
+
the pointer on the mark and its select fires, while a press-drag still pans
|
|
143
|
+
through and the tracker still hides once the pan commits. Resolves the
|
|
144
|
+
browser-dependent finding deferred from #308; adds
|
|
145
|
+
`e2e/annotations-panzoom.spec.ts`, the first real-pointer-event behavior e2e for
|
|
146
|
+
the annotation layer. (#309)
|
|
92
147
|
|
|
93
148
|
## [0.38.0] — 2026-07-03
|
|
94
149
|
|
package/dist/AreaChart.d.ts
CHANGED
|
@@ -8,6 +8,10 @@ export interface AreaChartProps<S extends SeriesSchema = SeriesSchema, VS extend
|
|
|
8
8
|
* `ValueSeries` (`series.byValue('dist')`) against its value axis — the
|
|
9
9
|
* container infers which from the data, no axis-type prop (mirrors
|
|
10
10
|
* `<LineChart>`). Either way `column` names the numeric value to fill from.
|
|
11
|
+
*
|
|
12
|
+
* **Live charts:** `series.byValue(…)` mints a *fresh* projection each call, so
|
|
13
|
+
* an inline `series={s.byValue('dist')}` re-registers this layer every render —
|
|
14
|
+
* on a frequently re-rendering chart, memoize the projection (`useMemo`).
|
|
11
15
|
*/
|
|
12
16
|
series: TimeSeries<S> | ValueSeries<VS>;
|
|
13
17
|
/** Name of the numeric value column to fill from. */
|
package/dist/BandChart.d.ts
CHANGED
|
@@ -8,6 +8,10 @@ export interface BandChartProps<S extends SeriesSchema = SeriesSchema, VS extend
|
|
|
8
8
|
* container infers which from the data, no axis-type prop (mirrors
|
|
9
9
|
* `<LineChart>` / `<AreaChart>`). Either way `lower`/`upper` name the numeric
|
|
10
10
|
* edge columns.
|
|
11
|
+
*
|
|
12
|
+
* **Live charts:** `series.byValue(…)` mints a *fresh* projection each call, so
|
|
13
|
+
* an inline `series={s.byValue('dist')}` re-registers this layer every render —
|
|
14
|
+
* on a frequently re-rendering chart, memoize the projection (`useMemo`).
|
|
11
15
|
*/
|
|
12
16
|
series: TimeSeries<S> | ValueSeries<VS>;
|
|
13
17
|
/** Name of the numeric column for the band's lower edge (e.g. `p25`). */
|
package/dist/BarChart.d.ts
CHANGED
|
@@ -10,6 +10,10 @@ export interface BarChartProps<S extends SeriesSchema = SeriesSchema, VS extends
|
|
|
10
10
|
* the same neighbour-spacing span applies (see {@link barsFromValueSeries}); the
|
|
11
11
|
* container infers the x-kind from the data, no axis-type prop (mirrors the
|
|
12
12
|
* other layers).
|
|
13
|
+
*
|
|
14
|
+
* **Live charts:** `series.byValue(…)` mints a *fresh* projection each call, so
|
|
15
|
+
* an inline `series={s.byValue('dist')}` re-registers this layer every render —
|
|
16
|
+
* on a frequently re-rendering chart, memoize the projection (`useMemo`).
|
|
13
17
|
*/
|
|
14
18
|
series: TimeSeries<S> | ValueSeries<VS>;
|
|
15
19
|
/** Name of the numeric value column for the bar height. */
|
|
@@ -63,6 +67,12 @@ export interface BarChartProps<S extends SeriesSchema = SeriesSchema, VS extends
|
|
|
63
67
|
* readout reads the same bar you click, even across a wide bucket (they differ
|
|
64
68
|
* only by the `gap` inset, where the pixel rect is narrower than the span).
|
|
65
69
|
*
|
|
70
|
+
* Both channels are also **controllable from outside** the chart via the
|
|
71
|
+
* container: `selected`/`onSelect` (committed) and `hovered`/`onHover` (transient)
|
|
72
|
+
* — pass either to pin the lit/selected bar from a legend or list row, and read
|
|
73
|
+
* the callback to mirror a bar-originated hover/click out-of-band. Symmetric pair,
|
|
74
|
+
* keyed by the same {@link SelectInfo} identity.
|
|
75
|
+
*
|
|
66
76
|
* **Value axis** — bars also scale on a value axis when fed a `ValueSeries`
|
|
67
77
|
* (`series.byValue('dist')`): estela's distance-domain splits/laps, one bar per
|
|
68
78
|
* segment over a monotonic axis. A `ValueSeries` is point-keyed, so the span is
|
package/dist/BarChart.js
CHANGED
|
@@ -26,6 +26,12 @@ import { useSlotKey } from './use-slot-key.js';
|
|
|
26
26
|
* readout reads the same bar you click, even across a wide bucket (they differ
|
|
27
27
|
* only by the `gap` inset, where the pixel rect is narrower than the span).
|
|
28
28
|
*
|
|
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.
|
|
34
|
+
*
|
|
29
35
|
* **Value axis** — bars also scale on a value axis when fed a `ValueSeries`
|
|
30
36
|
* (`series.byValue('dist')`): estela's distance-domain splits/laps, one bar per
|
|
31
37
|
* segment over a monotonic axis. A `ValueSeries` is point-keyed, so the span is
|
|
@@ -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
|
package/dist/ChartContainer.d.ts
CHANGED
|
@@ -57,6 +57,31 @@ export interface ChartContainerProps {
|
|
|
57
57
|
* works in both controlled and uncontrolled mode.
|
|
58
58
|
*/
|
|
59
59
|
onSelect?: (hit: SelectInfo | null) => void;
|
|
60
|
+
/**
|
|
61
|
+
* Controlled hover-highlight — the transiently lit mark (echo the `onHover` arg
|
|
62
|
+
* back), or `null`. **Omitted ⇒ uncontrolled** (the pointer over a selectable
|
|
63
|
+
* layer manages it internally). The hover analog of {@link selected}: pass it to
|
|
64
|
+
* **pin** a lit mark from outside the chart (e.g. hovering a legend / list row
|
|
65
|
+
* lights the matching {@link BarChart} bar). Only layers with a hover-highlight
|
|
66
|
+
* (currently `BarChart`) render it; keyed by the same {@link SelectInfo} identity
|
|
67
|
+
* as selection.
|
|
68
|
+
*/
|
|
69
|
+
hovered?: SelectInfo | null;
|
|
70
|
+
/**
|
|
71
|
+
* Fires when the pointer enters a selectable layer's mark (the hit mark) or
|
|
72
|
+
* leaves every mark (`null`) — the hover analog of {@link onSelect}. Notification
|
|
73
|
+
* only (works controlled or uncontrolled), and **deduped**: it fires on a mark
|
|
74
|
+
* transition, not on every pointer move. Wire it to mirror hover out-of-band
|
|
75
|
+
* (e.g. a list row ↔ the bar), pairing with {@link hovered} to sync both ways.
|
|
76
|
+
* (The annotation counterpart is {@link onHoverAnnotation}.)
|
|
77
|
+
*
|
|
78
|
+
* **Dedup key:** by the mark's `key` + `label` only (not `value`/`color`). So on
|
|
79
|
+
* a live chart where a bar's value changes while the cursor stays on it, this
|
|
80
|
+
* won't re-fire — read the current value from your series, not the last
|
|
81
|
+
* `onHover` payload. (Matches the internal hover-highlight, which repaints on
|
|
82
|
+
* key transitions.)
|
|
83
|
+
*/
|
|
84
|
+
onHover?: (hit: SelectInfo | null) => void;
|
|
60
85
|
/**
|
|
61
86
|
* Enable pan/zoom: drag the plot to pan the time range, wheel to zoom around
|
|
62
87
|
* the cursor. **Default off** — so it doesn't capture drag/scroll unless asked.
|
|
@@ -155,5 +180,5 @@ export interface ChartContainerProps {
|
|
|
155
180
|
* {@link TimeAxis} at the bottom, aligned under the plots. Y axes are per-row
|
|
156
181
|
* (`<YAxis>`).
|
|
157
182
|
*/
|
|
158
|
-
export declare function ChartContainer({ range, width, rowGap, showAxis, trackerPosition, onTrackerChanged, selected, onSelect, panZoom, onTimeRangeChange, minDuration, cursor, cursorTime, crosshairSnap, editAnnotations, creating, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap, timeFormat, theme, children, }: ChartContainerProps): import("react/jsx-runtime").JSX.Element;
|
|
183
|
+
export declare function ChartContainer({ range, width, rowGap, showAxis, trackerPosition, onTrackerChanged, selected, onSelect, hovered, onHover, panZoom, onTimeRangeChange, minDuration, cursor, cursorTime, crosshairSnap, editAnnotations, creating, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap, timeFormat, theme, children, }: ChartContainerProps): import("react/jsx-runtime").JSX.Element;
|
|
159
184
|
//# sourceMappingURL=ChartContainer.d.ts.map
|
package/dist/ChartContainer.js
CHANGED
|
@@ -31,7 +31,7 @@ function normalizeRange(range) {
|
|
|
31
31
|
* {@link TimeAxis} at the bottom, aligned under the plots. Y axes are per-row
|
|
32
32
|
* (`<YAxis>`).
|
|
33
33
|
*/
|
|
34
|
-
export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, selected, onSelect, panZoom = false, onTimeRangeChange, minDuration = 1, cursor = DEFAULT_CURSOR_MODE, cursorTime = false, crosshairSnap = true, editAnnotations = false, creating = null, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap = true, timeFormat, theme, children, }) {
|
|
34
|
+
export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trackerPosition, onTrackerChanged, selected, onSelect, hovered, onHover, panZoom = false, onTimeRangeChange, minDuration = 1, cursor = DEFAULT_CURSOR_MODE, cursorTime = false, crosshairSnap = true, editAnnotations = false, creating = null, onCreate, onSelectAnnotation, onHoverAnnotation, onEditAnnotation, snap = true, timeFormat, theme, children, }) {
|
|
35
35
|
// The explicit base domain from `range` (a tuple or a TimeRange). `undefined`
|
|
36
36
|
// ⇒ auto-fit (resolved from the layers below). Pan/zoom seeds from it; `seed`
|
|
37
37
|
// is the placeholder while auto-fitting.
|
|
@@ -175,18 +175,36 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
175
175
|
setInternalSelected(hit);
|
|
176
176
|
}, []);
|
|
177
177
|
// Hover-highlight: the transient mark under the pointer (distinct from the
|
|
178
|
-
// committed selection).
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
|
|
178
|
+
// committed selection). Controlled (`hovered` prop) or uncontrolled (internal),
|
|
179
|
+
// mirroring selection; `onHover` notifies in both modes. Deduped by key+label
|
|
180
|
+
// so it fires — and the data canvas repaints — only when the hovered mark
|
|
181
|
+
// changes, not on every pointer move (the move itself just slides the SVG
|
|
182
|
+
// cursor, which never touches the data canvas).
|
|
183
|
+
const [internalHovered, setInternalHovered] = useState(null);
|
|
184
|
+
const controlledHover = hovered !== undefined;
|
|
185
|
+
const hoveredValue = controlledHover ? (hovered ?? null) : internalHovered;
|
|
186
|
+
const onHoverRef = useRef(onHover);
|
|
187
|
+
const controlledHoverRef = useRef(controlledHover);
|
|
188
|
+
// The last mark we reported — so the callback dedups across pointer moves even
|
|
189
|
+
// in controlled mode, where there's no internal state to compare against.
|
|
190
|
+
const lastHoverRef = useRef(null);
|
|
191
|
+
useLayoutEffect(() => {
|
|
192
|
+
onHoverRef.current = onHover;
|
|
193
|
+
controlledHoverRef.current = controlledHover;
|
|
194
|
+
});
|
|
182
195
|
const setHovered = useCallback((hit) => {
|
|
183
|
-
|
|
196
|
+
const prev = lastHoverRef.current;
|
|
197
|
+
const same = prev === hit ||
|
|
184
198
|
(prev !== null &&
|
|
185
199
|
hit !== null &&
|
|
186
200
|
prev.key === hit.key &&
|
|
187
|
-
prev.label === hit.label)
|
|
188
|
-
|
|
189
|
-
|
|
201
|
+
prev.label === hit.label);
|
|
202
|
+
if (same)
|
|
203
|
+
return;
|
|
204
|
+
lastHoverRef.current = hit;
|
|
205
|
+
onHoverRef.current?.(hit);
|
|
206
|
+
if (!controlledHoverRef.current)
|
|
207
|
+
setInternalHovered(hit);
|
|
190
208
|
}, []);
|
|
191
209
|
// Rows report their per-slot gutter widths; we reserve each slot's max.
|
|
192
210
|
const [gutters, setGutters] = useState([]);
|
|
@@ -284,7 +302,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
284
302
|
setDragging,
|
|
285
303
|
selected: selectedValue,
|
|
286
304
|
select,
|
|
287
|
-
hovered,
|
|
305
|
+
hovered: hoveredValue,
|
|
288
306
|
setHovered,
|
|
289
307
|
cursor,
|
|
290
308
|
cursorTime,
|
|
@@ -329,7 +347,7 @@ export function ChartContainer({ range, width, rowGap = 0, showAxis = true, trac
|
|
|
329
347
|
setDragging,
|
|
330
348
|
selectedValue,
|
|
331
349
|
select,
|
|
332
|
-
|
|
350
|
+
hoveredValue,
|
|
333
351
|
setHovered,
|
|
334
352
|
cursor,
|
|
335
353
|
cursorTime,
|
package/dist/ChartRow.js
CHANGED
|
@@ -9,6 +9,62 @@ import { YAxis } from './YAxis.js';
|
|
|
9
9
|
import { ContainerContext, RowContext, } from './context.js';
|
|
10
10
|
/** Sentinel id for the implicit axis a row gets when no `<YAxis>` is declared. */
|
|
11
11
|
const IMPLICIT_AXIS_ID = '__default__';
|
|
12
|
+
/** Element-wise compare of two optional number arrays (an axis's tick values) —
|
|
13
|
+
* so a *fresh* `ticks={[…]}` array whose contents are unchanged doesn't count as
|
|
14
|
+
* a new spec (see {@link axisSpecEqual}). */
|
|
15
|
+
function numberArraysEqual(a, b) {
|
|
16
|
+
if (a === b)
|
|
17
|
+
return true;
|
|
18
|
+
if (a === undefined || b === undefined)
|
|
19
|
+
return false;
|
|
20
|
+
if (a.length !== b.length)
|
|
21
|
+
return false;
|
|
22
|
+
for (let i = 0; i < a.length; i += 1)
|
|
23
|
+
if (!Object.is(a[i], b[i]))
|
|
24
|
+
return false;
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Value-equality for two {@link AxisSpec}s — the registration guard's compare
|
|
29
|
+
* (see `registerAxis`). Every field is a plain value except `format`, which may
|
|
30
|
+
* be a `(value) => string` closure: those are compared by **reference**
|
|
31
|
+
* (`Object.is`), so a stable/hoisted formatter (or a string specifier) is equal
|
|
32
|
+
* across renders but a *fresh inline function* is not — the one case a structural
|
|
33
|
+
* guard provably can't collapse, hence the `<YAxis format>` memoize note. A
|
|
34
|
+
* fresh-but-value-equal `ticks` array (the common live-chart footgun) compares
|
|
35
|
+
* equal element-wise and no-ops.
|
|
36
|
+
*/
|
|
37
|
+
function axisSpecEqual(a, b) {
|
|
38
|
+
return (a.id === b.id &&
|
|
39
|
+
a.side === b.side &&
|
|
40
|
+
a.width === b.width &&
|
|
41
|
+
// Object.is (not ===) so a degenerate NaN bound compares equal to itself and
|
|
42
|
+
// doesn't re-register every render.
|
|
43
|
+
Object.is(a.min, b.min) &&
|
|
44
|
+
Object.is(a.max, b.max) &&
|
|
45
|
+
a.pad === b.pad &&
|
|
46
|
+
a.labelPlacement === b.labelPlacement &&
|
|
47
|
+
a.index === b.index &&
|
|
48
|
+
Object.is(a.format, b.format) &&
|
|
49
|
+
numberArraysEqual(a.tickValues, b.tickValues));
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Value-equality for two {@link LayerEntry}s — **defensive, not load-bearing.**
|
|
53
|
+
* The axis guard (`axisSpecEqual`) is what breaks the update-depth loop; this is
|
|
54
|
+
* belt-and-suspenders for the layer setter. Note that under the current draw-layer
|
|
55
|
+
* structure it *won't* actually fire: every layer builds `layer` inside the same
|
|
56
|
+
* `useMemo` as `entry`, so the register effect only runs when `entry` is fresh —
|
|
57
|
+
* and a fresh `entry` always carries a fresh `layer`, so `a.layer === b.layer` is
|
|
58
|
+
* never true when the guard runs (it falls through to a normal register). It would
|
|
59
|
+
* only bite if a future layer memoized `layer` separately from `entry` (or the
|
|
60
|
+
* `Layers` registry ref changed under a stable entry). A fresh `series` projection
|
|
61
|
+
* (`byValue()` mints one each call) rebuilds the memo → a new `layer` and
|
|
62
|
+
* legitimately re-registers; that's a consumer-side memoize (see the `series` note
|
|
63
|
+
* on the draw-layer components), not something the setter can value-compare.
|
|
64
|
+
*/
|
|
65
|
+
function layerEntryEqual(a, b) {
|
|
66
|
+
return a.layer === b.layer && a.axisId === b.axisId && a.index === b.index;
|
|
67
|
+
}
|
|
12
68
|
/** Axis tick count for the per-axis formatter — matches `<YAxis>`'s tick count
|
|
13
69
|
* so the readout formatter is calibrated exactly as the labels are. */
|
|
14
70
|
const AXIS_TICK_COUNT = 5;
|
|
@@ -52,8 +108,19 @@ export function ChartRow({ height, cursor, children }) {
|
|
|
52
108
|
// min/max or series change silently rebind axes / reorder the z-stack.)
|
|
53
109
|
const [axes, setAxes] = useState(() => new Map());
|
|
54
110
|
const [layers, setLayers] = useState(() => new Map());
|
|
111
|
+
// Registration is idempotent under value-equality: a `<YAxis>` re-fires its
|
|
112
|
+
// register effect whenever its `spec` memo yields a fresh object — which an
|
|
113
|
+
// inline `ticks={[]}` / `format` or a re-rendered parent does every render. If
|
|
114
|
+
// the spec is *value*-equal to the stored one, skip the `setState` entirely so
|
|
115
|
+
// it can't spin `register → setState → re-render → register` into React's
|
|
116
|
+
// "Maximum update depth exceeded" on a scrub-heavy chart (F-charts-axis-reregister).
|
|
55
117
|
const registerAxis = useCallback((key, spec) => {
|
|
56
|
-
setAxes((m) =>
|
|
118
|
+
setAxes((m) => {
|
|
119
|
+
const prev = m.get(key);
|
|
120
|
+
if (prev !== undefined && axisSpecEqual(prev, spec))
|
|
121
|
+
return m;
|
|
122
|
+
return new Map(m).set(key, spec);
|
|
123
|
+
});
|
|
57
124
|
}, []);
|
|
58
125
|
const unregisterAxis = useCallback((key) => {
|
|
59
126
|
setAxes((m) => {
|
|
@@ -64,8 +131,17 @@ export function ChartRow({ height, cursor, children }) {
|
|
|
64
131
|
return next;
|
|
65
132
|
});
|
|
66
133
|
}, []);
|
|
134
|
+
// Same value-equality guard as `registerAxis`: a re-register carrying the same
|
|
135
|
+
// `layer` object (stable while the draw layer's inputs are) + `axisId`/`index`
|
|
136
|
+
// no-ops rather than churning state. (A fresh `series` projection rebuilds the
|
|
137
|
+
// layer and legitimately re-registers — see `layerEntryEqual`.)
|
|
67
138
|
const registerLayer = useCallback((key, entry) => {
|
|
68
|
-
setLayers((m) =>
|
|
139
|
+
setLayers((m) => {
|
|
140
|
+
const prev = m.get(key);
|
|
141
|
+
if (prev !== undefined && layerEntryEqual(prev, entry))
|
|
142
|
+
return m;
|
|
143
|
+
return new Map(m).set(key, entry);
|
|
144
|
+
});
|
|
69
145
|
}, []);
|
|
70
146
|
const unregisterLayer = useCallback((key) => {
|
|
71
147
|
setLayers((m) => {
|
package/dist/LineChart.d.ts
CHANGED
|
@@ -8,6 +8,11 @@ export interface LineChartProps<S extends SeriesSchema = SeriesSchema, VS extend
|
|
|
8
8
|
* `ValueSeries` (`series.byValue('cumDist')`) against its value axis — the
|
|
9
9
|
* container infers which from the data, no axis-type prop. Either way the key
|
|
10
10
|
* / axis column supplies x and `column` supplies y.
|
|
11
|
+
*
|
|
12
|
+
* **Live charts:** `series.byValue(…)` mints a *fresh* projection each call, so
|
|
13
|
+
* passing `series={s.byValue('dist')}` inline re-registers this layer every
|
|
14
|
+
* render — on a frequently re-rendering (e.g. scrub-driven) chart, memoize the
|
|
15
|
+
* projection (`useMemo`) so the layer isn't rebuilt each frame.
|
|
11
16
|
*/
|
|
12
17
|
series: TimeSeries<S> | ValueSeries<VS>;
|
|
13
18
|
/** Name of the numeric value column to plot. */
|
package/dist/YAxis.d.ts
CHANGED
|
@@ -36,6 +36,12 @@ export interface YAxisProps {
|
|
|
36
36
|
* function. Omit for the scale's d3 default — which is calibrated to the tick
|
|
37
37
|
* step, so a between-ticks readout rounds to tick precision; pass a specifier
|
|
38
38
|
* (e.g. `',.2f'`) when you want finer readout precision. See {@link AxisFormat}.
|
|
39
|
+
*
|
|
40
|
+
* **Live charts:** a string specifier is value-compared, so an inline
|
|
41
|
+
* `format='.0%'` is safe every render. An inline `format={(v) => …}` **function**
|
|
42
|
+
* is a fresh reference each render — the one axis prop a structural guard can't
|
|
43
|
+
* value-compare — so on a frequently re-rendering (e.g. scrub-driven) chart,
|
|
44
|
+
* hoist it or wrap it in `useCallback`, or it re-registers the axis each frame.
|
|
39
45
|
*/
|
|
40
46
|
format?: AxisFormat;
|
|
41
47
|
/**
|
|
@@ -45,9 +51,10 @@ export interface YAxisProps {
|
|
|
45
51
|
* lever for a non-uniform axis like pace, where the caller chooses round-pace
|
|
46
52
|
* positions and their own `m:ss` labels (`{ at: -300, label: '5:00' }`). `at`
|
|
47
53
|
* values outside `[min, max]` extrapolate off-plot (the scale does not clamp).
|
|
48
|
-
* Pass `[]` to draw none.
|
|
49
|
-
*
|
|
50
|
-
* axis
|
|
54
|
+
* Pass `[]` to draw none. The array is **value-compared on registration**, so an
|
|
55
|
+
* inline `ticks={[…]}` (or `ticks={[]}`) with unchanged contents no longer
|
|
56
|
+
* re-registers the axis — only genuinely changed tick positions do. (An inline
|
|
57
|
+
* `format` *function* still needs hoisting; see `format`.)
|
|
51
58
|
*/
|
|
52
59
|
ticks?: ReadonlyArray<{
|
|
53
60
|
readonly at: number;
|
package/dist/data.d.ts
CHANGED
|
@@ -53,6 +53,32 @@ export interface BoxSeries {
|
|
|
53
53
|
readonly upper: Float64Array;
|
|
54
54
|
readonly length: number;
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* A chart-ready view of an OHLC series ({@link Candlestick}): the candle's
|
|
58
|
+
* horizontal slot (`x` = left edge, `xEnd` = right edge) plus the four price
|
|
59
|
+
* channels per mark — `open`/`high`/`low`/`close`. The chart derives the body
|
|
60
|
+
* extents (`min`/`max` of open/close) itself at draw time; only the four raw
|
|
61
|
+
* columns are read here.
|
|
62
|
+
*
|
|
63
|
+
* A mark is drawn only where **all four** prices are finite; any one `NaN` is a
|
|
64
|
+
* gap (the candle draws nothing — same gap contract as {@link BoxSeries}).
|
|
65
|
+
*
|
|
66
|
+
* Unlike {@link BoxSeries} (interval-keyed only), the OHLC view supports **both**
|
|
67
|
+
* key shapes, like {@link BarSeries}: an **interval**-keyed series (an
|
|
68
|
+
* `aggregate` rollup — weekly/monthly bars) uses the key's own `[begin, end)` as
|
|
69
|
+
* the slot; a **point**-keyed series (raw daily OHLCV) derives the slot from
|
|
70
|
+
* neighbour spacing (see {@link ohlcFromTimeSeries}), so it feeds straight in
|
|
71
|
+
* with no `aggregate` pass.
|
|
72
|
+
*/
|
|
73
|
+
export interface OhlcSeries {
|
|
74
|
+
readonly x: Float64Array;
|
|
75
|
+
readonly xEnd: Float64Array;
|
|
76
|
+
readonly open: Float64Array;
|
|
77
|
+
readonly high: Float64Array;
|
|
78
|
+
readonly low: Float64Array;
|
|
79
|
+
readonly close: Float64Array;
|
|
80
|
+
readonly length: number;
|
|
81
|
+
}
|
|
56
82
|
/**
|
|
57
83
|
* A chart-ready view of an interval-keyed series for bars: each mark spans
|
|
58
84
|
* `[begin[i], end[i]]` (the key's range) with height `y[i]`. Unlike
|
|
@@ -83,6 +109,17 @@ export interface BoxColumns {
|
|
|
83
109
|
/** Upper whisker end (e.g. `p95` / `max`). */
|
|
84
110
|
readonly upper: string;
|
|
85
111
|
}
|
|
112
|
+
/** The four OHLC column names {@link ohlcFromTimeSeries} reads. */
|
|
113
|
+
export interface OhlcColumns {
|
|
114
|
+
/** Opening price column. */
|
|
115
|
+
readonly open: string;
|
|
116
|
+
/** Session high column. */
|
|
117
|
+
readonly high: string;
|
|
118
|
+
/** Session low column. */
|
|
119
|
+
readonly low: string;
|
|
120
|
+
/** Closing price column. */
|
|
121
|
+
readonly close: string;
|
|
122
|
+
}
|
|
86
123
|
/**
|
|
87
124
|
* Build a {@link ChartSeries} from a pond `TimeSeries` by reading its columnar
|
|
88
125
|
* buffers directly — no per-event materialization. `column` names a numeric
|
|
@@ -143,6 +180,26 @@ export declare function bandFromValueSeries<VS extends ValueSeriesSchema>(series
|
|
|
143
180
|
* @throws TypeError if any quantile column is not a numeric column.
|
|
144
181
|
*/
|
|
145
182
|
export declare function boxFromTimeSeries<S extends SeriesSchema>(series: TimeSeries<S>, columns: BoxColumns): BoxSeries;
|
|
183
|
+
/**
|
|
184
|
+
* Build an {@link OhlcSeries} from a pond `TimeSeries` — four numeric price
|
|
185
|
+
* columns (`open`/`high`/`low`/`close`) plus the candle's horizontal slot.
|
|
186
|
+
*
|
|
187
|
+
* **Key-shape aware, like {@link barsFromTimeSeries}.** An **interval /
|
|
188
|
+
* timeRange**-keyed series (an `aggregate` rollup — weekly / monthly bars) uses
|
|
189
|
+
* the key's own `[begin, end)` as the slot. A **point**-keyed (`time`) series —
|
|
190
|
+
* raw daily OHLCV — has `begin === end` (zero width), so the slot is derived from
|
|
191
|
+
* neighbour spacing (each candle centred on its timestamp, reaching halfway to
|
|
192
|
+
* each neighbour; see {@link neighbourSpans}). This is the ergonomic win over the
|
|
193
|
+
* interval-only {@link boxFromTimeSeries}: raw OHLC feeds straight in with no
|
|
194
|
+
* `aggregate` pass.
|
|
195
|
+
*
|
|
196
|
+
* A key with any of the four prices missing reads as a gap (the candle draws
|
|
197
|
+
* nothing). Detected by `keyColumn().kind === 'time'`.
|
|
198
|
+
*
|
|
199
|
+
* @throws RangeError if any price column does not exist.
|
|
200
|
+
* @throws TypeError if any price column is not a numeric column.
|
|
201
|
+
*/
|
|
202
|
+
export declare function ohlcFromTimeSeries<S extends SeriesSchema>(series: TimeSeries<S>, columns: OhlcColumns): OhlcSeries;
|
|
146
203
|
/**
|
|
147
204
|
* Build a {@link BarSeries} from a pond `TimeSeries` — one bar per event, the
|
|
148
205
|
* key's `[begin, end]` as the x-span and `column` as the height.
|
package/dist/data.js
CHANGED
|
@@ -170,6 +170,41 @@ export function boxFromTimeSeries(series, columns) {
|
|
|
170
170
|
length: series.length,
|
|
171
171
|
};
|
|
172
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* Build an {@link OhlcSeries} from a pond `TimeSeries` — four numeric price
|
|
175
|
+
* columns (`open`/`high`/`low`/`close`) plus the candle's horizontal slot.
|
|
176
|
+
*
|
|
177
|
+
* **Key-shape aware, like {@link barsFromTimeSeries}.** An **interval /
|
|
178
|
+
* timeRange**-keyed series (an `aggregate` rollup — weekly / monthly bars) uses
|
|
179
|
+
* the key's own `[begin, end)` as the slot. A **point**-keyed (`time`) series —
|
|
180
|
+
* raw daily OHLCV — has `begin === end` (zero width), so the slot is derived from
|
|
181
|
+
* neighbour spacing (each candle centred on its timestamp, reaching halfway to
|
|
182
|
+
* each neighbour; see {@link neighbourSpans}). This is the ergonomic win over the
|
|
183
|
+
* interval-only {@link boxFromTimeSeries}: raw OHLC feeds straight in with no
|
|
184
|
+
* `aggregate` pass.
|
|
185
|
+
*
|
|
186
|
+
* A key with any of the four prices missing reads as a gap (the candle draws
|
|
187
|
+
* nothing). Detected by `keyColumn().kind === 'time'`.
|
|
188
|
+
*
|
|
189
|
+
* @throws RangeError if any price column does not exist.
|
|
190
|
+
* @throws TypeError if any price column is not a numeric column.
|
|
191
|
+
*/
|
|
192
|
+
export function ohlcFromTimeSeries(series, columns) {
|
|
193
|
+
const open = readNumericColumn(series, columns.open);
|
|
194
|
+
const high = readNumericColumn(series, columns.high);
|
|
195
|
+
const low = readNumericColumn(series, columns.low);
|
|
196
|
+
const close = readNumericColumn(series, columns.close);
|
|
197
|
+
const n = series.length;
|
|
198
|
+
if (series.keyColumn().kind !== 'time') {
|
|
199
|
+
// Interval / timeRange: the key's own endpoints are the candle slot.
|
|
200
|
+
const { begin, end } = keyBeginEnd(series);
|
|
201
|
+
return { x: begin, xEnd: end, open, high, low, close, length: n };
|
|
202
|
+
}
|
|
203
|
+
// Point key (begin === end): synthesize the slot from neighbour spacing so raw
|
|
204
|
+
// daily OHLCV renders as contiguous candles without a pre-key to intervals.
|
|
205
|
+
const { begin, end } = neighbourSpans(series.keyColumn().begin, n);
|
|
206
|
+
return { x: begin, xEnd: end, open, high, low, close, length: n };
|
|
207
|
+
}
|
|
173
208
|
/**
|
|
174
209
|
* Per-row begin/end buffers for the key column, each aligned to the logical
|
|
175
210
|
* length (zero-copy views). For an interval / timeRange key these are the key's
|
|
@@ -184,6 +219,30 @@ function keyBeginEnd(series) {
|
|
|
184
219
|
// (point-in-time), which the caller's point-key fallback replaces.
|
|
185
220
|
return { begin: key.begin.subarray(0, n), end: key.end.subarray(0, n) };
|
|
186
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* Synthesize per-point `[begin, end]` spans from a monotonic axis buffer by
|
|
224
|
+
* **neighbour spacing**: each point is centred on its own value and reaches
|
|
225
|
+
* halfway to each neighbour (a Voronoi cell on the axis). The first / last points
|
|
226
|
+
* mirror their single adjacent gap so the end cells match their interior width; a
|
|
227
|
+
* lone point (length 1) keeps zero width (the renderer's `minWidth` floor takes
|
|
228
|
+
* over). Shared by the point-keyed `TimeSeries` bars, the `ValueSeries` bars, and
|
|
229
|
+
* the point-keyed OHLC reader. `axis` is a zero-copy key buffer (must not be
|
|
230
|
+
* mutated) — fresh output buffers are allocated.
|
|
231
|
+
*/
|
|
232
|
+
function neighbourSpans(axis, n) {
|
|
233
|
+
const begin = new Float64Array(n);
|
|
234
|
+
const end = new Float64Array(n);
|
|
235
|
+
for (let i = 0; i < n; i += 1) {
|
|
236
|
+
const x = axis[i];
|
|
237
|
+
// Half-gap to the previous neighbour (mirror the next gap at the left edge).
|
|
238
|
+
const prevGap = i > 0 ? x - axis[i - 1] : i + 1 < n ? axis[i + 1] - x : 0;
|
|
239
|
+
// Half-gap to the next neighbour (mirror the previous gap at the right edge).
|
|
240
|
+
const nextGap = i + 1 < n ? axis[i + 1] - x : i > 0 ? x - axis[i - 1] : 0;
|
|
241
|
+
begin[i] = x - prevGap / 2;
|
|
242
|
+
end[i] = x + nextGap / 2;
|
|
243
|
+
}
|
|
244
|
+
return { begin, end };
|
|
245
|
+
}
|
|
187
246
|
/**
|
|
188
247
|
* Build a {@link BarSeries} from a pond `TimeSeries` — one bar per event, the
|
|
189
248
|
* key's `[begin, end]` as the x-span and `column` as the height.
|
|
@@ -215,20 +274,8 @@ export function barsFromTimeSeries(series, column) {
|
|
|
215
274
|
return { begin, end, y, length: n };
|
|
216
275
|
}
|
|
217
276
|
// Point key (begin === end): synthesize a span from neighbour spacing so the
|
|
218
|
-
// bars have width
|
|
219
|
-
|
|
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
|
-
}
|
|
277
|
+
// bars have width (see neighbourSpans).
|
|
278
|
+
const { begin, end } = neighbourSpans(series.keyColumn().begin, n);
|
|
232
279
|
return { begin, end, y, length: n };
|
|
233
280
|
}
|
|
234
281
|
/**
|
|
@@ -253,20 +300,9 @@ export function barsFromTimeSeries(series, column) {
|
|
|
253
300
|
export function barsFromValueSeries(series, column) {
|
|
254
301
|
const y = readValueColumn(series, column);
|
|
255
302
|
const n = series.length;
|
|
256
|
-
// axisValues() is the monotonic key buffer (zero-copy)
|
|
257
|
-
//
|
|
258
|
-
const
|
|
259
|
-
const begin = new Float64Array(n);
|
|
260
|
-
const end = new Float64Array(n);
|
|
261
|
-
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;
|
|
269
|
-
}
|
|
303
|
+
// axisValues() is the monotonic key buffer (zero-copy); neighbourSpans reads it
|
|
304
|
+
// and allocates fresh span buffers (never mutates the source).
|
|
305
|
+
const { begin, end } = neighbourSpans(series.axisValues(), n);
|
|
270
306
|
return { begin, end, y, length: n };
|
|
271
307
|
}
|
|
272
308
|
//# sourceMappingURL=data.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -42,18 +42,21 @@ 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';
|
|
45
48
|
export { Region, Baseline, Marker } from './annotations.js';
|
|
46
49
|
export type { RegionProps, BaselineProps, MarkerProps } from './annotations.js';
|
|
47
50
|
export type { AnnotationKind, CreateSpec } from './context.js';
|
|
48
51
|
export { YAxisIndicator, createLiveValue } from './indicators.js';
|
|
49
52
|
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';
|
|
53
|
+
export { fromTimeSeries, bandFromTimeSeries, boxFromTimeSeries, barsFromTimeSeries, ohlcFromTimeSeries, } from './data.js';
|
|
54
|
+
export type { ChartSeries, BandSeries, BoxSeries, BoxColumns, BarSeries, OhlcSeries, OhlcColumns, } from './data.js';
|
|
52
55
|
export type { RadiusEncoding, ColorEncoding } from './encoding.js';
|
|
53
56
|
export type { Curve } from './curve.js';
|
|
54
57
|
export type { GapMode } from './gaps.js';
|
|
55
58
|
export { defaultTheme, estelaTheme } from './theme.js';
|
|
56
|
-
export type { ChartTheme, LineStyle, BandStyle, AreaStyle, ScatterStyle, BoxStyle, BarStyle, } from './theme.js';
|
|
59
|
+
export type { ChartTheme, LineStyle, BandStyle, AreaStyle, ScatterStyle, BoxStyle, CandleStyle, BarStyle, } from './theme.js';
|
|
57
60
|
export { cssVarTheme } from './css-theme.js';
|
|
58
61
|
export type { ChartThemeOverrides, VarReader } from './css-theme.js';
|
|
59
62
|
export { useChartTheme } from './useChartTheme.js';
|
package/dist/index.js
CHANGED
|
@@ -29,13 +29,14 @@ 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';
|
|
32
33
|
// Annotations — user-authored marks in the turquoise register (distinct from the
|
|
33
34
|
// data): a shaded span, a horizontal value line, a vertical x line.
|
|
34
35
|
export { Region, Baseline, Marker } from './annotations.js';
|
|
35
36
|
// Axis indicators — a value pill pinned to an axis edge (the ChartIQ live tag).
|
|
36
37
|
// `createLiveValue` is the high-frequency, isolated-repaint update path.
|
|
37
38
|
export { YAxisIndicator, createLiveValue } from './indicators.js';
|
|
38
|
-
export { fromTimeSeries, bandFromTimeSeries, boxFromTimeSeries, barsFromTimeSeries, } from './data.js';
|
|
39
|
+
export { fromTimeSeries, bandFromTimeSeries, boxFromTimeSeries, barsFromTimeSeries, ohlcFromTimeSeries, } from './data.js';
|
|
39
40
|
export { defaultTheme, estelaTheme } from './theme.js';
|
|
40
41
|
// CSS-custom-property → ChartTheme bridge: build a theme from a design system's
|
|
41
42
|
// 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/theme.d.ts
CHANGED
|
@@ -73,6 +73,22 @@ export interface ChartTheme {
|
|
|
73
73
|
readonly default: BoxStyle;
|
|
74
74
|
readonly [semantic: string]: BoxStyle;
|
|
75
75
|
};
|
|
76
|
+
/**
|
|
77
|
+
* Map from a candle's semantic identifier to its style — a first-class OHLC
|
|
78
|
+
* mark ({@link Candlestick}), the financial sibling of the box. Unlike the
|
|
79
|
+
* other slots a {@link CandleStyle} carries a *pair* (`rising`/`falling`, plus
|
|
80
|
+
* an optional `neutral` doji): direction colouring is intrinsic to the mark, so
|
|
81
|
+
* one colour can't express it. `default` is the fallback; a chart tags each
|
|
82
|
+
* series with a role (`<Candlestick as="AAPL" />`) resolving `candle[semantic]
|
|
83
|
+
* ?? candle.default`. The default pair is **neutral / unbranded** (a
|
|
84
|
+
* distinguishable up/down, *not* market green/red) — a consumer supplies its
|
|
85
|
+
* own palette via `cssVarTheme`; the library owns the type + a renderable
|
|
86
|
+
* default, never a brand.
|
|
87
|
+
*/
|
|
88
|
+
readonly candle: {
|
|
89
|
+
readonly default: CandleStyle;
|
|
90
|
+
readonly [semantic: string]: CandleStyle;
|
|
91
|
+
};
|
|
76
92
|
/**
|
|
77
93
|
* Map from a bar's semantic identifier to its style — the fill, the
|
|
78
94
|
* selected-bar highlight, and the slot gap / minimum width ({@link BarChart}).
|
|
@@ -204,6 +220,42 @@ export interface BoxStyle {
|
|
|
204
220
|
readonly whisker: string;
|
|
205
221
|
readonly whiskerWidth: number;
|
|
206
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* A resolved candlestick style ({@link Candlestick}). A candle is unreadable in
|
|
225
|
+
* one colour — rising vs falling *must* differ to mean anything — so the style
|
|
226
|
+
* is a **pair**: `rising` (close > open) and `falling` (close < open), each a
|
|
227
|
+
* `body` (the open→close rectangle / the OHLC bar) and a `wick` (the high–low
|
|
228
|
+
* line / the bar's stem). `neutral` styles a **doji** (open === close); it falls
|
|
229
|
+
* back to `rising` when unset. `bodyWidth` is the body's fraction of the candle
|
|
230
|
+
* slot (0–1; the wick always sits at the slot centre) — omitted ⇒ `0.8`.
|
|
231
|
+
* `wickWidth` is the wick / bar stroke width in px.
|
|
232
|
+
*
|
|
233
|
+
* With `colorBy='series'` the direction split is bypassed and every candle draws
|
|
234
|
+
* in the `rising` colours (one colour = one series, for a candle sitting beside
|
|
235
|
+
* coloured lines).
|
|
236
|
+
*/
|
|
237
|
+
export interface CandleStyle {
|
|
238
|
+
/** Rising candle (close > open) — body + wick colours. Also the single colour
|
|
239
|
+
* under `colorBy='series'`. */
|
|
240
|
+
readonly rising: {
|
|
241
|
+
readonly body: string;
|
|
242
|
+
readonly wick: string;
|
|
243
|
+
};
|
|
244
|
+
/** Falling candle (close < open) — body + wick colours. */
|
|
245
|
+
readonly falling: {
|
|
246
|
+
readonly body: string;
|
|
247
|
+
readonly wick: string;
|
|
248
|
+
};
|
|
249
|
+
/** Doji (open === close) — body + wick colours; falls back to `rising` if unset. */
|
|
250
|
+
readonly neutral?: {
|
|
251
|
+
readonly body: string;
|
|
252
|
+
readonly wick: string;
|
|
253
|
+
};
|
|
254
|
+
/** Body width as a fraction of the candle slot (0–1). Omitted ⇒ `0.8`. */
|
|
255
|
+
readonly bodyWidth?: number;
|
|
256
|
+
/** Wick / OHLC-bar stroke width in px. */
|
|
257
|
+
readonly wickWidth: number;
|
|
258
|
+
}
|
|
207
259
|
/**
|
|
208
260
|
* A resolved area style: an outline stroke plus a graded fill. `color`/`width`
|
|
209
261
|
* stroke the value line on top; `fill` is the gradient base colour, opaque
|
package/dist/theme.js
CHANGED
|
@@ -75,6 +75,18 @@ export const defaultTheme = {
|
|
|
75
75
|
whiskerWidth: 1,
|
|
76
76
|
},
|
|
77
77
|
},
|
|
78
|
+
candle: {
|
|
79
|
+
// Neutral / unbranded up-down pair — *not* market green/red (a consumer
|
|
80
|
+
// supplies that via cssVarTheme). Rising reuses the brand blue; falling the
|
|
81
|
+
// warm secondary accent — distinguishable at a glance on the light ground.
|
|
82
|
+
default: {
|
|
83
|
+
rising: { body: '#2563eb', wick: '#1e3a8a' },
|
|
84
|
+
falling: { body: '#e8836b', wick: '#b4442a' },
|
|
85
|
+
neutral: { body: '#94a3b8', wick: '#64748b' },
|
|
86
|
+
bodyWidth: 0.7,
|
|
87
|
+
wickWidth: 1,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
78
90
|
bar: {
|
|
79
91
|
// Flat blue fill; the selected bar brightens + outlines. `secondary` reuses
|
|
80
92
|
// the line's warm accent for a second series.
|
|
@@ -202,6 +214,18 @@ export const estelaTheme = {
|
|
|
202
214
|
whiskerWidth: 1.5,
|
|
203
215
|
},
|
|
204
216
|
},
|
|
217
|
+
candle: {
|
|
218
|
+
// On the dark ground: brand teal rising, warm filament falling — the estela
|
|
219
|
+
// palette's own up/down, still *not* literal green/red (a financial consumer
|
|
220
|
+
// like Tidal overlays its market palette via cssVarTheme).
|
|
221
|
+
default: {
|
|
222
|
+
rising: { body: '#15B3A6', wick: '#0E7D74' }, // --es-estela
|
|
223
|
+
falling: { body: '#E0B36A', wick: '#B4863F' }, // --es-filament
|
|
224
|
+
neutral: { body: '#4E6B6B', wick: '#DBEAE8' }, // --es-slate / --es-mist
|
|
225
|
+
bodyWidth: 0.7,
|
|
226
|
+
wickWidth: 1.5,
|
|
227
|
+
},
|
|
228
|
+
},
|
|
205
229
|
bar: {
|
|
206
230
|
// Brand-teal fill on the dark ground; the selected bar lifts to the bright
|
|
207
231
|
// reef + an outline. `secondary` is the warm filament accent.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pond-ts/charts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Canvas-rendered, streaming-first time-series charts for pond-ts",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"perf": "PERF_BENCH=1 playwright test perf.spec.ts --workers=1"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
|
-
"@pond-ts/react": "^0.
|
|
42
|
-
"pond-ts": "^0.
|
|
41
|
+
"@pond-ts/react": "^0.41.0",
|
|
42
|
+
"pond-ts": "^0.41.0",
|
|
43
43
|
"react": "^18.0.0 || ^19.0.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|