Skip to content

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