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