Skip to content

packages/core/src/features/viz/inspection/candle.ts

Read as Markdown

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

Back to reference

1 import type { LayoutBox } from "@pibbl/core";
2 import type { CandlestickSeriesProps } from "../lib/candlestick-series.js";
3 import {
4   invertNumericPosition,
5   type NumericPositionScale,
6 } from "../lib/series-position.js";
7 import type { ResolvedContinuousScale } from "../lib/types.js";
8 import type {
9   NumericRenderedSeries,
10   RenderedSeriesPoint,
11 } from "./query-types.js";
12 import { nearestRecordedX } from "./queries.js";
13 import { useRenderedRef } from "./owned-ref.js";
14 
15 /** One original candle with its evaluated OHLC values and committed local geometry.
16  * @see {@link CandleRenderedSeries}
17  */
18 export interface CandleObservation<D> {
19   /** Caller-owned original record. */
20   readonly datum: D;
21   /** Original source index, including skipped records. */
22   readonly sourceIndex: number;
23   /** Evaluated UTC-millisecond timestamp. */
24   readonly time: number;
25   /** Evaluated opening value. */
26   readonly open: number;
27   /** Evaluated high value. */
28   readonly high: number;
29   /** Evaluated low value. */
30   readonly low: number;
31   /** Evaluated closing value. */
32   readonly close: number;
33   /** Closing endpoint in series-local logical units. */
34   readonly anchorLocal: Readonly<RenderedSeriesPoint>;
35   /** Closing endpoint in root-logical units. */
36   readonly anchorRoot: Readonly<RenderedSeriesPoint>;
37   /** Body rectangle before world transforms. */
38   readonly bodyBoundsLocal: Readonly<LayoutBox>;
39   /** Wick center and endpoints before world transforms. */
40   readonly wickLocal: Readonly<{
41     /** Horizontal wick center in series-local logical units. */
42     x: number;
43     /** Mapped low endpoint in series-local logical units. */
44     lowY: number;
45     /** Mapped high endpoint in series-local logical units. */
46     highY: number;
47   }>;
48 }
49 
50 /** A candle candidate whose closing endpoint can be tested against committed clips.
51  * @see {@link CandleObservation}
52  */
53 export interface CandleInspectionCandidate<D> extends CandleObservation<D> {
54   /**
55    * @returns Whether the close anchor lies inside every committed clip.
56    */
57   anchorInsideClips(): boolean;
58 }
59 
60 /** Candle selected by nearest timestamp.
61  * @see {@link nearestRecordedCandleX}
62  */
63 export interface NearestCandleHit<D> extends CandleObservation<D> {
64   /** Absolute UTC-millisecond distance from the requested timestamp. */
65   readonly timeDelta: number;
66 }
67 
68 /** Candle part whose actual painted geometry covers a root-logical query point.
69  * @see {@link paintedCandleAt}
70  */
71 export interface PaintedCandleHit<D> extends CandleObservation<D> {
72   /** Part selected in source paint order: body paints after wick. */
73   readonly part: "body" | "wick";
74 }
75 
76 /** @internal Local candle geometry and paint flags committed by CandlestickSeries. */
77 export interface CapturedCandleMark<D> extends Omit<
78   CandleObservation<D>,
79   "anchorRoot"
80 > {
81   /** Whether the body paints an interior. */
82   readonly bodyFill: boolean;
83   /** Local body outline width, zero when its outline is not painted. */
84   readonly bodyStrokeWidthLocal: number;
85   /** Local wick stroke width, zero when its wick is not painted. */
86   readonly wickStrokeWidthLocal: number;
87 }
88 
89 /** Borrowed committed candle generation.
90  * @see {@link CandleInspectionRef}
91  */
92 export interface CandleRenderedSeries<D> {
93   /** Captured original records, values, and geometry in source order. */
94   readonly observations: readonly CandleObservation<D>[];
95   /** Throws after replacement or disposal. */
96   assertLive(): void;
97   /**
98    * Selects the supplied/nearest timestamp at a root-logical coordinate.
99    * @param pointRoot - Root-logical point to invert through the committed transform.
100    * @returns UTC milliseconds, or undefined for singular transforms or empty mappings.
101    */
102   timeAtRoot(pointRoot: Readonly<RenderedSeriesPoint>): number | undefined;
103   /**
104    * Tests local geometry against each committed clip.
105    * @param pointLocal - Series-local logical point.
106    * @returns Whether every committed clip contains the point.
107    */
108   anchorInsideClips(pointLocal: Readonly<RenderedSeriesPoint>): boolean;
109   /** @internal Captured paint flags in source paint order. */
110   readonly marks: readonly CapturedCandleMark<D>[];
111   /** @internal Numeric-query view based on close endpoints. */
112   readonly numeric: NumericRenderedSeries<D>;
113   /**
114    * @internal Converts root-logical points to local geometry.
115    * @param point - Root-logical point to convert.
116    * @returns Series-local point, or undefined when the transform is singular.
117    */
118   localAtRoot(
119     point: Readonly<RenderedSeriesPoint>,
120   ): Readonly<RenderedSeriesPoint> | undefined;
121 }
122 
123 /** Mount-owned optional candle capture.
124  * @see {@link useCandleInspection}
125  */
126 export interface CandleInspectionRef<D> {
127   /**
128    * @returns The current borrowed generation, or undefined before paint and after disposal.
129    */
130   rendered(): CandleRenderedSeries<D> | undefined;
131   /**
132    * @internal Records a completed CandlestickSeries paint without executing queries.
133    * @param input - Source-ordered marks and the committed numeric mappings.
134    */
135   capture(input: {
136     /** Source-ordered local marks and paint flags. */
137     marks: readonly CapturedCandleMark<D>[];
138     /** Committed numeric or trading-time horizontal mapping. */
139     xScale: NumericPositionScale;
140     /** Committed continuous vertical mapping. */
141     yScale: ResolvedContinuousScale;
142   }): void;
143 }
144 
145 /**
146  * Creates a stable optional candle receiver without reading data or subscribing.
147  *
148  * @param _inputs - Inputs retained only to infer the original datum type.
149  * @returns A ref for exactly one CandlestickSeries inspection prop.
150  * @see {@link CandleInspectionRef}
151  */
152 export function useCandleInspection<D>(
153   _inputs: Pick<
154     CandlestickSeriesProps<D>,
155     "data" | "time" | "open" | "high" | "low" | "close"
156   >,
157 ): CandleInspectionRef<D> {
158   return useRenderedRef<
159     {
160       marks: readonly CapturedCandleMark<D>[];
161       xScale: NumericPositionScale;
162       yScale: ResolvedContinuousScale;
163     },
164     CandleRenderedSeries<D>
165   >("candleInspection", (candidate, geometry) => {
166     const observations = Object.freeze(
167       candidate.marks.map((mark) =>
168         Object.freeze({
169           datum: mark.datum,
170           sourceIndex: mark.sourceIndex,
171           time: mark.time,
172           open: mark.open,
173           high: mark.high,
174           low: mark.low,
175           close: mark.close,
176           anchorLocal: mark.anchorLocal,
177           anchorRoot: geometry.rootAtLocal(mark.anchorLocal),
178           bodyBoundsLocal: mark.bodyBoundsLocal,
179           wickLocal: mark.wickLocal,
180         }),
181       ),
182     );
183     const numeric: NumericRenderedSeries<D> = Object.freeze({
184       observations: Object.freeze(
185         observations.map((row) =>
186           Object.freeze({
187             ...row,
188             domainX: row.time,
189             domainY: row.close,
190             run: 0,
191           }),
192         ),
193       ),
194       assertLive: geometry.assertLive,
195       domainXAtRoot(point: Readonly<RenderedSeriesPoint>) {
196         const local = geometry.localAtRoot(point);
197         return local
198           ? invertNumericPosition(candidate.xScale, local.x)
199           : undefined;
200       },
201       anchorInsideClips: geometry.anchorInsideClips,
202       anchorAtDomain(point: Readonly<{ domainX: number; domainY: number }>) {
203         geometry.assertLive();
204         const anchorLocal = Object.freeze({
205           x: candidate.xScale.map(point.domainX),
206           y: candidate.yScale.map(point.domainY),
207         });
208         return Object.freeze({
209           anchorLocal,
210           anchorRoot: geometry.rootAtLocal(anchorLocal),
211         });
212       },
213     });
214     return Object.freeze({
215       observations,
216       marks: Object.freeze(candidate.marks.slice()),
217       numeric,
218       assertLive: geometry.assertLive,
219       localAtRoot: geometry.localAtRoot,
220       anchorInsideClips: geometry.anchorInsideClips,
221       timeAtRoot: numeric.domainXAtRoot,
222     });
223   });
224 }
225 
226 /**
227  * Selects a captured candle nearest to a requested UTC timestamp.
228  *
229  * @param rendered - Borrowed committed candle generation.
230  * @param options - Timestamp and optional pure candidate filter.
231  * @returns The first source-order candle on a tie, or undefined when none qualifies.
232  * @see {@link NearestCandleHit}
233  */
234 export function nearestRecordedCandleX<D>(
235   rendered: CandleRenderedSeries<D>,
236   options: {
237     time: number;
238     filter?: (observation: CandleInspectionCandidate<D>) => boolean;
239   },
240 ): NearestCandleHit<D> | undefined {
241   const hit = nearestRecordedX(rendered.numeric, {
242     domainX: options.time,
243     filter: options.filter
244       ? (candidate) =>
245           options.filter!(
246             candidate as typeof candidate & CandleInspectionCandidate<D>,
247           )
248       : undefined,
249   });
250   if (!hit) return undefined;
251   const row = rendered.observations.find(
252     (observation) => observation.sourceIndex === hit.sourceIndex,
253   )!;
254   return Object.freeze({ ...row, timeDelta: hit.domainDelta });
255 }
256 
257 function rectangleCovers(
258   point: Readonly<RenderedSeriesPoint>,
259   bounds: Readonly<LayoutBox>,
260   fill: boolean,
261   strokeWidth: number,
262 ): boolean {
263   const half = strokeWidth / 2;
264   const inside =
265     point.x >= bounds.x &&
266     point.x <= bounds.x + bounds.width &&
267     point.y >= bounds.y &&
268     point.y <= bounds.y + bounds.height;
269   const outline =
270     half > 0 &&
271     point.x >= bounds.x - half &&
272     point.x <= bounds.x + bounds.width + half &&
273     point.y >= bounds.y - half &&
274     point.y <= bounds.y + bounds.height + half &&
275     !(
276       point.x > bounds.x + half &&
277       point.x < bounds.x + bounds.width - half &&
278       point.y > bounds.y + half &&
279       point.y < bounds.y + bounds.height - half
280     );
281   return (fill && inside) || outline;
282 }
283 
284 function wickCovers(
285   point: Readonly<RenderedSeriesPoint>,
286   wick: Readonly<{ x: number; lowY: number; highY: number }>,
287   strokeWidth: number,
288 ): boolean {
289   if (strokeWidth <= 0 || wick.lowY === wick.highY) return false;
290   const half = strokeWidth / 2;
291   return (
292     point.x >= wick.x - half &&
293     point.x <= wick.x + half &&
294     point.y >= Math.min(wick.lowY, wick.highY) &&
295     point.y <= Math.max(wick.lowY, wick.highY)
296   );
297 }
298 
299 /**
300  * Finds the last-painted candle body or wick covering a root-logical point.
301  *
302  * Body coverage is tested before wick coverage because a candle paints its body after its wick.
303  *
304  * @param rendered - Borrowed committed candle generation.
305  * @param options - Root-logical point; clips and singular transforms produce no result.
306  * @returns Captured candle and painted part, or undefined for gaps and unpainted geometry.
307  * @see {@link PaintedCandleHit}
308  */
309 export function paintedCandleAt<D>(
310   rendered: CandleRenderedSeries<D>,
311   options: { rootPoint: Readonly<RenderedSeriesPoint> },
312 ): PaintedCandleHit<D> | undefined {
313   rendered.assertLive();
314   if (
315     !Number.isFinite(options.rootPoint.x) ||
316     !Number.isFinite(options.rootPoint.y)
317   )
318     throw new RangeError("paintedCandleAt rootPoint must be finite.");
319   const point = rendered.localAtRoot(options.rootPoint);
320   if (!point || !rendered.anchorInsideClips(point)) return undefined;
321   for (let index = rendered.marks.length - 1; index >= 0; index--) {
322     const mark = rendered.marks[index]!;
323     if (
324       rectangleCovers(
325         point,
326         mark.bodyBoundsLocal,
327         mark.bodyFill,
328         mark.bodyStrokeWidthLocal,
329       )
330     )
331       return Object.freeze({
332         ...rendered.observations[index]!,
333         part: "body" as const,
334       });
335     if (wickCovers(point, mark.wickLocal, mark.wickStrokeWidthLocal))
336       return Object.freeze({
337         ...rendered.observations[index]!,
338         part: "wick" as const,
339       });
340   }
341   return undefined;
342 }
343 

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