Skip to content

packages/core/src/features/viz/lib/anchored-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   resolveSignalValue,
4   useLayoutBox,
5   type PibblNode,
6   type SignalValue,
7 } from "@pibbl/core";
8 import {
9   pushLayoutBox,
10   setCurrentComponentAfterRender,
11   withHooksForbidden,
12 } from "@pibbl/core/internal";
13 
14 /**
15  * Declared outer box and protected anchor, all in the parent's local coordinates.
16  *
17  * @see {@link AnchoredLayout}
18  * @see {@link AnchoredLayoutProps}
19  */
20 export interface AnchoredItem {
21   /** Stable identity used by the containing protocol. See {@link AnchoredItem}. */
22   readonly key: string | number;
23   /** Logical point to which the content or result is anchored. See {@link AnchoredItem}. */
24   readonly anchor: Readonly<{
25     /**
26      * Horizontal coordinate or displacement in the containing coordinate system. See
27      * {@link AnchoredItem}.
28      */
29     x: number;
30     /**
31      * Vertical coordinate or displacement in the containing coordinate system. See
32      * {@link AnchoredItem}.
33      */
34     y: number;
35   }>;
36   /** Clearance around the anchor before placing the item. See {@link AnchoredItem}. */
37   readonly anchorRadius?: number;
38   /**
39    * Horizontal extent in the units of the containing geometry or surface. See {@link AnchoredItem}
40    * .
41    */
42   readonly width: number;
43   /**
44    * Vertical extent in the units of the containing geometry or surface. See {@link AnchoredItem}.
45    */
46   readonly height: number;
47 }
48 /**
49  * The resolved logical rectangle assigned to an anchored item.
50  *
51  * @see {@link AnchoredLayoutProps}
52  */
53 export interface AnchoredPlacement {
54   /**
55    * Horizontal coordinate or displacement in the containing coordinate system. See
56    * {@link AnchoredPlacement}.
57    */
58   readonly x: number;
59   /**
60    * Vertical coordinate or displacement in the containing coordinate system. See
61    * {@link AnchoredPlacement}.
62    */
63   readonly y: number;
64   /**
65    * Horizontal extent in the units of the containing geometry or surface. See
66    * {@link AnchoredPlacement}.
67    */
68   readonly width: number;
69   /**
70    * Vertical extent in the units of the containing geometry or surface. See
71    * {@link AnchoredPlacement}.
72    */
73   readonly height: number;
74 }
75 /**
76  * Authored inputs for AnchoredLayout, including the declared data and presentation options.
77  *
78  * @see {@link AnchoredItem}
79  * @see {@link SignalValue}
80  * @see {@link AnchoredPlacement}
81  * @see {@link PibblNode}
82  * @see {@link AnchoredLayout}
83  */
84 export interface AnchoredLayoutProps<T extends AnchoredItem> {
85   /** Items to place around their declared anchors. See {@link SignalValue}. */
86   readonly items: SignalValue<readonly T[]>;
87   /** Direction in which this operation proceeds. See {@link AnchoredLayoutProps}. */
88   readonly direction?: "vertical" | "horizontal";
89   /** Spacing between adjacent items. See {@link SignalValue}. */
90   readonly gap?: SignalValue<number>;
91   /**
92    * Logical region constraining the content or query. See {@link SignalValue},
93    * {@link AnchoredPlacement}.
94    */
95   readonly bounds?: SignalValue<AnchoredPlacement>;
96   /**
97    * Descendant content or the callback that supplies it. See {@link AnchoredPlacement},
98    * {@link PibblNode}.
99    * @param item - Item being placed.
100    * @param placement - Computed anchor and placement geometry. See {@link AnchoredPlacement}.
101    * @returns Pibbl content for that item. See {@link PibblNode}.
102    */
103   readonly children: (item: T, placement: AnchoredPlacement) => PibblNode;
104 }
105 function finite(value: number, name: string, nonnegative = false) {
106   if (!Number.isFinite(value) || (nonnegative && value < 0))
107     throw new RangeError(
108       `AnchoredLayout ${name} must be finite${nonnegative ? " and nonnegative" : ""}.`,
109     );
110   return value;
111 }
112 function arrange(
113   items: readonly AnchoredItem[],
114   bounds: AnchoredPlacement,
115   gap: number,
116   vertical: boolean,
117 ): AnchoredPlacement[] {
118   finite(gap, "gap", true);
119   finite(bounds.x, "bounds.x");
120   finite(bounds.y, "bounds.y");
121   finite(bounds.width, "bounds.width", true);
122   finite(bounds.height, "bounds.height", true);
123   finite(bounds.x + bounds.width, "bounds right");
124   finite(bounds.y + bounds.height, "bounds bottom");
125   const keys = new Set<string>();
126   const rows = items.map((item, index) => {
127     if (typeof item.key !== "string" && typeof item.key !== "number")
128       throw new TypeError("AnchoredLayout keys must be strings or numbers.");
129     const key = String(item.key);
130     if (keys.has(key))
131       throw new Error(`AnchoredLayout duplicate key "${key}".`);
132     keys.add(key);
133     finite(item.anchor.x, "anchor.x");
134     finite(item.anchor.y, "anchor.y");
135     finite(item.width, "width", true);
136     finite(item.height, "height", true);
137     const radius = finite(item.anchorRadius ?? 0, "anchorRadius", true);
138     return {
139       item,
140       index,
141       key,
142       radius,
143       along: vertical ? item.anchor.y : item.anchor.x,
144       across: vertical ? item.anchor.x : item.anchor.y,
145       size: vertical ? item.height : item.width,
146       breadth: vertical ? item.width : item.height,
147       position: 0,
148     };
149   });
150   if (!rows.length) return [];
151   // Sort a private working list; preserve source paint order when rendering.
152   rows.sort(
153     (a, b) => a.along - b.along || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0),
154   );
155   const start = vertical ? bounds.y : bounds.x;
156   const end = finite(
157     start + (vertical ? bounds.height : bounds.width),
158     "packing end",
159   );
160   const acrossStart = vertical ? bounds.x : bounds.y;
161   const acrossEnd = finite(
162     acrossStart + (vertical ? bounds.width : bounds.height),
163     "cross-axis end",
164   );
165   let positive = -Infinity,
166     negative = Infinity,
167     breadth = 0,
168     cursor = start;
169   for (const row of rows) {
170     positive = Math.max(
171       positive,
172       finite(row.across + row.radius + gap, "anchor clearance"),
173     );
174     negative = Math.min(
175       negative,
176       finite(row.across - row.radius - gap, "anchor clearance"),
177     );
178     breadth = Math.max(breadth, row.breadth);
179     row.position = Math.max(start, row.along - row.size / 2, cursor);
180     cursor = finite(row.position + row.size + gap, "packing position");
181   }
182   // Back-pack at the far edge, then preserve separation by overflowing if needed.
183   let limit = end;
184   for (let i = rows.length - 1; i >= 0; i--) {
185     const row = rows[i];
186     row.position = Math.min(row.position, limit - row.size);
187     limit = finite(row.position - gap, "packing limit");
188   }
189   const shift = Math.max(0, start - rows[0].position);
190   const positiveFits =
191     positive >= acrossStart && positive + breadth <= acrossEnd;
192   const negativeFits =
193     negative - breadth >= acrossStart && negative <= acrossEnd;
194   const usePositive = positiveFits || !negativeFits;
195   const placements: AnchoredPlacement[] = new Array(items.length);
196   for (const row of rows) {
197     const along = finite(row.position + shift, "placement");
198     const across = finite(
199       usePositive ? positive : negative - row.breadth,
200       "placement",
201     );
202     const x = vertical ? across : along,
203       y = vertical ? along : across;
204     finite(x + row.item.width, "placement right");
205     finite(y + row.item.height, "placement bottom");
206     placements[row.index] = {
207       x,
208       y,
209       width: row.item.width,
210       height: row.item.height,
211     };
212   }
213   return placements;
214 }
215 function Item<T extends AnchoredItem>({
216   item,
217   placement,
218   render,
219 }: {
220   item: T;
221   placement: AnchoredPlacement;
222   render: AnchoredLayoutProps<T>["children"];
223 }): PibblNode {
224   const box = { x: 0, y: 0, width: placement.width, height: placement.height };
225   // Both the received allocation and percentage basis belong to the declared box.
226   const outer = pushLayoutBox(box);
227   let inner: (() => void) | undefined;
228   try {
229     inner = pushLayoutBox(box);
230     setCurrentComponentAfterRender(() => {
231       inner!();
232       outer();
233     });
234   } catch (error) {
235     inner?.();
236     outer();
237     throw error;
238   }
239   return withHooksForbidden(
240     "Pibbl hooks cannot be called in AnchoredLayout children; return a component instead.",
241     () => render(item, placement),
242   );
243 }
244 /**
245  * Arranges keyed Pibbl content together, clear of every supplied anchor.
246  *
247  * @param props - Anchored items, placement constraints, and child renderer. See
248  * {@link AnchoredLayoutProps} .
249  * @returns Pibbl content positioned using the computed placements. See {@link PibblNode}.
250  *
251  * @see {@link AnchoredLayoutProps}
252  * @see {@link PibblNode}
253  * @see {@link AnchoredItem}
254  */
255 export function AnchoredLayout<T extends AnchoredItem>(
256   props: AnchoredLayoutProps<T>,
257 ): PibblNode {
258   const allocation = useLayoutBox();
259   const items = resolveSignalValue(props.items);
260   if (!Array.isArray(items))
261     throw new TypeError("AnchoredLayout items must be an array.");
262   if (typeof props.children !== "function")
263     throw new TypeError(
264       "AnchoredLayout children must be a synchronous function.",
265     );
266   const direction = props.direction ?? "vertical";
267   if (direction !== "vertical" && direction !== "horizontal")
268     throw new TypeError(
269       "AnchoredLayout direction must be vertical or horizontal.",
270     );
271   const bounds =
272     props.bounds === undefined
273       ? { x: 0, y: 0, width: allocation.width, height: allocation.height }
274       : resolveSignalValue(props.bounds);
275   const placements = arrange(
276     items,
277     bounds,
278     resolveSignalValue(props.gap ?? 8),
279     direction === "vertical",
280   );
281   return items.map((item, i) => (
282     <Group
283       key={String(item.key)}
284       style={{ translateX: placements[i].x, translateY: placements[i].y }}
285     >
286       <Item item={item} placement={placements[i]} render={props.children} />
287     </Group>
288   ));
289 }
290 

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