Skip to content

packages/core/src/features/viz/lib/candlestick-series.tsx

Read as Markdown

This is the source snapshot used to build these API details. View this revision on GitHub.

Back to reference

1 import {
2   Group,
3   Path,
4   resolveSignalValue,
5   type FillStyle,
6   type StrokeStyle,
7   type SignalValue,
8   type PibblEventHandlers,
9   type PibblEventParticipationOptions,
10   type PibblNode,
11 } from "@pibbl/core";
12 import { withHooksForbidden } from "@pibbl/core/internal";
13 import type { LineAccessor, ScaleId } from "./types.js";
14 import type {
15   CandleInspectionRef,
16   CapturedCandleMark,
17 } from "../inspection/candle.js";
18 import { useNumericPosition } from "./series-position.js";
19 import { useContinuousScale } from "./use-continuous-scale.js";
20 import { pointAccessor, pointData, finiteCoordinate } from "./point-data.js";
21 import { financialMarkWidth, candleGeometry } from "./financial-geometry.js";
22 import { opacity, width } from "./paint.js";
23 /** Open/close body paint, independent from wick paint.
24  * @see {@link CandlestickSeries}
25  */
26 export interface CandleBodyStyle {
27   /** Interior; default teal for close >= open, coral otherwise. Empty string disables fill. */ readonly fill?: FillStyle;
28   /** Optional body outline. */ readonly stroke?: StrokeStyle;
29   /** Nonnegative local outline width; default 1. */ readonly strokeWidth?: number;
30 }
31 /** High/low wick paint.
32  * @see {@link CandlestickSeries}
33  */
34 export interface CandleWickStyle {
35   /** Wick paint; default dark teal. Empty string disables wick paint. */ readonly stroke?: StrokeStyle;
36   /** Nonnegative local width; default 1. */ readonly strokeWidth?: number;
37 }
38 /** Accepted named body/wick presentation for OHLC marks.
39  * @see {@link CandlestickSeries}
40  */
41 export interface CandlestickSeriesStyle {
42   /** Positive local body width, or automatic 70% spacing capped at 12; singleton fallback 8. */ readonly bandwidth?:
43     number | "auto";
44   /** Independent open/close body appearance. */ readonly body?: CandleBodyStyle;
45   /** Independent high/low wick appearance. */ readonly wick?: CandleWickStyle;
46   /** Opacity zero through one; default 1. */ readonly opacity?: number;
47   /** Cursor while the series owns targeting. */ readonly cursor?: string;
48 }
49 /** Source-ordered OHLC records with named accessors and optional shared inspection.
50  * @see {@link CandlestickSeries}
51  */
52 export interface CandlestickSeriesProps<D>
53   extends PibblEventHandlers, PibblEventParticipationOptions {
54   /** Original records/signal; never sorted or mutated. */ readonly data: SignalValue<
55     readonly D[]
56   >;
57   /** UTC-millisecond property or callback. */ readonly time: LineAccessor<D>;
58   /** Open price property or callback. */ readonly open: LineAccessor<D>;
59   /** High price property or callback. */ readonly high: LineAccessor<D>;
60   /** Low price property or callback. */ readonly low: LineAccessor<D>;
61   /** Close price property or callback. */ readonly close: LineAccessor<D>;
62   /** Numeric/UTC/trading horizontal mapping identity. */ readonly xScale: ScaleId;
63   /** Independent numeric price mapping identity. */ readonly yScale: ScaleId;
64   /** Tests original rows before accessor evaluation.
65    * @param datum - Original row.
66    * @param index - Source index including skipped rows.
67    * @param data - Original complete array.
68    * @returns Whether the candle participates.
69    */ readonly defined?: (
70     datum: D,
71     index: number,
72     data: readonly D[],
73   ) => boolean;
74   /** Named body/wick appearance or a hook-free datum resolver.
75    * @param datum - Original row.
76    * @param index - Original source index.
77    * @param data - Complete source array.
78    * @returns Plain named candle styles.
79    */ readonly style?:
80     | SignalValue<CandlestickSeriesStyle>
81     | ((datum: D, index: number, data: readonly D[]) => CandlestickSeriesStyle);
82   /** Optional capture on the ordinary inspection foundation; no queries while painting. */ readonly inspection?: CandleInspectionRef<D>;
83 }
84 /**
85  * Paints OHLC bodies and wicks, preserving source order and skipping complete missing candles.
86  * Finite malformed OHLC rejects; equal open/close paints a one-local-unit doji.
87  * @param props - Named OHLC accessors, scales, styles and optional inspection.
88  * @returns Ordinary Pibbl composition; disjoint equal styles share paint paths.
89  * @see {@link CandlestickSeriesProps}
90  */
91 export function CandlestickSeries<D>({
92   data: input,
93   time: timeInput,
94   open: openInput,
95   high: highInput,
96   low: lowInput,
97   close: closeInput,
98   xScale,
99   yScale,
100   defined,
101   style: styleInput,
102   inspection,
103   pointerEvents,
104   ...events
105 }: CandlestickSeriesProps<D>): PibblNode {
106   const xs = useNumericPosition(xScale),
107     ys = useContinuousScale(yScale).get();
108   const data = pointData(resolveSignalValue(input), "CandlestickSeries");
109   const time = pointAccessor(timeInput),
110     open = pointAccessor(openInput),
111     high = pointAccessor(highInput),
112     low = pointAccessor(lowInput),
113     close = pointAccessor(closeInput);
114   const rows: {
115     datum: D;
116     sourceIndex: number;
117     time: number;
118     open: number;
119     high: number;
120     low: number;
121     close: number;
122     x: number;
123   }[] = [];
124   for (let i = 0; i < data.length; i++) {
125     const datum = data[i];
126     if (defined && !defined(datum, i, data)) continue;
127     const t = time(datum, i, data),
128       o = open(datum, i, data),
129       h = high(datum, i, data),
130       l = low(datum, i, data),
131       c = close(datum, i, data);
132     if (
133       !finiteCoordinate(t) ||
134       !finiteCoordinate(o) ||
135       !finiteCoordinate(h) ||
136       !finiteCoordinate(l) ||
137       !finiteCoordinate(c)
138     )
139       continue;
140     if (l > Math.min(o, c) || h < Math.max(o, c))
141       throw new RangeError(
142         `CandlestickSeries: malformed OHLC at source index ${i}; require low <= open/close <= high.`,
143       );
144     rows.push({
145       datum,
146       sourceIndex: i,
147       time: t,
148       open: o,
149       high: h,
150       low: l,
151       close: c,
152       x: xs.map(t),
153     });
154   }
155   const defaultWidth = financialMarkWidth(rows.map((row) => row.x));
156   const specified =
157     typeof styleInput === "function"
158       ? styleInput
159       : resolveSignalValue(styleInput);
160   const marks: CapturedCandleMark<D>[] | undefined = inspection
161     ? []
162     : undefined;
163   const batches: {
164     body: Path2D;
165     wick: Path2D;
166     fill: FillStyle;
167     bodyStroke: StrokeStyle | undefined;
168     bodyWidth: number;
169     wickStroke: StrokeStyle;
170     wickWidth: number;
171     opacity: number;
172     cursor: string | undefined;
173     minX: number;
174     maxX: number;
175   }[] = [];
176   for (const row of rows) {
177     const style =
178       typeof specified === "function"
179         ? withHooksForbidden("CandlestickSeries style callback", () =>
180             specified(row.datum, row.sourceIndex, data),
181           )
182         : (specified ?? {});
183     const markWidth =
184       style.bandwidth === undefined || style.bandwidth === "auto"
185         ? defaultWidth
186         : financialMarkWidth([], { width: style.bandwidth });
187     const g = candleGeometry({
188       centerX: row.x,
189       openY: ys.map(row.open),
190       closeY: ys.map(row.close),
191       highY: ys.map(row.high),
192       lowY: ys.map(row.low),
193       width: markWidth,
194       dojiHeight: 1,
195     });
196     const fill =
197         style.body?.fill ?? (row.close >= row.open ? "#087f8c" : "#d9674c"),
198       bodyStroke = style.body?.stroke,
199       bodyWidth = width(style.body?.strokeWidth ?? 1, "CandlestickSeries body"),
200       wickStroke = style.wick?.stroke ?? "#173a35",
201       wickWidth = width(style.wick?.strokeWidth ?? 1, "CandlestickSeries wick"),
202       alpha = opacity(style.opacity ?? 1, "CandlestickSeries");
203     marks?.push(
204       Object.freeze({
205         datum: row.datum,
206         sourceIndex: row.sourceIndex,
207         time: row.time,
208         open: row.open,
209         high: row.high,
210         low: row.low,
211         close: row.close,
212         anchorLocal: Object.freeze({ x: row.x, y: ys.map(row.close) }),
213         bodyBoundsLocal: g.bodyBoundsLocal,
214         wickLocal: g.wickLocal,
215         bodyFill: !!fill && alpha > 0,
216         bodyStrokeWidthLocal: bodyStroke && alpha > 0 ? bodyWidth : 0,
217         wickStrokeWidthLocal: wickStroke && alpha > 0 ? wickWidth : 0,
218       }),
219     );
220     if (alpha === 0) continue;
221     const half =
222         Math.max(bodyStroke ? bodyWidth : 0, wickStroke ? wickWidth : 0) / 2,
223       minX = g.bodyBoundsLocal.x - half,
224       maxX = g.bodyBoundsLocal.x + markWidth + half;
225     let batch = batches.at(-1);
226     if (
227       !batch ||
228       (minX < batch.maxX && maxX > batch.minX) ||
229       batch.fill !== fill ||
230       batch.bodyStroke !== bodyStroke ||
231       batch.bodyWidth !== bodyWidth ||
232       batch.wickStroke !== wickStroke ||
233       batch.wickWidth !== wickWidth ||
234       batch.opacity !== alpha ||
235       batch.cursor !== style.cursor
236     ) {
237       batch = {
238         body: new Path2D(),
239         wick: new Path2D(),
240         fill,
241         bodyStroke,
242         bodyWidth,
243         wickStroke,
244         wickWidth,
245         opacity: alpha,
246         cursor: style.cursor,
247         minX,
248         maxX,
249       };
250       batches.push(batch);
251     }
252     batch.minX = Math.min(batch.minX, minX);
253     batch.maxX = Math.max(batch.maxX, maxX);
254     const b = g.bodyBoundsLocal;
255     batch.body.rect(b.x, b.y, b.width, b.height);
256     batch.wick.moveTo(g.wickLocal.x, g.wickLocal.highY);
257     batch.wick.lineTo(g.wickLocal.x, g.wickLocal.lowY);
258   }
259   inspection?.capture({ marks: marks!, xScale: xs, yScale: ys });
260   return (
261     <Group {...events}>
262       {batches.map((batch, index) => (
263         <Group key={index}>
264           <Path
265             pointerEvents={pointerEvents}
266             style={{
267               d: batch.wick,
268               stroke: batch.wickStroke,
269               strokeWidth: batch.wickWidth,
270               opacity: batch.opacity,
271               cursor: batch.cursor,
272             }}
273           />
274           <Path
275             pointerEvents={pointerEvents}
276             style={{
277               d: batch.body,
278               fill: batch.fill,
279               stroke: batch.bodyStroke,
280               strokeWidth: batch.bodyWidth,
281               opacity: batch.opacity,
282               cursor: batch.cursor,
283             }}
284           />
285         </Group>
286       ))}
287     </Group>
288   );
289 }
290 

Documentation built with @pibbl/core 0.0.2, revision dd39ce2. ALPHA — NOT FOR PRODUCTION USE.