packages/core/src/features/viz/lib/censored-point-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 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 version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision dd39ce2. ALPHA — NOT FOR PRODUCTION USE.