Skip to content

packages/core/src/features/viz/lib/interval-band-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 } 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 built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.