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