Skip to content

packages/core/src/features/viz/lib/weighted-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 { resolveTexturePaint } from "@pibbl/core/internal";
2 import { Path, resolveSignalValue, useCanvasContext } from "@pibbl/core";
3 import type {
4   FillStyle,
5   PibblEventHandlers,
6   PibblEventParticipationOptions,
7   SignalValue,
8   StrokeStyle,
9 } from "@pibbl/core";
10 import type { SignalStyle } from "@pibbl/core/internal";
11 import { validateTexturePaint, withHooksForbidden } from "@pibbl/core/internal";
12 import type { LineAccessor, ScaleId, SeriesAccessor } from "./types.js";
13 import { pointAccessor, pointData } from "./point-data.js";
14 import { appendPointTarget, pointPath } from "./point-geometry.js";
15 import { opacity, width } from "./paint.js";
16 import {
17   mapSeriesPosition,
18   seriesAccessor,
19   useSeriesPosition,
20 } from "./series-position.js";
21 
22 const fields = ["fill", "stroke", "strokeWidth", "opacity", "cursor"];
23 
24 /**
25  * A data property or callback extracting a nonnegative research weight.
26  *
27  * @see {@link WeightedPointSeriesProps}
28  */
29 export type WeightAccessor<D> = LineAccessor<D>;
30 
31 /** Shared presentation for {@link WeightedPointSeries}. */
32 export interface WeightedPointSeriesStyle {
33   /** Paint used for point interiors. Defaults to `#087f8c`. */
34   readonly fill?: FillStyle;
35   /** Paint used for point outlines. Defaults to no outline. */
36   readonly stroke?: StrokeStyle;
37   /** Outline width in local coordinate units. Defaults to 1. */
38   readonly strokeWidth?: number;
39   /** Opacity in the inclusive range [0, 1]. Defaults to 1. */
40   readonly opacity?: number;
41   /** Pointer cursor while the series owns a target. */
42   readonly cursor?: string;
43 }
44 
45 /**
46  * Authored inputs for {@link WeightedPointSeries}.
47  *
48  * Weights are supplied data, not calculated precision, sample size, or evidence.
49  * The zero baseline and squared-radius interpolation make displayed area linear in
50  * the supplied weight.
51  */
52 export interface WeightedPointSeriesProps<D>
53   extends PibblEventHandlers, PibblEventParticipationOptions {
54   /** Readonly data or a signal containing it. */
55   readonly data: SignalValue<readonly D[]>;
56   /** Horizontal coordinate property or synchronous accessor. */
57   readonly x: SeriesAccessor<D>;
58   /** Vertical coordinate property or synchronous accessor. */
59   readonly y: SeriesAccessor<D>;
60   /** Nonnegative supplied weight property or synchronous accessor. */
61   readonly weight: WeightAccessor<D>;
62   /** Scale used to map x coordinates. */
63   readonly xScale: ScaleId;
64   /** Scale used to map y coordinates. */
65   readonly yScale: ScaleId;
66   /** Supplied inclusive weight extent. The lower bound must be zero. */
67   readonly weightDomain: readonly [0, number];
68   /** Radius extent `[0, maximumRadius]`; defaults to `[0, 6]`. */
69   readonly radiusRange?: readonly [0, number];
70   /**
71    * Excludes a datum before coordinate and weight accessors run.
72    * @param datum - Candidate source datum.
73    * @param index - Zero-based source index.
74    * @param data - Complete source data array.
75    * @returns Whether this datum contributes a mark.
76    */
77   readonly defined?: (datum: D, index: number, data: readonly D[]) => boolean;
78   /** Shared presentation. Whole-object and shallow field signals are supported. */
79   readonly style?: SignalValue<SignalStyle<WeightedPointSeriesStyle>>;
80 }
81 
82 function paint(
83   value: unknown,
84   name: string,
85 ): FillStyle | StrokeStyle | undefined {
86   if (
87     value === undefined ||
88     typeof value === "string" ||
89     value instanceof CanvasGradient ||
90     value instanceof CanvasPattern
91   )
92     return value;
93   try {
94     validateTexturePaint(value as never);
95     return value as FillStyle | StrokeStyle;
96   } catch {
97     // Normalize all unsupported public paint values to the series error.
98   }
99   throw new TypeError(`WeightedPointSeries: invalid ${name}.`);
100 }
101 
102 function styleObject(input: unknown): WeightedPointSeriesStyle {
103   if (input === null || typeof input !== "object" || Array.isArray(input))
104     throw new TypeError("WeightedPointSeries: style must be an object.");
105   const result: Record<string, unknown> = {};
106   for (const key of Object.keys(input)) {
107     if (!fields.includes(key))
108       throw new TypeError(
109         `WeightedPointSeries: unsupported style field ${key}.`,
110       );
111     result[key] = resolveSignalValue((input as Record<string, unknown>)[key]);
112   }
113   return result as WeightedPointSeriesStyle;
114 }
115 
116 function normalized(style: WeightedPointSeriesStyle) {
117   if (style.cursor !== undefined && typeof style.cursor !== "string")
118     throw new TypeError("WeightedPointSeries: cursor must be a string.");
119   return {
120     fill: paint(style.fill ?? "#087f8c", "fill"),
121     stroke: paint(style.stroke, "stroke"),
122     strokeWidth: width(
123       style.strokeWidth ?? 1,
124       "WeightedPointSeries strokeWidth",
125     ),
126     opacity: opacity(style.opacity ?? 1, "WeightedPointSeries opacity"),
127     cursor: style.cursor,
128   };
129 }
130 
131 function range(
132   value: readonly [number, number] | undefined,
133 ): readonly [number, number] {
134   const actual: readonly [number, number] = value ?? [0, 6];
135   if (
136     !Array.isArray(actual) ||
137     actual.length !== 2 ||
138     actual[0] !== 0 ||
139     !Number.isFinite(actual[1]) ||
140     actual[1] < 0
141   )
142     throw new RangeError(
143       "WeightedPointSeries: radiusRange must be [0, a finite nonnegative maximum].",
144     );
145   return actual;
146 }
147 
148 function domain(value: readonly [0, number]): readonly [number, number] {
149   if (
150     !Array.isArray(value) ||
151     value.length !== 2 ||
152     value[0] !== 0 ||
153     !Number.isFinite(value[1]) ||
154     value[1] <= 0
155   )
156     throw new RangeError(
157       "WeightedPointSeries: weightDomain must be [0, a finite positive maximum].",
158     );
159   return value;
160 }
161 
162 /**
163  * Draws batched circles whose visible area is proportional to supplied weights.
164  *
165  * Nullish weights and zero weights omit their marks. Finite positive weights must
166  * fall inside `weightDomain`; the component never clamps or infers weights.
167  * `data` and shared style signals schedule ordinary Pibbl updates. The component
168  * owns no retained resources and returns one ordinary Pibbl pointer target.
169  *
170  * @param props - Supplied observations, coordinate and weight accessors, scales, and paint.
171  * See {@link WeightedPointSeriesProps}.
172  * @returns One batched Pibbl Path whose mark area is proportional to each supplied weight.
173  * @see {@link WeightedPointSeriesProps}
174  */
175 export function WeightedPointSeries<D>({
176   data: input,
177   x: xInput,
178   y: yInput,
179   weight: weightInput,
180   xScale: xId,
181   yScale: yId,
182   weightDomain,
183   radiusRange,
184   defined,
185   style: styleInput,
186   ...events
187 }: WeightedPointSeriesProps<D>) {
188   const xs = useSeriesPosition(xId);
189   const ys = useSeriesPosition(yId);
190   const data = pointData(resolveSignalValue(input), "WeightedPointSeries");
191   const x = seriesAccessor(xInput);
192   const y = seriesAccessor(yInput);
193   const weight = pointAccessor(weightInput);
194   const [, maximumWeight] = domain(weightDomain);
195   const [, maximumRadius] = range(radiusRange);
196   const style = normalized(styleObject(resolveSignalValue(styleInput) ?? {}));
197   const ctx = useCanvasContext();
198   const target = new Path2D();
199   const marks: {
200     readonly x: number;
201     readonly y: number;
202     readonly radius: number;
203   }[] = [];
204   for (let i = 0; i < data.length; i++) {
205     const datum = data[i];
206     const values = withHooksForbidden(
207       "WeightedPointSeries: accessors cannot call hooks.",
208       () => {
209         if (defined && !defined(datum, i, data)) return undefined;
210         return {
211           x: x(datum, i, data),
212           y: y(datum, i, data),
213           weight: weight(datum, i, data),
214         };
215       },
216     );
217     if (!values) continue;
218     const mappedX = mapSeriesPosition(xs, values.x);
219     const mappedY = mapSeriesPosition(ys, values.y);
220     if (mappedX === undefined || mappedY === undefined) continue;
221     const supplied = values.weight;
222     if (supplied == null) continue;
223     if (typeof supplied !== "number" || !Number.isFinite(supplied))
224       throw new TypeError(
225         "WeightedPointSeries: weight must be a finite number.",
226       );
227     if (supplied < 0)
228       throw new RangeError("WeightedPointSeries: weight must be nonnegative.");
229     if (supplied > maximumWeight)
230       throw new RangeError("WeightedPointSeries: weight exceeds weightDomain.");
231     if (supplied === 0 || maximumRadius === 0) continue;
232     const radius = maximumRadius * Math.sqrt(supplied / maximumWeight);
233     marks.push({ x: mappedX, y: mappedY, radius });
234   }
235   for (const { x, y, radius } of marks) {
236     appendPointTarget(
237       target,
238       x,
239       y,
240       radius,
241       !!style.fill,
242       style.stroke ? style.strokeWidth : 0,
243     );
244     const path = pointPath(x, y, radius);
245     ctx.save();
246     try {
247       ctx.globalAlpha *= style.opacity;
248       if (style.fill) {
249         ctx.fillStyle = resolveTexturePaint(style.fill, ctx, {
250           x: x - radius,
251           y: y - radius,
252           width: radius * 2,
253           height: radius * 2,
254         });
255         ctx.fill(path);
256       }
257       if (style.stroke && style.strokeWidth > 0) {
258         ctx.strokeStyle = resolveTexturePaint(style.stroke, ctx, {
259           x: x - radius,
260           y: y - radius,
261           width: radius * 2,
262           height: radius * 2,
263         });
264         ctx.lineWidth = style.strokeWidth;
265         ctx.stroke(path);
266       }
267     } finally {
268       ctx.restore();
269     }
270   }
271   return (
272     <Path
273       {...events}
274       style={{ d: target, fill: "transparent", cursor: style.cursor }}
275     />
276   );
277 }
278 

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