packages/core/src/features/viz/lib/weighted-point-series.tsx
This is the source snapshot used to build these API details. View this revision on GitHub.
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 version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision dd39ce2. ALPHA — NOT FOR PRODUCTION USE.