packages/core/src/features/viz/inspection/candle.ts
This is the source snapshot used to build these API details. View this revision on GitHub.
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 version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision dd39ce2. ALPHA — NOT FOR PRODUCTION USE.