packages/core/src/features/viz/lib/trajectory-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 PibblEventHandlers,
4 PibblEventParticipationOptions,
5 SignalValue,
6 StrokeStyle,
7 } from "@pibbl/core";
8 import { withHooksForbidden } from "@pibbl/core/internal";
9 import type { SignalStyle } from "@pibbl/core/internal";
10 import type { LineAccessor, ScaleId, SeriesAccessor } from "./types.js";
11 import { opacity, width } from "./paint.js";
12 import { pointAccessor, pointData } from "./point-data.js";
13 import {
14 mapSeriesPosition,
15 seriesAccessor,
16 useNumericPosition,
17 useSeriesPosition,
18 } from "./series-position.js";
19
20 /** Shared path appearance for {@link TrajectorySeries}. */
21 export interface TrajectorySeriesStyle {
22 /** Path paint. Defaults to `#2563eb`. */
23 readonly stroke?: StrokeStyle;
24 /** Visible path width in local units. Defaults to 2. */
25 readonly strokeWidth?: number;
26 /** Pointer targeting width. Defaults to `strokeWidth`. */
27 readonly lineInteractionWidth?: number;
28 /** Path opacity in [0, 1]. Defaults to 1. */
29 readonly opacity?: number;
30 /** Cursor while the batched path owns pointer presentation. */
31 readonly cursor?: string;
32 }
33
34 /**
35 * A participant or specimen identity property or callback.
36 *
37 * Nullish identities deliberately omit a row. Numeric identities must be finite.
38 * @param datum - Source observation.
39 * @param index - Zero-based source index.
40 * @param data - Complete source data array.
41 * @returns A finite numeric or string identity, or a nullish omitted identity.
42 * @see {@link TrajectorySeriesProps}
43 */
44 export type TrajectoryGroupAccessor<D> =
45 | {
46 [K in keyof D]-?: D[K] extends string | number | null | undefined
47 ? K
48 : never;
49 }[keyof D]
50 | ((
51 datum: D,
52 index: number,
53 data: readonly D[],
54 ) => string | number | null | undefined);
55
56 /**
57 * Authoring inputs for {@link TrajectorySeries}.
58 *
59 * Rows are connected in source encounter order within each group. The component
60 * neither sorts time values nor invents visits, summaries, or missing values.
61 */
62 export interface TrajectorySeriesProps<D>
63 extends PibblEventHandlers, PibblEventParticipationOptions {
64 /** Source observations or a signal containing them. */
65 readonly data: SignalValue<readonly D[]>;
66 /** Stable participant or specimen identity. Nullish identities omit that row. */
67 readonly group: TrajectoryGroupAccessor<D>;
68 /** Encounter-time or scheduled-visit coordinate. */
69 readonly x: SeriesAccessor<D>;
70 /** Numeric observed outcome coordinate. */
71 readonly y: LineAccessor<D>;
72 /** Scale mapping encounter-time or visit coordinates. */
73 readonly xScale: ScaleId;
74 /** Numeric scale mapping outcomes. */
75 readonly yScale: ScaleId;
76 /**
77 * Ends that group's current run without reading coordinate accessors.
78 * @param datum - Candidate source datum.
79 * @param index - Zero-based source index.
80 * @param data - Complete source data array.
81 * @returns Whether this datum contributes a coordinate to its group.
82 */
83 readonly defined?: (datum: D, index: number, data: readonly D[]) => boolean;
84 /** Shared path style, including whole-object and shallow field signals. */
85 readonly style?: SignalValue<SignalStyle<TrajectorySeriesStyle>>;
86 }
87
88 interface ResolvedTrajectoryStyle {
89 readonly stroke: StrokeStyle;
90 readonly strokeWidth: number;
91 readonly lineInteractionWidth: number;
92 readonly opacity: number;
93 readonly cursor?: string;
94 }
95
96 function style(input: unknown): ResolvedTrajectoryStyle {
97 if (input === null || typeof input !== "object" || Array.isArray(input))
98 throw new TypeError("TrajectorySeries: style must be an object.");
99 const value = input as Record<string, unknown>;
100 for (const field of Object.keys(value))
101 if (
102 ![
103 "stroke",
104 "strokeWidth",
105 "lineInteractionWidth",
106 "opacity",
107 "cursor",
108 ].includes(field)
109 )
110 throw new TypeError(
111 `TrajectorySeries: unsupported style field ${field}.`,
112 );
113 const stroke = resolveSignalValue(value.stroke) ?? "#2563eb";
114 const strokeWidth = width(
115 (resolveSignalValue(value.strokeWidth) ?? 2) as number,
116 "TrajectorySeries strokeWidth",
117 );
118 const lineInteractionWidth = width(
119 (resolveSignalValue(value.lineInteractionWidth) ?? strokeWidth) as number,
120 "TrajectorySeries lineInteractionWidth",
121 );
122 const cursor = resolveSignalValue(value.cursor);
123 if (cursor !== undefined && typeof cursor !== "string")
124 throw new TypeError("TrajectorySeries: cursor must be a string.");
125 return {
126 stroke: stroke as StrokeStyle,
127 strokeWidth,
128 lineInteractionWidth,
129 opacity: opacity(
130 (resolveSignalValue(value.opacity) ?? 1) as number,
131 "TrajectorySeries opacity",
132 ),
133 cursor,
134 };
135 }
136
137 /**
138 * Draws one shared-style path connecting each participant or specimen trajectory.
139 *
140 * A missing, excluded, or non-finite coordinate ends only that group's current
141 * run. Interleaved groups retain their source encounter order; trajectories are
142 * never sorted by either actual or scheduled time.
143 *
144 * @param props - Observations, identities, coordinates, scales, and shared path paint.
145 * @returns One batched ordinary Pibbl path containing source-ordered trajectory segments.
146 * @see {@link TrajectorySeriesProps}
147 */
148 export function TrajectorySeries<D>({
149 data: input,
150 group: groupInput,
151 x: xInput,
152 y: yInput,
153 xScale: xId,
154 yScale: yId,
155 defined,
156 style: styleInput,
157 ...events
158 }: TrajectorySeriesProps<D>) {
159 const xs = useSeriesPosition(xId);
160 const ys = useNumericPosition(yId);
161 const data = pointData(resolveSignalValue(input), "TrajectorySeries");
162 const x = seriesAccessor(xInput);
163 const y = pointAccessor(yInput);
164 const group =
165 typeof groupInput === "function"
166 ? groupInput
167 : (datum: D) => datum[groupInput] as string | number | null | undefined;
168 const resolved = style(resolveSignalValue(styleInput) ?? {});
169 const last = new Map<string | number, readonly [number, number]>();
170 const path = new Path2D();
171 for (let i = 0; i < data.length; i++) {
172 const datum = data[i];
173 const row = withHooksForbidden(
174 "TrajectorySeries: accessors cannot call hooks.",
175 () => {
176 const identity = group(datum, i, data);
177 if (defined && !defined(datum, i, data)) return { group: identity };
178 return {
179 group: identity,
180 x: x(datum, i, data),
181 y: y(datum, i, data),
182 };
183 },
184 );
185 if (!row || row.group == null) continue;
186 if (
187 (typeof row.group !== "string" && typeof row.group !== "number") ||
188 (typeof row.group === "number" && !Number.isFinite(row.group))
189 )
190 throw new TypeError(
191 "TrajectorySeries: group must be a string or number.",
192 );
193 if (!("x" in row) || !("y" in row)) {
194 last.delete(row.group);
195 continue;
196 }
197 const px = mapSeriesPosition(xs, row.x);
198 const py = mapSeriesPosition(ys, row.y);
199 if (px === undefined || py === undefined) {
200 last.delete(row.group);
201 continue;
202 }
203 const prior = last.get(row.group);
204 if (prior) {
205 path.moveTo(prior[0], prior[1]);
206 path.lineTo(px, py);
207 }
208 last.set(row.group, [px, py]);
209 }
210 return <Path {...events} style={{ d: path, ...resolved }} />;
211 }
212
Documentation version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision dd39ce2. ALPHA — NOT FOR PRODUCTION USE.