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