Skip to content

packages/core/src/features/viz/lib/baseline-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 { Path, resolveSignalValue } from "@pibbl/core";
2 import type {
3   FillStyle,
4   PibblEventHandlers,
5   PibblEventParticipationOptions,
6   SignalValue,
7   StrokeStyle,
8 } from "@pibbl/core";
9 import { useInternalValueMemoSlot, validateTexturePaint } from "@pibbl/core/internal";
10 import type { SignalStyle } from "@pibbl/core/internal";
11 import type { LineAccessor, LineSeriesProps, ScaleId } from "./types.js";
12 import { useNumericPosition } from "./series-position.js";
13 import { opacity, width } from "./paint.js";
14 import { finiteCoordinate, pointAccessor, pointData } from "./point-data.js";
15 
16 /** Shared paint for one side of a baseline. See {@link BaselineSeries}. */
17 export interface BaselineSideStyle {
18   /** Area fill for this side of the baseline. */
19   readonly fill?: FillStyle;
20   /** Line stroke for this side of the baseline. */
21   readonly stroke?: StrokeStyle;
22 }
23 
24 /** Shared presentation for a linear baseline series. See {@link BaselineSeries}. */
25 export interface BaselineSeriesStyle {
26   /** Paint for y values greater than or equal to baseline. */
27   readonly above?: BaselineSideStyle;
28   /** Paint for y values less than baseline. */
29   readonly below?: BaselineSideStyle;
30   /** Opacity for both area fills; defaults to 0.2. */
31   readonly fillOpacity?: number;
32   /** Width for both line strokes; defaults to 2. */
33   readonly strokeWidth?: number;
34   /** Cursor while a painted series path owns targeting. */
35   readonly cursor?: string;
36 }
37 
38 /** A linear connected series split into independently painted regions at baseline crossings. See
39  * {@link BaselineSeries}.
40  */
41 export interface BaselineSeriesProps<D>
42   extends PibblEventHandlers, PibblEventParticipationOptions {
43   /** Application records or a signal of records; source order is retained. */
44   readonly data: SignalValue<readonly D[]>;
45   /** Property key or callback extracting horizontal coordinates. */
46   readonly x: LineAccessor<D>;
47   /** Property key or callback extracting vertical coordinates. */
48   readonly y: LineAccessor<D>;
49   /** Numeric or trading-time horizontal scale identity. */
50   readonly xScale: ScaleId;
51   /** Linear vertical scale identity. */
52   readonly yScale: ScaleId;
53   /** Finite data-space division between the above and below presentations. */
54   readonly baseline: SignalValue<number>;
55   /** Excludes a record before its accessors run. */
56   readonly defined?: LineSeriesProps<D>["defined"];
57   /** Optional committed original-record capture on the standard numeric inspection foundation. */
58   readonly inspection?: import("../inspection/capture-types.js").NumericInspectionRef<D>;
59   /** Shared above/below paint and interaction presentation. */
60   readonly style?: SignalValue<SignalStyle<BaselineSeriesStyle>>;
61 }
62 
63 interface Point<D> {
64   readonly datum: D;
65   readonly domainX: number;
66   readonly domainY: number;
67   readonly x: number;
68   readonly y: number;
69 }
70 
71 interface Piece {
72   readonly side: "above" | "below";
73   readonly fill: Path2D | undefined;
74   readonly stroke: Path2D;
75 }
76 
77 function fill(value: unknown, receiver: string): FillStyle {
78   if (typeof value === "string" || value instanceof CanvasGradient || value instanceof CanvasPattern)
79     return value;
80   try {
81     validateTexturePaint(value as never);
82     return value as FillStyle;
83   } catch {
84     throw new TypeError(`${receiver}: invalid fill.`);
85   }
86 }
87 
88 function side(value: number, baseline: number): "above" | "below" {
89   return value >= baseline ? "above" : "below";
90 }
91 
92 /**
93  * Draws a linear connected series with independently painted regions above and below a baseline.
94  * Equal values belong to the above stroke; zero-area spans never receive a fill. Gaps split runs,
95  * and optional inspection captures only original records.
96  * @param props - Records, a numeric or trading-time x scale, linear price y scale, baseline, event props, and shared side paint.
97  * @returns Filled and stroked Pibbl paths in source paint order.
98  * @see {@link LineSeriesProps}
99  */
100 export function BaselineSeries<D>({
101   data: dataInput,
102   x: xInput,
103   y: yInput,
104   xScale: xId,
105   yScale: yId,
106   baseline: baselineInput,
107   defined,
108   inspection,
109   style: styleInput,
110   ...eventProps
111 }: BaselineSeriesProps<D>) {
112   const xScale = useNumericPosition(xId), yScale = useNumericPosition(yId);
113   if (yScale.type !== "linear")
114     throw new TypeError("BaselineSeries requires LinearScale for yScale.");
115   const x = useInternalValueMemoSlot("vizBaselineXAccessor", () => pointAccessor(xInput), [xInput]);
116   const y = useInternalValueMemoSlot("vizBaselineYAccessor", () => pointAccessor(yInput), [yInput]);
117   const data = pointData(resolveSignalValue(dataInput), "BaselineSeries");
118   const baseline = resolveSignalValue(baselineInput);
119   if (typeof baseline !== "number" || !Number.isFinite(baseline))
120     throw new RangeError("BaselineSeries baseline must be finite.");
121   const baselineY = yScale.map(baseline);
122   if (!Number.isFinite(baselineY)) throw new RangeError("BaselineSeries baseline must map to a finite coordinate.");
123   const style = resolveSignalValue(styleInput) ?? {};
124   if (style === null || typeof style !== "object" || Array.isArray(style))
125     throw new TypeError("BaselineSeries style must be an object.");
126   const above = resolveSignalValue(style.above) ?? {}, below = resolveSignalValue(style.below) ?? {};
127   const aboveFill = fill(resolveSignalValue(above.fill) ?? "#087f8c", "BaselineSeries above");
128   const belowFill = fill(resolveSignalValue(below.fill) ?? "#d9674c", "BaselineSeries below");
129   const aboveStroke = resolveSignalValue(above.stroke) ?? "#087f8c";
130   const belowStroke = resolveSignalValue(below.stroke) ?? "#d9674c";
131   const fillOpacity = opacity(resolveSignalValue(style.fillOpacity) ?? 0.2, "BaselineSeries fill");
132   const strokeWidth = width(resolveSignalValue(style.strokeWidth) ?? 2, "BaselineSeries stroke");
133   const cursor = resolveSignalValue(style.cursor);
134   if (cursor !== undefined && typeof cursor !== "string") throw new TypeError("BaselineSeries cursor must be a string.");
135 
136   const pieces: Piece[] = [];
137   const captured: import("../inspection/capture-types.js").CapturedNumericObservation<D>[] | undefined = inspection ? [] : undefined;
138   let previous: Point<D> | undefined;
139   let run = 0;
140   const breakRun = () => { previous = undefined; run++; };
141   const segment = (first: Point<D>, second: Point<D>) => {
142     const firstSide = side(first.domainY, baseline), secondSide = side(second.domainY, baseline);
143     const points: { point: Point<D> | { x: number; y: number; domainY: number }; side: "above" | "below" }[] = [
144       { point: first, side: firstSide },
145     ];
146     if (firstSide !== secondSide) {
147       const firstDistance = Math.abs(first.domainY - baseline);
148       const secondDistance = Math.abs(second.domainY - baseline);
149       const amount = firstDistance > secondDistance
150         ? 1 / (1 + secondDistance / firstDistance)
151         : (firstDistance / secondDistance) / (1 + firstDistance / secondDistance);
152       points.push({
153         point: { x: first.x + (second.x - first.x) * amount, y: baselineY, domainY: baseline },
154         side: firstSide,
155       });
156       points.push({
157         point: { x: first.x + (second.x - first.x) * amount, y: baselineY, domainY: baseline },
158         side: secondSide,
159       });
160     }
161     points.push({ point: second, side: secondSide });
162     for (let index = 1; index < points.length; index++) {
163       const start = points[index - 1]!, end = points[index]!;
164       const kind = start.side;
165       const a = start.point, b = end.point;
166       const stroke = new Path2D();
167       stroke.moveTo(a.x, a.y);
168       stroke.lineTo(b.x, b.y);
169       if (a.y === baselineY && b.y === baselineY) {
170         pieces.push({ side: kind, fill: undefined, stroke });
171         continue;
172       }
173       const fill = new Path2D();
174       fill.moveTo(a.x, baselineY);
175       fill.lineTo(a.x, a.y);
176       fill.lineTo(b.x, b.y);
177       fill.lineTo(b.x, baselineY);
178       fill.closePath();
179       pieces.push({ side: kind, fill, stroke });
180     }
181   };
182   for (let index = 0; index < data.length; index++) {
183     const datum = data[index]!;
184     if (defined && !defined(datum, index, data)) { breakRun(); continue; }
185     const domainX = x(datum, index, data), domainY = y(datum, index, data);
186     if (!finiteCoordinate(domainX) || !finiteCoordinate(domainY)) { breakRun(); continue; }
187     const mappedX = xScale.map(domainX), mappedY = yScale.map(domainY);
188     if (!Number.isFinite(mappedX) || !Number.isFinite(mappedY)) { breakRun(); continue; }
189     const point: Point<D> = { datum, domainX, domainY, x: mappedX, y: mappedY };
190     captured?.push(Object.freeze({ datum, sourceIndex: index, domainX: point.domainX, domainY: point.domainY, anchorLocal: Object.freeze({ x: point.x, y: point.y }), run }));
191     if (previous) segment(previous, point);
192     previous = point;
193   }
194   if (inspection) inspection.capture({ observations: captured!, xScale, yScale });
195   return (
196     <>
197       {pieces.flatMap((piece, index) => {
198         const fill = piece.side === "above" ? aboveFill : belowFill;
199         const stroke = piece.side === "above" ? aboveStroke : belowStroke;
200         return [
201           ...(piece.fill ? [<Path key={`${index}:fill`} {...eventProps} style={{ d: piece.fill, fill, opacity: fillOpacity, cursor }} />] : []),
202           <Path key={`${index}:stroke`} {...eventProps} style={{ d: piece.stroke, stroke, strokeWidth, cursor }} />,
203         ];
204       })}
205     </>
206   );
207 }
208 

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