Skip to content

packages/core/src/features/viz/lib/pie-layout.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 {
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 built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.