@pond-ts/charts 0.40.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 CHANGED
@@ -8,7 +8,8 @@ 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.40.0...HEAD
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
12
13
  [0.40.0]: https://github.com/pjm17971/pond-ts/compare/v0.39.0...v0.40.0
13
14
  [0.39.0]: https://github.com/pjm17971/pond-ts/compare/v0.38.0...v0.39.0
14
15
  [0.38.0]: https://github.com/pjm17971/pond-ts/compare/v0.37.0...v0.38.0
@@ -38,6 +39,33 @@ patch bumps are strictly additive.
38
39
 
39
40
  ## [Unreleased]
40
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
+
41
69
  ## [0.40.0] — 2026-07-05
42
70
 
43
71
  A **core + charts** release from the estela `DataChart`-port friction wave.
@@ -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/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. 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
- }
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) must not be mutated,
257
- // so synthesise the spans into fresh buffers.
258
- const ax = series.axisValues();
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.40.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.40.0",
42
- "pond-ts": "^0.40.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": {