packages/core/src/features/viz/lib/interval-band-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 } from "@pibbl/core";
8 import {
9 useInternalValueMemoSlot,
10 validateTexturePaint,
11 } from "@pibbl/core/internal";
12 import type { SignalStyle } from "@pibbl/core/internal";
13 import type { AreaSeriesStyle } from "./area-series.js";
14 import { opacity } from "./paint.js";
15 import type { LineAccessor, LineCoordinate, LineSeriesProps } from "./types.js";
16 import {
17 appendCurveForward,
18 appendCurveReverse,
19 curveGeometry,
20 resolveSeriesCurve,
21 validatePairedCurveGeometry,
22 } from "./curve-definition.js";
23 import { useContinuousScale } from "./use-continuous-scale.js";
24
25 /**
26 * Authored inputs for {@link IntervalBandSeries}.
27 *
28 * The data, horizontal coordinate, scales, and defined predicate use the same contract as
29 * {@link LineSeriesProps}. Each contiguous run of at least two observations draws its upper
30 * boundary forward and lower boundary backward.
31 *
32 * @see {@link AreaSeriesStyle}
33 * @see {@link LineSeriesProps}
34 */
35 export interface IntervalBandSeriesProps<D>
36 extends PibblEventHandlers, PibblEventParticipationOptions {
37 /** Application data supplied directly or through a signal. */
38 readonly data: LineSeriesProps<D>["data"];
39 /** Property key or callback extracting the horizontal coordinate. */
40 readonly x: LineAccessor<D>;
41 /** Property key or callback extracting the lower bound. */
42 readonly lower: LineAccessor<D>;
43 /** Property key or callback extracting the upper bound. */
44 readonly upper: LineAccessor<D>;
45 /** Continuous scale used to map horizontal data coordinates. */
46 readonly xScale: LineSeriesProps<D>["xScale"];
47 /** Continuous scale used to map both data bounds. */
48 readonly yScale: LineSeriesProps<D>["yScale"];
49 /** Selects records that participate in a matching-bounds run. */
50 readonly defined?: LineSeriesProps<D>["defined"];
51 /** Curve used to connect successive matching bounds. */
52 readonly curve?: LineSeriesProps<D>["curve"];
53 /** Shared band presentation. Individual datum styling is not supported. */
54 readonly style?: SignalValue<SignalStyle<AreaSeriesStyle>>;
55 }
56
57 function accessor<D>(
58 input: LineAccessor<D>,
59 ): (datum: D, index: number, data: readonly D[]) => LineCoordinate {
60 return typeof input === "function"
61 ? input
62 : (datum) => datum[input] as LineCoordinate;
63 }
64
65 function fill(value: unknown): FillStyle {
66 if (
67 typeof value === "string" ||
68 value instanceof CanvasGradient ||
69 value instanceof CanvasPattern
70 )
71 return value;
72 try {
73 validateTexturePaint(value as never);
74 return value as FillStyle;
75 } catch {
76 throw new TypeError("IntervalBandSeries: invalid fill.");
77 }
78 }
79
80 /**
81 * Draws a filled band between matching lower and upper observations.
82 *
83 * Missing, non-finite, and excluded coordinates split runs. A lower value above its upper
84 * value rejects the datum; equal bounds produce zero-area geometry. The component owns one
85 * ordinary {@link Path} target, so event participation follows standard Path defaults.
86 *
87 * @param props - Data, coordinate accessors, continuous scales, and presentation.
88 * @returns A Pibbl path node containing all drawable interval-band runs.
89 * @throws When data, presentation, or bounds are invalid.
90 *
91 * @see {@link IntervalBandSeriesProps}
92 */
93 export function IntervalBandSeries<D>({
94 data: dataInput,
95 x: xInput,
96 lower: lowerInput,
97 upper: upperInput,
98 xScale: xId,
99 yScale: yId,
100 defined,
101 curve,
102 style: styleInput,
103 ...eventProps
104 }: IntervalBandSeriesProps<D>) {
105 const xScale = useContinuousScale(xId).get();
106 const yScale = useContinuousScale(yId).get();
107 const curveDefinition = resolveSeriesCurve(curve);
108 const x = useInternalValueMemoSlot(
109 "vizIntervalBandXAccessor",
110 () => accessor(xInput),
111 [xInput],
112 );
113 const lower = useInternalValueMemoSlot(
114 "vizIntervalBandLowerAccessor",
115 () => accessor(lowerInput),
116 [lowerInput],
117 );
118 const upper = useInternalValueMemoSlot(
119 "vizIntervalBandUpperAccessor",
120 () => accessor(upperInput),
121 [upperInput],
122 );
123 const data = resolveSignalValue(dataInput);
124 if (!Array.isArray(data))
125 throw new TypeError(
126 "IntervalBandSeries data must be an array or a signal containing an array.",
127 );
128 const style = resolveSignalValue(styleInput) ?? {};
129 if (style === null || typeof style !== "object" || Array.isArray(style))
130 throw new TypeError("IntervalBandSeries: style must be an object.");
131 const bandFill = fill(resolveSignalValue(style.fill) ?? "#2563eb");
132 const alpha = opacity(
133 resolveSignalValue(style.opacity) ?? 0.2,
134 "IntervalBandSeries",
135 );
136 const cursor = resolveSignalValue(style.cursor);
137 if (cursor !== undefined && typeof cursor !== "string")
138 throw new TypeError("IntervalBandSeries: cursor must be a string.");
139
140 const path = new Path2D();
141 let run: (readonly [number, number, number])[] = [];
142 const closeRun = () => {
143 if (run.length >= 2) {
144 const upper = curveGeometry(
145 curveDefinition,
146 run.map(([x, _lower, value]) => [x, value] as const),
147 );
148 const lower = curveGeometry(
149 curveDefinition,
150 run.map(([x, value]) => [x, value] as const),
151 );
152 validatePairedCurveGeometry(upper, lower);
153 path.moveTo(upper.start[0], upper.start[1]);
154 appendCurveForward(path, upper);
155 appendCurveReverse(path, lower);
156 path.closePath();
157 }
158 run = [];
159 };
160 for (let i = 0; i < data.length; i++) {
161 const datum = data[i];
162 if (defined && !defined(datum, i, data)) {
163 closeRun();
164 continue;
165 }
166 const xv = x(datum, i, data);
167 const lowerValue = lower(datum, i, data);
168 const upperValue = upper(datum, i, data);
169 if (
170 typeof xv !== "number" ||
171 typeof lowerValue !== "number" ||
172 typeof upperValue !== "number" ||
173 !Number.isFinite(xv) ||
174 !Number.isFinite(lowerValue) ||
175 !Number.isFinite(upperValue)
176 ) {
177 closeRun();
178 continue;
179 }
180 if (lowerValue > upperValue)
181 throw new RangeError(
182 `IntervalBandSeries datum at index ${i} has lower bound above upper bound.`,
183 );
184 const point = [
185 xScale.map(xv),
186 yScale.map(lowerValue),
187 yScale.map(upperValue),
188 ] as const;
189 if (!point.every(Number.isFinite)) {
190 closeRun();
191 continue;
192 }
193 run.push(point);
194 }
195 closeRun();
196 return (
197 <Path
198 {...eventProps}
199 style={{ d: path, fill: bandFill, opacity: alpha, cursor }}
200 />
201 );
202 }
203
Documentation version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.