packages/core/src/features/viz/lib/pie-layout.tsx
This is the source snapshot used to build these API details. View this revision on GitHub.
1 import {
2 Group,
3 Path,
4 Wedge,
5 definePrimitive,
6 Line,
7 Rectangle,
8 resolveSignalValue,
9 useComputed,
10 useCanvasContext,
11 useLayoutBox,
12 } from "@pibbl/core";
13 import { resolveTexturePaint, withHooksForbidden } from "@pibbl/core/internal";
14 import type {
15 PieLayoutProps,
16 PieLayoutSlice,
17 PieSliceProps,
18 PieLabelProps,
19 PieLabelLayout,
20 } from "./pie-types.js";
21 import { pieSlices } from "./pie-slices.js";
22 import { layoutPie } from "./layout-pie.js";
23 function finite(value: number, name: string, nonnegative = false) {
24 if (!Number.isFinite(value) || (nonnegative && value < 0))
25 throw new RangeError(
26 `PieLayout: ${name} must be finite${nonnegative ? " and nonnegative" : ""}.`,
27 );
28 return value;
29 }
30 /**
31 * Computes pie geometry and label placement, then renders children for each resolved slice.
32 *
33 * @param props - Data, pie geometry, label policy, and child renderer. See {@link PieLayoutProps}.
34 * @returns Pibbl content arranged as pie slices.
35 *
36 * @see {@link PieLayoutProps}
37 */
38 export function PieLayout<D>(props: PieLayoutProps<D>) {
39 const allocation = useLayoutBox();
40 const context = useCanvasContext();
41 const style = resolveSignalValue(props.style) ?? {};
42 const bounds = resolveSignalValue(props.bounds) ?? {
43 x: 0,
44 y: 0,
45 width: allocation.width,
46 height: allocation.height,
47 };
48 const bx = finite(bounds.x, "bounds.x"),
49 by = finite(bounds.y, "bounds.y");
50 const bw = finite(bounds.width, "bounds.width", true),
51 bh = finite(bounds.height, "bounds.height", true);
52 finite(bx + bw, "bounds right");
53 finite(by + bh, "bounds bottom");
54 const cx = finite(resolveSignalValue(style.cx) ?? bx + bw / 2, "cx");
55 const cy = finite(resolveSignalValue(style.cy) ?? by + bh / 2, "cy");
56 const radius = finite(
57 resolveSignalValue(style.radius) ?? Math.min(bw * 0.28, bh * 0.4),
58 "radius",
59 true,
60 );
61 const innerRadius = finite(
62 resolveSignalValue(style.innerRadius) ?? 0,
63 "innerRadius",
64 true,
65 );
66 if (radius === 0 ? innerRadius !== 0 : innerRadius >= radius)
67 throw new RangeError(
68 "PieLayout: innerRadius must be zero when radius is zero, or less than radius.",
69 );
70 const gap = finite(resolveSignalValue(style.labelGap) ?? 8, "labelGap", true);
71 finite(cx + radius + gap, "right label edge");
72 finite(cx - radius - gap, "left label edge");
73 finite(cy + radius, "bottom");
74 finite(cy - radius, "top");
75 const font = resolveSignalValue(style.labelFont) ?? "14px sans-serif";
76 if (typeof font !== "string" || !font.trim())
77 throw new TypeError("PieLayout: labelFont must be a Canvas font string.");
78 const placement = resolveSignalValue(props.placement) ?? "outside";
79 if (!["inside", "outside", "auto"].includes(placement))
80 throw new TypeError(
81 "PieLayout: placement must be inside, outside, or auto.",
82 );
83 if (typeof props.children !== "function")
84 throw new TypeError(
85 "PieLayout: children must be a function returning Pibbl elements.",
86 );
87 const revision =
88 props.revision === undefined ? undefined : resolveSignalValue(props.revision);
89 if (
90 revision !== undefined &&
91 (!Number.isSafeInteger(revision) || revision < 0)
92 )
93 throw new RangeError(
94 "PieLayout: revision must be a finite nonnegative safe integer.",
95 );
96 // An omitted revision deliberately replaces this token on every evaluation.
97 // Stable callback identities alone are not evidence of stable results.
98 const cacheKey = revision === undefined ? {} : revision;
99 const slices = useComputed(
100 () =>
101 pieSlices(resolveSignalValue(props.data), {
102 value: props.value,
103 keyBy: props.keyBy,
104 }),
105 [cacheKey, props.data, props.value, props.keyBy],
106 );
107 const layout = useComputed(() => {
108 context.save();
109 try {
110 // Canvas silently ignores malformed fonts. Two distinct sentinels detect
111 // that without depending on the caller's inherited font or CSS spelling.
112 context.font = "10px serif";
113 context.font = font;
114 const normalized = context.font;
115 context.font = "11px monospace";
116 context.font = font;
117 if (context.font !== normalized)
118 throw new TypeError("PieLayout: labelFont must be a valid Canvas font.");
119 context.textAlign = "left";
120 context.textBaseline = "alphabetic";
121 return layoutPie(
122 slices.get(),
123 cx,
124 cy,
125 radius,
126 innerRadius,
127 { x: bx, y: by, width: bw, height: bh },
128 context.font,
129 gap,
130 placement,
131 props.label,
132 (text) => {
133 const m = context.measureText(text);
134 const width = Math.max(
135 m.width,
136 m.actualBoundingBoxLeft + m.actualBoundingBoxRight,
137 );
138 const ascent = Math.max(0, m.actualBoundingBoxAscent),
139 descent = Math.max(0, m.actualBoundingBoxDescent);
140 return {
141 width: finite(width, "label width", true),
142 height: finite(Math.max(1, ascent + descent), "label height", true),
143 left: Math.max(0, m.actualBoundingBoxLeft),
144 ascent,
145 };
146 },
147 );
148 } finally {
149 context.restore();
150 }
151 }, [
152 slices,
153 cx,
154 cy,
155 radius,
156 innerRadius,
157 bx,
158 by,
159 bw,
160 bh,
161 font,
162 gap,
163 placement,
164 props.label,
165 context,
166 ]);
167 return layout
168 .get()
169 .map((slice) => (
170 <Group key={slice.key}>
171 {withHooksForbidden(
172 "PieLayout children cannot call hooks; return a component instead.",
173 () => props.children(slice),
174 )}
175 </Group>
176 ));
177 }
178 /**
179 * Draws a resolved pie slice using wedge geometry and the supplied paint style.
180 *
181 * @param props - Slice geometry, appearance, handlers, and children. See {@link PieSliceProps}.
182 * @returns Pibbl nodes drawing one pie slice and its children.
183 *
184 * @see {@link PieSliceProps}
185 */
186 export function PieSlice<D>({
187 slice,
188 style: input,
189 children,
190 ...events
191 }: PieSliceProps<D>) {
192 const style = resolveSignalValue(input) ?? {};
193 if (slice.innerRadius === 0) {
194 return (
195 <Group>
196 <Wedge
197 {...events}
198 style={{
199 ...style,
200 cx: slice.cx,
201 cy: slice.cy,
202 radius: slice.radius,
203 startAngle: slice.startAngle,
204 endAngle: slice.endAngle,
205 }}
206 />
207 {children}
208 </Group>
209 );
210 }
211 const sweep = resolveClockwiseSweep(slice.startAngle, slice.endAngle);
212 const path = new Path2D();
213 path.arc(
214 slice.cx,
215 slice.cy,
216 slice.radius,
217 sweep.startAngle,
218 sweep.endAngle,
219 );
220 if (slice.innerRadius > 0)
221 path.arc(
222 slice.cx,
223 slice.cy,
224 slice.innerRadius,
225 sweep.endAngle,
226 sweep.startAngle,
227 true,
228 );
229 else path.lineTo(slice.cx, slice.cy);
230 path.closePath();
231 return (
232 <Group>
233 <Path
234 {...events}
235 keyboardNavigationBounds={
236 events.keyboardNavigationBounds ??
237 annularSliceBounds(slice, sweep)
238 }
239 style={{
240 ...style,
241 d: path,
242 }}
243 />
244 {children}
245 </Group>
246 );
247 }
248
249 /** The outer and inner arcs both contribute to an annular path's focus box. */
250 function annularSliceBounds(
251 slice: PieLayoutSlice<unknown>,
252 sweep: Readonly<ClockwiseSweep>,
253 ) {
254 if (sweep.sweep === 0) return undefined;
255 if (sweep.fullCircle)
256 return {
257 x: slice.cx - slice.radius,
258 y: slice.cy - slice.radius,
259 width: slice.radius * 2,
260 height: slice.radius * 2,
261 };
262 const angles = [sweep.startAngle, sweep.endAngle];
263 for (const cardinal of [0, Math.PI / 2, Math.PI, Math.PI * 1.5]) {
264 const angle = cardinal < sweep.startAngle ? cardinal + fullTurn : cardinal;
265 if (angle <= sweep.endAngle) angles.push(angle);
266 }
267 const xs: number[] = [];
268 const ys: number[] = [];
269 for (const angle of angles) {
270 const cosine = Math.cos(angle);
271 const sine = Math.sin(angle);
272 xs.push(
273 slice.cx + slice.radius * cosine,
274 slice.cx + slice.innerRadius * cosine,
275 );
276 ys.push(
277 slice.cy + slice.radius * sine,
278 slice.cy + slice.innerRadius * sine,
279 );
280 }
281 const left = Math.min(...xs);
282 const right = Math.max(...xs);
283 const top = Math.min(...ys);
284 const bottom = Math.max(...ys);
285 return { x: left, y: top, width: right - left, height: bottom - top };
286 }
287
288 const fullTurn = Math.PI * 2;
289
290 interface ClockwiseSweep {
291 readonly startAngle: number;
292 readonly endAngle: number;
293 readonly sweep: number;
294 readonly fullCircle: boolean;
295 }
296
297 /** Matches Canvas's bounded clockwise arc rule without retaining unbounded turns. */
298 function resolveClockwiseSweep(
299 startAngle: number,
300 endAngle: number,
301 ): ClockwiseSweep {
302 if (!Number.isFinite(startAngle) || !Number.isFinite(endAngle))
303 throw new RangeError("PieSlice: slice angles must be finite.");
304 const start = positiveModulo(startAngle, fullTurn);
305 if (endAngle - startAngle >= fullTurn)
306 return {
307 startAngle: start,
308 endAngle: start + fullTurn,
309 sweep: fullTurn,
310 fullCircle: true,
311 };
312 const sweep = positiveModulo(
313 positiveModulo(endAngle, fullTurn) - start,
314 fullTurn,
315 );
316 return {
317 startAngle: start,
318 endAngle: start + sweep,
319 sweep,
320 fullCircle: false,
321 };
322 }
323
324 function positiveModulo(value: number, divisor: number) {
325 const result = value % divisor;
326 if (result === 0) return 0;
327 return result < 0 ? result + divisor : result;
328 }
329
330 const LabelText = definePrimitive<
331 { label: Extract<PieLabelLayout, { status: "placed" }> },
332 { readonly custom?: never; fill: import("@pibbl/core").FillStyle }
333 >(({ label }, style, context) => {
334 context.font = label.font;
335 context.textAlign = "left";
336 context.textBaseline = "alphabetic";
337 context.fillStyle = resolveTexturePaint(style.fill, context, { x: label.x, y: label.y, width: label.width, height: label.height });
338 context.fillText(label.text, label.textX, label.textY);
339 });
340
341 /**
342 * Draws a resolved slice label and optional leader line; decorative labels ignore pointer
343 * targeting
344 * by default.
345 *
346 * @param props - Slice geometry, label appearance, and interaction options. See
347 * {@link PieLabelProps} .
348 * @returns Pibbl content drawing the slice label, or null when the label was not placed.
349 *
350 * @see {@link PieLabelProps}
351 */
352 export function PieLabel<D>({
353 slice,
354 style: input,
355 pointerEvents = "none",
356 ...events
357 }: PieLabelProps<D>) {
358 const style = resolveSignalValue(input) ?? {};
359 const label = slice.label;
360 if (!label || label.status !== "placed") return null;
361 return (
362 <Group {...events}>
363 {label.leader && (
364 <Line
365 pointerEvents="none"
366 style={{
367 coords: label.leader.map((p) => [p[0], p[1]]),
368 stroke: resolveSignalValue(style.leaderStroke) ?? "#94a3b8",
369 strokeWidth: resolveSignalValue(style.leaderWidth) ?? 1,
370 }}
371 />
372 )}
373 <Rectangle
374 pointerEvents={pointerEvents}
375 style={{
376 left: label.x,
377 top: label.y,
378 width: label.width,
379 height: label.height,
380 fill: "transparent",
381 }}
382 />
383 <LabelText
384 label={label}
385 style={{ fill: resolveSignalValue(style.fill) ?? "#182c42" }}
386 />
387 </Group>
388 );
389 }
390
Documentation version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.