Skip to content

packages/core/src/features/viz/lib/interval-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 { Circle, Group, Line, resolveSignalValue } from "@pibbl/core";
2 import type {
3   FillStyle,
4   PibblEventHandlers,
5   PibblEventParticipationOptions,
6   PibblNode,
7   SignalValue,
8   StrokeStyle,
9 } from "@pibbl/core";
10 import type { SignalStyle } from "@pibbl/core/internal";
11 import { withHooksForbidden } from "@pibbl/core/internal";
12 import type { BarCategoryAccessor } from "./bar-series.js";
13 import type { BandCategory, LineAccessor, ScaleId } from "./types.js";
14 import { lookupScale } from "./scale-context.js";
15 import { useContinuousScale } from "./use-continuous-scale.js";
16 import { finiteCoordinate, pointAccessor, pointData } from "./point-data.js";
17 import { opacity, width } from "./paint.js";
18 
19 /** Shared local-pixel geometry and paint for estimate-and-interval marks.
20  * @see {@link IntervalSeriesProps}
21  */
22 export interface IntervalSeriesStyle {
23   /** Estimate circle radius in local pixels; defaults to 4. */
24   readonly radius?: number;
25   /** Full perpendicular length of each endpoint cap; defaults to 8 pixels. */
26   readonly capSize?: number;
27   /** Estimate circle fill; defaults to #087f8c. */
28   readonly fill?: FillStyle;
29   /** Interval, cap, and circle outline paint; defaults to #087f8c. */
30   readonly stroke?: StrokeStyle;
31   /** Visible stroke width in local pixels; defaults to 1.5. */
32   readonly strokeWidth?: number;
33   /** Stroke targeting width, independent of visible width; defaults to strokeWidth. */
34   readonly lineInteractionWidth?: number;
35   /** Shared paint opacity from zero to one; defaults to 1. */
36   readonly opacity?: number;
37   /** Cursor for painted mark targets. */
38   readonly cursor?: string;
39 }
40 
41 /** Explicit supplied estimates and numeric bounds placed at categorical centers.
42  * @see {@link HorizontalIntervalSeries}
43  * @see {@link VerticalIntervalSeries}
44  */
45 export interface IntervalSeriesProps<D>
46   extends PibblEventHandlers, PibblEventParticipationOptions {
47   /** Original records, or a signal of records; no estimation is performed. */
48   readonly data: SignalValue<readonly D[]>;
49   /** Property or callback supplying a category; missing/unknown categories omit rows. */
50   readonly category: BarCategoryAccessor<D>;
51   /** Property or callback supplying the numeric point estimate. */
52   readonly estimate: LineAccessor<D>;
53   /** Property or callback supplying the numeric lower bound. */
54   readonly lower: LineAccessor<D>;
55   /** Property or callback supplying the numeric upper bound. */
56   readonly upper: LineAccessor<D>;
57   /** Ancestor PointScale or BarScale; band categories use their centers. */
58   readonly categoryScale: ScaleId;
59   /** Ancestor continuous numeric scale mapping estimate and both bounds independently. */
60   readonly valueScale: ScaleId;
61   /** Optional synchronous predicate; excluded rows contribute no marks.
62    * @param datum - Original record being tested.
63    * @param index - Source index of the record.
64    * @param data - Complete supplied data array.
65    * @returns Whether to include this record's estimate and interval.
66    */
67   readonly defined?: (datum: D, index: number, data: readonly D[]) => boolean;
68   /** Shared style; whole-object and shallow field signals are observed by the series. */
69   readonly style?: SignalValue<SignalStyle<IntervalSeriesStyle>>;
70 }
71 
72 /** Horizontal estimate-and-interval authoring inputs. See {@link HorizontalIntervalSeries}. */
73 export type HorizontalIntervalSeriesProps<D> = IntervalSeriesProps<D>;
74 /** Vertical estimate-and-interval authoring inputs. See {@link VerticalIntervalSeries}. */
75 export type VerticalIntervalSeriesProps<D> = IntervalSeriesProps<D>;
76 
77 function intervalSeries<D>(
78   props: IntervalSeriesProps<D>,
79   horizontal: boolean,
80 ): PibblNode {
81   const receiver = horizontal
82     ? "HorizontalIntervalSeries"
83     : "VerticalIntervalSeries";
84   const {
85     data: input,
86     category: categoryInput,
87     estimate: estimateInput,
88     lower: lowerInput,
89     upper: upperInput,
90     categoryScale,
91     valueScale,
92     defined,
93     style: styleInput,
94     pointerEvents,
95     ...events
96   } = props;
97   const categories = lookupScale(categoryScale, receiver)?.get();
98   const values = useContinuousScale(valueScale).get();
99   if (
100     !categories ||
101     (categories.type !== "point" && categories.type !== "band")
102   )
103     throw new TypeError(
104       `${receiver}: categoryScale requires PointScale or BarScale.`,
105     );
106   const data = pointData(resolveSignalValue(input), receiver);
107   const category =
108     typeof categoryInput === "function"
109       ? categoryInput
110       : (datum: D) => datum[categoryInput] as BandCategory | null | undefined;
111   const estimate = pointAccessor(estimateInput),
112     lower = pointAccessor(lowerInput),
113     upper = pointAccessor(upperInput);
114   const specified = resolveSignalValue(styleInput) ?? {};
115   if (typeof specified !== "object" || Array.isArray(specified))
116     throw new TypeError(`${receiver}: style must be an object.`);
117   const style: Record<string, unknown> = {};
118   for (const key of Object.keys(specified)) {
119     if (
120       ![
121         "radius",
122         "capSize",
123         "fill",
124         "stroke",
125         "strokeWidth",
126         "lineInteractionWidth",
127         "opacity",
128         "cursor",
129       ].includes(key)
130     )
131       throw new TypeError(`${receiver}: unsupported style field ${key}.`);
132     style[key] = resolveSignalValue(specified[key as keyof typeof specified]);
133   }
134   const resolved = style as IntervalSeriesStyle;
135   const radius = width(resolved.radius ?? 4, `${receiver} radius`);
136   const halfCap = width(resolved.capSize ?? 8, `${receiver} capSize`) / 2;
137   const strokeWidth = width(resolved.strokeWidth ?? 1.5, receiver);
138   const lineInteractionWidth = width(
139     resolved.lineInteractionWidth ?? strokeWidth,
140     receiver,
141   );
142   const alpha = opacity(resolved.opacity ?? 1, receiver);
143   if (resolved.cursor !== undefined && typeof resolved.cursor !== "string")
144     throw new TypeError(`${receiver}: cursor must be a string.`);
145   const stroke = resolved.stroke ?? "#087f8c";
146   const lineStyle = {
147     stroke,
148     strokeWidth,
149     lineInteractionWidth,
150     opacity: alpha,
151     cursor: resolved.cursor,
152   };
153   const nodes: PibblNode[] = [];
154   for (let i = 0; i < data.length; i++) {
155     const datum = data[i];
156     const coordinates = withHooksForbidden(
157       `${receiver}: accessors cannot call hooks.`,
158       () => {
159         if (defined && !defined(datum, i, data)) return undefined;
160         return {
161           category: category(datum, i, data),
162           estimate: estimate(datum, i, data),
163           lower: lower(datum, i, data),
164           upper: upper(datum, i, data),
165         };
166       },
167     );
168     if (
169       !coordinates ||
170       coordinates.category == null ||
171       !finiteCoordinate(coordinates.estimate) ||
172       !finiteCoordinate(coordinates.lower) ||
173       !finiteCoordinate(coordinates.upper)
174     )
175       continue;
176     const start = categories.map(coordinates.category);
177     if (start === undefined) continue;
178     if (coordinates.lower > coordinates.upper)
179       throw new RangeError(
180         `${receiver}: lower must not exceed upper (row ${i}).`,
181       );
182     const center =
183       start + (categories.type === "band" ? categories.bandwidth / 2 : 0);
184     const lo = values.map(coordinates.lower),
185       hi = values.map(coordinates.upper),
186       point = values.map(coordinates.estimate);
187     if (![center, lo, hi, point].every(Number.isFinite))
188       throw new RangeError(
189         `${receiver}: mapped coordinates must be finite (row ${i}).`,
190       );
191     const position = (value: number, offset = 0): [number, number] =>
192       horizontal ? [value, center + offset] : [center + offset, value];
193     nodes.push(
194       <Group key={i}>
195         <Line
196           pointerEvents={pointerEvents}
197           style={{ ...lineStyle, coords: [position(lo), position(hi)] }}
198         />
199         {halfCap > 0 && (
200           <>
201             <Line
202               pointerEvents={pointerEvents}
203               style={{
204                 ...lineStyle,
205                 coords: [position(lo, -halfCap), position(lo, halfCap)],
206               }}
207             />
208             <Line
209               pointerEvents={pointerEvents}
210               style={{
211                 ...lineStyle,
212                 coords: [position(hi, -halfCap), position(hi, halfCap)],
213               }}
214             />
215           </>
216         )}
217         {radius > 0 && (
218           <Circle
219             pointerEvents={pointerEvents}
220             style={{
221               ...lineStyle,
222               cx: horizontal ? point : center,
223               cy: horizontal ? center : point,
224               radius,
225               fill: resolved.fill ?? "#087f8c",
226             }}
227           />
228         )}
229       </Group>,
230     );
231   }
232   return <Group {...events}>{nodes}</Group>;
233 }
234 
235 /**
236  * Draw supplied numeric intervals horizontally with an estimate circle per category.
237  * Missing/non-finite coordinates omit the entire row; reversed bounds reject it.
238  * Mapping does not clamp or clip; compose a Clip when required. Marks use ordinary
239  * Line/Circle targets, with series handlers receiving their bubbled events.
240  *
241  * @param props - Supplied data, categorical/numeric scale IDs, and shared style.
242  * @returns Ordinary Pibbl content in source order, interval then circle for each row.
243  * @see {@link HorizontalIntervalSeriesProps}
244  */
245 export function HorizontalIntervalSeries<D>(
246   props: HorizontalIntervalSeriesProps<D>,
247 ): PibblNode {
248   return intervalSeries(props, true);
249 }
250 
251 /**
252  * Draw supplied numeric intervals vertically with an estimate circle per category.
253  * Shares the missing-data, bounds, paint, targeting, and clipping contract of
254  * HorizontalIntervalSeries; orientation is explicit in the operation name.
255  *
256  * @param props - Supplied data, categorical/numeric scale IDs, and shared style.
257  * @returns Ordinary Pibbl content in source order, interval then circle for each row.
258  * @see {@link VerticalIntervalSeriesProps}
259  */
260 export function VerticalIntervalSeries<D>(
261   props: VerticalIntervalSeriesProps<D>,
262 ): PibblNode {
263   return intervalSeries(props, false);
264 }
265 

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