Skip to content

packages/core/src/features/viz/lib/censored-point-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   StrokeStyle,
8 } from "@pibbl/core";
9 import type { SignalStyle } from "@pibbl/core/internal";
10 import { validateTexturePaint, withHooksForbidden } from "@pibbl/core/internal";
11 import type { LineAccessor, ScaleId } from "./types.js";
12 import { finiteCoordinate, pointAccessor, pointData } from "./point-data.js";
13 import { opacity, width } from "./paint.js";
14 import { useContinuousScale } from "./use-continuous-scale.js";
15 
16 /** A supplied assay reporting status. `below` and `above` retain the supplied reporting limit.
17  * @see {@link CensoredPointSeriesProps}
18  */
19 export type CensoringStatus = "observed" | "below" | "above";
20 
21 /** Extracts an observation's supplied reporting status.
22  * @param datum - Original record being read.
23  * @param index - Zero-based source index.
24  * @param data - Complete supplied record array.
25  * @returns The supplied status, or a nullish sentinel that omits the record.
26  * @see {@link CensoringStatus}
27  */
28 export type CensoringAccessor<D> =
29   | {
30       [K in keyof D]-?: D[K] extends CensoringStatus | null | undefined
31         ? K
32         : never;
33     }[keyof D]
34   | ((
35       datum: D,
36       index: number,
37       data: readonly D[],
38     ) => CensoringStatus | null | undefined);
39 
40 /** Shared mark paint and local-pixel geometry for {@link CensoredPointSeries}. */
41 export interface CensoredPointSeriesStyle {
42   /** Circle and open censor marker radius in local pixels; defaults to 4. */
43   readonly radius?: number;
44   /** Fill for observed circles; defaults to #087f8c. Censor markers remain open. */
45   readonly fill?: FillStyle;
46   /** Outline for circles and open directional censor markers; defaults to #087f8c. */
47   readonly stroke?: StrokeStyle;
48   /** Outline width in local pixels; defaults to 1.5. */
49   readonly strokeWidth?: number;
50   /** Shared paint opacity from zero to one; defaults to 1. */
51   readonly opacity?: number;
52   /** Cursor for painted mark targets. */
53   readonly cursor?: string;
54 }
55 
56 /** Authored inputs for supplied observed and censored numeric observations.
57  * @see {@link CensoredPointSeries}
58  */
59 export interface CensoredPointSeriesProps<D>
60   extends PibblEventHandlers, PibblEventParticipationOptions {
61   /** Original records, or a signal of records. The series performs no imputation or inference. */
62   readonly data: SignalValue<readonly D[]>;
63   /** Property or callback supplying the numeric horizontal coordinate. */
64   readonly x: LineAccessor<D>;
65   /** Property or callback supplying the numeric vertical value or reporting limit. */
66   readonly y: LineAccessor<D>;
67   /** Property or callback yielding `observed`, `below`, or `above`. */
68   readonly censoring: CensoringAccessor<D>;
69   /** Ancestor continuous numeric scale mapping x. */
70   readonly xScale: ScaleId;
71   /** Ancestor continuous numeric scale mapping y and its reporting limit. */
72   readonly yScale: ScaleId;
73   /** Optional synchronous predicate; excluded rows contribute no mark.
74    * @param datum - Original record being tested.
75    * @param index - Zero-based source index.
76    * @param data - Complete supplied record array.
77    * @returns Whether this record contributes a mark.
78    */
79   readonly defined?: (datum: D, index: number, data: readonly D[]) => boolean;
80   /** Shared style; whole-object and shallow field signals are observed by the series. */
81   readonly style?: SignalValue<SignalStyle<CensoredPointSeriesStyle>>;
82 }
83 
84 const fields = ["radius", "fill", "stroke", "strokeWidth", "opacity", "cursor"];
85 
86 function paint(
87   value: unknown,
88   name: string,
89 ): FillStyle | StrokeStyle | undefined {
90   if (
91     value === undefined ||
92     typeof value === "string" ||
93     value instanceof CanvasGradient ||
94     value instanceof CanvasPattern
95   )
96     return value;
97   try {
98     validateTexturePaint(value as never);
99     return value as FillStyle | StrokeStyle;
100   } catch {
101     // Fall through to the local series error rather than leaking protocol detail.
102   }
103   throw new TypeError(`CensoredPointSeries: invalid ${name}.`);
104 }
105 
106 function triangle(
107   path: Path2D,
108   x: number,
109   y: number,
110   radius: number,
111   direction: number,
112 ) {
113   path.moveTo(x, y + direction * radius);
114   path.lineTo(x - radius * 0.78, y - direction * radius * 0.55);
115   path.lineTo(x + radius * 0.78, y - direction * radius * 0.55);
116   path.closePath();
117 }
118 
119 /**
120  * Draws supplied numeric observations as circles and below/above reporting limits as open arrows.
121  *
122  * `below` points toward decreasing y-domain values and `above` toward increasing y-domain values,
123  * including when the y scale uses a reversed domain or range. The supplied `y` value remains the
124  * reporting limit; this component never substitutes a measurement, imputes a value, or fits a model.
125  *
126  * @param props - Supplied observations, coordinates, reporting statuses, scales, and paint.
127  * See {@link CensoredPointSeriesProps}.
128  * @returns Two ordinary batched paths for observed and open censored marks.
129  * @see {@link CensoredPointSeriesProps}
130  */
131 export function CensoredPointSeries<D>({
132   data: input,
133   x: xInput,
134   y: yInput,
135   censoring: censoringInput,
136   xScale: xId,
137   yScale: yId,
138   defined,
139   style: styleInput,
140   pointerEvents,
141   ...events
142 }: CensoredPointSeriesProps<D>) {
143   const xScale = useContinuousScale(xId).get();
144   const yScale = useContinuousScale(yId).get();
145   const data = pointData(resolveSignalValue(input), "CensoredPointSeries");
146   const x = pointAccessor(xInput),
147     y = pointAccessor(yInput);
148   const censoring =
149     typeof censoringInput === "function"
150       ? censoringInput
151       : (datum: D) =>
152           datum[censoringInput] as CensoringStatus | null | undefined;
153   const specified = resolveSignalValue(styleInput) ?? {};
154   if (
155     specified === null ||
156     typeof specified !== "object" ||
157     Array.isArray(specified)
158   )
159     throw new TypeError("CensoredPointSeries: style must be an object.");
160   const style: Record<string, unknown> = {};
161   for (const key of Object.keys(specified)) {
162     if (!fields.includes(key))
163       throw new TypeError(
164         `CensoredPointSeries: unsupported style field ${key}.`,
165       );
166     style[key] = resolveSignalValue(
167       (specified as Record<string, unknown>)[key],
168     );
169   }
170   const resolved = style as CensoredPointSeriesStyle;
171   const radius = width(resolved.radius ?? 4, "CensoredPointSeries radius");
172   const strokeWidth = width(resolved.strokeWidth ?? 1.5, "CensoredPointSeries");
173   const alpha = opacity(resolved.opacity ?? 1, "CensoredPointSeries");
174   if (resolved.cursor !== undefined && typeof resolved.cursor !== "string")
175     throw new TypeError("CensoredPointSeries: cursor must be a string.");
176   const fill = paint(resolved.fill ?? "#087f8c", "fill");
177   const stroke = paint(resolved.stroke ?? "#087f8c", "stroke");
178   const observed = new Path2D();
179   const censored = new Path2D();
180   // The sign is a property of the domain-to-range mapping, rather than canvas orientation.
181   const increasingY = Math.sign(
182     (yScale.domain[1] - yScale.domain[0]) * (yScale.range[1] - yScale.range[0]),
183   );
184   for (let i = 0; i < data.length; i++) {
185     const datum = data[i];
186     const coordinates = withHooksForbidden(
187       "CensoredPointSeries: accessors cannot call hooks.",
188       () => {
189         if (defined && !defined(datum, i, data)) return undefined;
190         return {
191           x: x(datum, i, data),
192           y: y(datum, i, data),
193           censoring: censoring(datum, i, data),
194         };
195       },
196     );
197     if (
198       !coordinates ||
199       !finiteCoordinate(coordinates.x) ||
200       !finiteCoordinate(coordinates.y) ||
201       coordinates.censoring == null
202     )
203       continue;
204     if (
205       !(["observed", "below", "above"] as const).includes(coordinates.censoring)
206     )
207       throw new TypeError(
208         `CensoredPointSeries: unknown censoring status ${String(coordinates.censoring)} (row ${i}).`,
209       );
210     const px = xScale.map(coordinates.x),
211       py = yScale.map(coordinates.y);
212     if (!Number.isFinite(px) || !Number.isFinite(py))
213       throw new RangeError(
214         `CensoredPointSeries: mapped coordinates must be finite (row ${i}).`,
215       );
216     if (radius === 0) continue;
217     if (coordinates.censoring === "observed") {
218       observed.moveTo(px + radius, py);
219       observed.arc(px, py, radius, 0, Math.PI * 2);
220       observed.closePath();
221     } else {
222       const direction =
223         coordinates.censoring === "above" ? increasingY : -increasingY;
224       triangle(censored, px, py, radius, direction || 1);
225     }
226   }
227   const common = {
228     stroke,
229     strokeWidth,
230     opacity: alpha,
231     cursor: resolved.cursor,
232   };
233   return (
234     <>
235       <Path
236         {...events}
237         pointerEvents={pointerEvents}
238         style={{ d: observed, fill, ...common }}
239       />
240       <Path
241         {...events}
242         pointerEvents={pointerEvents}
243         style={{ d: censored, fill: "transparent", ...common }}
244       />
245     </>
246   );
247 }
248 

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