@graview/layout 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +96 -0
  2. package/README.md +20 -0
  3. package/dist/band.d.ts +103 -0
  4. package/dist/band.d.ts.map +1 -0
  5. package/dist/band.js +344 -0
  6. package/dist/band.js.map +1 -0
  7. package/dist/city.d.ts +161 -0
  8. package/dist/city.d.ts.map +1 -0
  9. package/dist/city.js +319 -0
  10. package/dist/city.js.map +1 -0
  11. package/dist/ids.d.ts +79 -0
  12. package/dist/ids.d.ts.map +1 -0
  13. package/dist/ids.js +115 -0
  14. package/dist/ids.js.map +1 -0
  15. package/dist/index.d.ts +19 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +10 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/interpolate.d.ts +27 -0
  20. package/dist/interpolate.d.ts.map +1 -0
  21. package/dist/interpolate.js +217 -0
  22. package/dist/interpolate.js.map +1 -0
  23. package/dist/label-fit.d.ts +115 -0
  24. package/dist/label-fit.d.ts.map +1 -0
  25. package/dist/label-fit.js +162 -0
  26. package/dist/label-fit.js.map +1 -0
  27. package/dist/layout.d.ts +99 -0
  28. package/dist/layout.d.ts.map +1 -0
  29. package/dist/layout.js +1391 -0
  30. package/dist/layout.js.map +1 -0
  31. package/dist/rank.d.ts +35 -0
  32. package/dist/rank.d.ts.map +1 -0
  33. package/dist/rank.js +84 -0
  34. package/dist/rank.js.map +1 -0
  35. package/dist/related.d.ts +42 -0
  36. package/dist/related.d.ts.map +1 -0
  37. package/dist/related.js +207 -0
  38. package/dist/related.js.map +1 -0
  39. package/dist/types.d.ts +404 -0
  40. package/dist/types.d.ts.map +1 -0
  41. package/dist/types.js +27 -0
  42. package/dist/types.js.map +1 -0
  43. package/dist/view-state.d.ts +189 -0
  44. package/dist/view-state.d.ts.map +1 -0
  45. package/dist/view-state.js +350 -0
  46. package/dist/view-state.js.map +1 -0
  47. package/package.json +54 -0
package/dist/layout.js ADDED
@@ -0,0 +1,1391 @@
1
+ import { isCurrent, toIso } from "@graview/core";
2
+ import { DEFAULT_OPTIONS, } from "./types.js";
3
+ import { bandCaps, bandOf, packRuns, runOf } from "./band.js";
4
+ import { placeCity } from "./city.js";
5
+ import { rankKinds } from "./rank.js";
6
+ import { byStableKey, kindCardId, kindsOfAggregate, BEYOND_CARD } from "./ids.js";
7
+ import { connectorsFor, pluralOf, relatedNodes } from "./related.js";
8
+ /**
9
+ * Lays the kinds out as a flat shelf: one baseline, even spacing.
10
+ *
11
+ * It used to bow upward in an arc, middle cards lifted and receded. The bow
12
+ * carried no meaning — the row is not curved in the model, and the lift
13
+ * mostly existed to negotiate room with chrome that no longer reserves any.
14
+ * A shelf is a map; a map lies flat. Depth within the plane still separates
15
+ * a secondary kind from a primary one, without moving anything.
16
+ */
17
+ const SHELF_DEPTH = 0.85;
18
+ /*
19
+ * A kind card holds a name and a count, and that is a fixed number of
20
+ * pixels. Both the ring and the shelf are held to it: the ring has to reason
21
+ * about the height that will be DRAWN, which is this one whenever its own
22
+ * proportional answer lands under it.
23
+ */
24
+ const CARD_MIN_HEIGHT = 60;
25
+ /*
26
+ * The width a district card needs to hold its own name on one line.
27
+ *
28
+ * Measured rather than chosen: at a 16px root a seven- or eight-letter
29
+ * plural reads on one line down to about 132px of card and breaks between
30
+ * 132 and 98 — the label box is roughly a third of the card, the rest being
31
+ * the figure, the count and the padding. In the reader's own unit, like
32
+ * everything else here.
33
+ */
34
+ /* 160, not 132: the type scale went up a step, and "COMPONENTS" in 14px
35
+ capitals — drawn at plane two's 0.9, so 12.6 — needs the room 11.25px
36
+ did not. */
37
+ const DISTRICT_MIN_WIDTH = 160;
38
+ /**
39
+ * A billboard cut to its picture is never shorter than this, in the lens's
40
+ * own pixels. An empty lens — a header over no rows yet — measured to a
41
+ * strip one line tall, which read as a label, not a screen; at this height
42
+ * the room under the header is visibly an empty screen.
43
+ */
44
+ /**
45
+ * HOW FAR A BILLBOARD MAY BE MOVED FROM ITS OWN PLOT, in city cells.
46
+ *
47
+ * Cells rather than pixels so the same pin holds at every zoom: a board
48
+ * nudged two cells north is two cells north whether the city is flown close
49
+ * or seen whole. Two is about a village's width — enough to shift a board
50
+ * off whatever it was covering, not enough for it to read as a picture of
51
+ * somewhere else.
52
+ */
53
+ export const SCREEN_LEASH_CELLS = 2;
54
+ const SCREEN_MIN_NATURAL_HEIGHT = 240;
55
+ /*
56
+ * The width a drive-in screen is never drawn under: the picture is the
57
+ * interface scaled, and below this its words are not words. In the
58
+ * reader's unit, like the districts.
59
+ */
60
+ const DRIVE_IN_MIN_WIDTH = 300;
61
+ /*
62
+ * THE ROOM A DRIVE-IN'S MARQUEE TAKES under the nameplate: its showings as
63
+ * buttons, wrapped to the card's width. It was one fixed row, and Rota's
64
+ * three long titles wrapped to three and stood on the landmark below. The
65
+ * estimate is the pill's own metrics — eleven-pixel type, eight of padding
66
+ * a side, a four-pixel gap, a twenty-eight-pixel row — so the band is the
67
+ * height the buttons will actually take, and nothing else moves.
68
+ */
69
+ /*
70
+ * The showings are PICTURES now: one thumbnail per lens with its name
71
+ * under it — a single showing at 120×72, two or more in two columns of
72
+ * 58×36 — so the band is the rows of thumbnails they make. `cardWidth` is
73
+ * kept for the call sites; the columns are fixed by the thumbnail size.
74
+ */
75
+ export const THUMB_ONE = { width: 120, height: 72 };
76
+ export const THUMB_TWO = { width: 58, height: 36 };
77
+ const THUMB_TITLE = 16;
78
+ const MARQUEE_GAP = 4;
79
+ export function marqueeHeightFor(titles, cardWidth) {
80
+ void cardWidth;
81
+ if (titles.length === 0)
82
+ return 0;
83
+ if (titles.length === 1)
84
+ return 10 + THUMB_ONE.height + THUMB_TITLE;
85
+ const rows = Math.ceil(titles.length / 2);
86
+ return 10 + rows * (THUMB_TWO.height + THUMB_TITLE) + (rows - 1) * MARQUEE_GAP;
87
+ }
88
+ /*
89
+ * A district's nameplate is a pill drawn by the stylesheet, not the card's
90
+ * box: on a landmark it floats above the box by its own height and a gap,
91
+ * and it is as wide as its words. The screen keeps this much clear of the
92
+ * box's top, and other districts' boxes reach this much further sideways,
93
+ * so a screen never stands on a plate.
94
+ */
95
+ const PLATE_CLEARANCE = 52;
96
+ const PLATE_REACH = 60;
97
+ function shelf(count, size, gap, canvasWidth, baseY, depth = SHELF_DEPTH) {
98
+ const total = count * size.width + Math.max(0, count - 1) * gap;
99
+ const startX = Math.max(gap, (canvasWidth - total) / 2);
100
+ return Array.from({ length: count }, (_, index) => ({
101
+ x: startX + index * (size.width + gap),
102
+ y: baseY,
103
+ depth,
104
+ }));
105
+ }
106
+ /** Lays a row of equal boxes out, centred on the canvas. */
107
+ function row(count, size, gap, canvasWidth, y) {
108
+ const total = count * size.width + Math.max(0, count - 1) * gap;
109
+ const startX = Math.max(gap, (canvasWidth - total) / 2);
110
+ return Array.from({ length: count }, (_, index) => ({
111
+ x: startX + index * (size.width + gap),
112
+ y,
113
+ }));
114
+ }
115
+ /**
116
+ * Where everything sits, as a pure function of (focus, relation, graph),
117
+ * overlaid with user pins.
118
+ *
119
+ * Same graph and same view always produce the same picture — that is what
120
+ * lets spatial memory survive, and what lets any two states be interpolated
121
+ * into an animated transition.
122
+ */
123
+ /**
124
+ * AN OPENED DISTRICT'S ROSTER, in arithmetic both sides share.
125
+ *
126
+ * The layout reserved 96 pixels under an opened district's nameplate and
127
+ * the view listed sixteen members in 250 — so a dealership's Vehicles,
128
+ * opened at the bottom of the city, ran its list off the scene and over the
129
+ * district beside it. The layout now reserves the rows it will be asked to
130
+ * hold, the view lists the rows it was given (`openedRows`), and the rest
131
+ * are counted.
132
+ */
133
+ export const ROSTER_ROW = 27;
134
+ /** The most rows a roster is given: past it the district is gone into. */
135
+ export const ROSTER_MOST = 8;
136
+ /** The rows it keeps however crowded the city: the city grows before the roster goes under four. */
137
+ const ROSTER_KEPT = 4;
138
+ /** The count line under the rows, the hairline and padding above them, and the header's second line (the count wraps under the name). */
139
+ const ROSTER_CHROME = 64;
140
+ export const rosterRows = (count) => Math.max(1, Math.min(ROSTER_MOST, count));
141
+ export const rosterHeight = (rows) => rows * ROSTER_ROW + ROSTER_CHROME;
142
+ export function layout(graph, schema, state, options = {}) {
143
+ const opts = { ...DEFAULT_OPTIONS, ...options };
144
+ /*
145
+ * THE SPAN: the canvas less the rail reserved for chrome. Every card is
146
+ * sized and centred within it, in every mode — the inspector and the
147
+ * quick relations live on the left edge whether the picture is the whole
148
+ * domain or one thing, and a lens that takes the full focus width was
149
+ * drawn under them. The reported width stays the canvas's own.
150
+ */
151
+ /*
152
+ * WHAT ONE `rem` IS WORTH. Every card here holds text sized in `rem`, so
153
+ * the cards follow the same number the text does — the whole city grows
154
+ * when a reader asks for bigger words. 16 is the browser's own default,
155
+ * so an app that says nothing lays out exactly as it always has.
156
+ */
157
+ const unit = Math.max(8, opts.unit ?? 16) / 16;
158
+ const railLeft = opts.inset?.left ?? 0;
159
+ const spanW = opts.width - railLeft - (opts.inset?.right ?? 0);
160
+ const nodes = [];
161
+ const placed = new Map();
162
+ // A group may be focused as readily as a node: "show me the week" and
163
+ // "show me this run" are the same gesture at different granularities.
164
+ /*
165
+ * THE HORIZON. A node retired under its kind's declared lifecycle is not
166
+ * in the picture unless the view has deliberately widened to the past —
167
+ * and everything that drops out is COUNTED where it dropped from, so the
168
+ * archive is one step away rather than gone.
169
+ */
170
+ const current = (node) => state.past === true || isCurrent(schema.tryDefinition(node.kind), node, opts.today);
171
+ /*
172
+ * MODULES OFF are not drawn at all — no card, no members, no raised
173
+ * plane. Unlike the horizon there is no advert and no count: a workspace
174
+ * that turned Vehicles off did not archive its cars, it scoped its
175
+ * interface, and a "+2 elsewhere" pill would reintroduce the very concept
176
+ * the toggle removed. The nodes stay in the graph untouched.
177
+ */
178
+ const hidden = new Set(options.hiddenKinds ?? []);
179
+ const visible = (node) => !hidden.has(node.kind);
180
+ /*
181
+ * A GROUP IS A PLACE WHETHER OR NOT ANYBODY IS IN IT.
182
+ *
183
+ * The kinds are read from the ADDRESS, and then checked against the
184
+ * declaration: `aggregate:nonsense` is a focus id nothing resolves and
185
+ * falls back to the default view below, while `aggregate:item` with no
186
+ * items is a real place holding nobody — which is exactly the picture a
187
+ * blank app needs, and exactly where a lens's empty state lives.
188
+ */
189
+ let focusKinds = state.focusId
190
+ ? kindsOfAggregate(state.focusId)
191
+ .filter((kind) => schema.tryDefinition(kind) !== undefined)
192
+ .filter((kind) => !hidden.has(kind))
193
+ : [];
194
+ /*
195
+ * A URL can point where this workspace cannot go — a bookmarked vehicle
196
+ * in a workspace that turned the module off. The honest landing is the
197
+ * default view, not a void with that node's name on it.
198
+ */
199
+ if (state.focusId && kindsOfAggregate(state.focusId).length > 0 && focusKinds.length === 0) {
200
+ state = { ...state, focusId: null };
201
+ }
202
+ const hiddenFocus = state.focusId ? graph.getNode(state.focusId) : undefined;
203
+ if (hiddenFocus && !visible(hiddenFocus)) {
204
+ state = { ...state, focusId: null };
205
+ focusKinds = [];
206
+ }
207
+ const focusAll = focusKinds.length > 0
208
+ ? [...graph.allNodes()]
209
+ .filter((node) => focusKinds.includes(node.kind))
210
+ .sort(byStableKey)
211
+ : [];
212
+ const focusGroup = focusAll.filter(visible).filter(current);
213
+ const focusRetired = focusAll.length - focusGroup.length;
214
+ const focus = state.focusId && focusKinds.length === 0 ? graph.getNode(state.focusId) : undefined;
215
+ /*
216
+ * Bands are PROPORTIONS of the canvas, and they tile it.
217
+ *
218
+ * Fixed pixel bands in a scene that is now sized to its container left
219
+ * more than half the height empty — the focus panel floating in the top
220
+ * third with a void beneath it. Every band is a share of the height, so
221
+ * the composition holds at any size, and the three planes read as one
222
+ * arrangement rather than three rows that happen to be stacked.
223
+ *
224
+ * A DETAIL gets a tighter set than a group. The week's calendar fills 56%
225
+ * of the height honestly; one nap does not, and holding the same band open
226
+ * for it puts a small card alone in the top half with several hundred
227
+ * pixels of nothing under it before its relations begin.
228
+ */
229
+ /*
230
+ * Every band ends inside the canvas. This is checked, because it was not
231
+ * true: the group set ran to 1.03 of the height, so the bottom 3% of every
232
+ * context card was cut off on every screen, in every app, from the day the
233
+ * bands became proportional. Nothing scrolls — a scene is sized to its
234
+ * container on purpose — so an overflowing band is content nobody can
235
+ * reach rather than content below the fold.
236
+ */
237
+ /*
238
+ * ZOOMED IN CLOSE, the focus takes most of the scene — most, not all.
239
+ *
240
+ * The shelf and any raised relation keep thin bands at the bottom,
241
+ * receded: the zoomed stop is still a place in the same picture, with its
242
+ * connectors, not a document that replaced it. This is what the jack-in
243
+ * gesture lands on now; the modal page it used to open isolated the view
244
+ * from every relation it had.
245
+ */
246
+ const zoomed = state.zoom === true && !state.overview && state.focusId !== null;
247
+ /*
248
+ * THE SHELF'S OWN HEIGHT HAS A FLOOR, SO ITS TOP CANNOT BE A PROPORTION.
249
+ *
250
+ * A zoomed band put the kinds plane at 89.5% of the height and gave it
251
+ * `max(56, height * 0.062)` — which agree only while the proportion is the
252
+ * larger of the two. On a short canvas the floor wins and the row runs off
253
+ * the bottom: at 390x242, 0.895 puts its top at 217 and 56 more is 273, a
254
+ * card thirty pixels below the screen with nothing to scroll. Every other
255
+ * band in this function ends inside the canvas by arithmetic; this one
256
+ * ended inside it by luck, and a generated declaration at a phone's
257
+ * proportions is where the luck ran out.
258
+ *
259
+ * The floor decides where the top is, not the other way round.
260
+ */
261
+ const zoomedContextH = Math.max(56, opts.height * 0.062);
262
+ const zoomedContextY = Math.min(opts.height * 0.895, opts.height - zoomedContextH);
263
+ const band = zoomed
264
+ ? focus !== undefined
265
+ ? {
266
+ // A zoomed RECORD is a reading column with its neighbourhood
267
+ // under it at full size: the column does not need the height a
268
+ // dense picture does, and a neighbourhood squeezed into a sliver
269
+ // clipped its own cards.
270
+ focusY: opts.height * 0.03,
271
+ focusH: opts.height * 0.58,
272
+ relationY: opts.height * 0.645,
273
+ relationH: opts.height * 0.2,
274
+ contextY: zoomedContextY,
275
+ contextH: zoomedContextH,
276
+ }
277
+ : {
278
+ // A zoomed PLACE is the dense picture: most of the scene, with a
279
+ // raised relation kept usable and the shelf receded below.
280
+ focusY: opts.height * 0.03,
281
+ focusH: opts.height * 0.71,
282
+ relationY: opts.height * 0.765,
283
+ relationH: opts.height * 0.1,
284
+ contextY: zoomedContextY,
285
+ contextH: zoomedContextH,
286
+ }
287
+ : focus === undefined
288
+ ? {
289
+ /*
290
+ * Checked WITH a relation raised, which is the state that broke:
291
+ * the old proportions left plane 1's bottom edge minus one pixel
292
+ * from plane 2's top, and the raised cards sat directly on the
293
+ * kinds shelf. Every band boundary here keeps clear ground below
294
+ * it at any canvas height the surveys cover.
295
+ */
296
+ focusY: opts.height * 0.045,
297
+ focusH: opts.height * 0.62,
298
+ relationY: opts.height * 0.68,
299
+ relationH: opts.height * 0.2,
300
+ contextY: opts.height * 0.918,
301
+ contextH: opts.height * 0.082,
302
+ }
303
+ : (() => {
304
+ /*
305
+ * A SHORT CANVAS gives the focus more of itself. At a window's
306
+ * height 42% is a card with room to spare; in a box the height of
307
+ * a paragraph it is a card cut across its own facts. The focus
308
+ * takes up to 56% until it has 300 pixels, and the relations band
309
+ * gives up what the focus took; at 715 and above nothing changes.
310
+ */
311
+ const h = opts.height;
312
+ const focusY = h * 0.04;
313
+ const focusH = Math.max(h * 0.42, Math.min(h * 0.56, 300));
314
+ const relationY = focusY + focusH + h * 0.06;
315
+ return {
316
+ focusY,
317
+ focusH,
318
+ relationY,
319
+ relationH: Math.max(h * 0.12, h * 0.878 - h * 0.068 - relationY),
320
+ contextY: h * 0.878,
321
+ contextH: h * 0.092,
322
+ };
323
+ })();
324
+ /*
325
+ * A group gets the whole width; a single node does not.
326
+ *
327
+ * The week's calendar has five columns to fill and earns 1040 pixels. One
328
+ * nap, with a name, a time and one person at it, drawn across the same
329
+ * width is a letterbox with four words in it. Narrowing the detail box is
330
+ * the difference between a card and an empty page.
331
+ */
332
+ // Zoomed, a group runs nearly wall to wall; a record stays a readable
333
+ // column even with the room — 880 is a document's width, not a letterbox.
334
+ /*
335
+ * The margin a focused card keeps from the span's edges: six gaps on a
336
+ * screen with room, and never more than a tenth of a narrow one — 96
337
+ * pixels off a 240-pixel span left a lens too thin to draw its own
338
+ * label column, on a frame where the margin was buying nothing.
339
+ */
340
+ const focusMargin = (gaps) => Math.min(opts.gap * gaps, Math.round(spanW * 0.1));
341
+ const detailWidth = zoomed
342
+ ? Math.min(880, spanW - focusMargin(5))
343
+ : Math.min(700, spanW - focusMargin(6));
344
+ const groupWidth = zoomed
345
+ ? spanW - focusMargin(5)
346
+ : Math.min(opts.focusSize.width, spanW - focusMargin(6));
347
+ /** Fits `count` boxes across the canvas, never wider than the cap. */
348
+ const fit = (count, cap, height) => ({
349
+ width: count === 0
350
+ ? cap
351
+ : Math.min(cap, (spanW - opts.gap * (count + 1)) / count),
352
+ height,
353
+ });
354
+ /*
355
+ * A DISTRICT IS READ, SO IT IS NEVER SQUEEZED BELOW A WORD.
356
+ *
357
+ * `fit` divides the span by the count with no floor, and past a handful of
358
+ * kinds in a narrow host that is a row of one letter per line: measured,
359
+ * a district card needs about 132px at a 16px root to hold a seven- or
360
+ * eight-letter plural on one line, and it breaks somewhere between 132 and
361
+ * 98. Four kinds at 390px gave 46px cards and five lines of "Lists";
362
+ * thirteen kinds at 700px gave 72px and three lines of "Rules". The chips
363
+ * already know how to shed detail — they drop the count and the disclosure
364
+ * before the label — and the missing step was shedding the ROW.
365
+ *
366
+ * So the row holds as many as fit at the floor and hands the rest to one
367
+ * card that names them. Squeezing to one glyph per line is the one answer
368
+ * that communicates nothing.
369
+ */
370
+ const districtRow = (count, cap, height) => {
371
+ const size = fit(count, cap, height);
372
+ /*
373
+ * The floor is a floor until the ROOM runs out, and then the room wins.
374
+ *
375
+ * At the reader's largest text on a phone the unit doubles, so the floor
376
+ * is 264 of a span that is 262 — and a card held to a floor wider than
377
+ * the ground it stands on paints off the edge with nothing to scroll.
378
+ * Wrapping a name is bad; drawing it past the screen is worse.
379
+ */
380
+ const floor = Math.min(cap, DISTRICT_MIN_WIDTH * unit, spanW - opts.gap * 2);
381
+ return size.width >= floor ? size : { width: floor, height };
382
+ };
383
+ /** How many districts fit at the floor, before the row has to shed. */
384
+ const districtCapacity = (cap) => Math.max(1, Math.floor((spanW - opts.gap) /
385
+ (Math.min(cap, DISTRICT_MIN_WIDTH * unit, spanW - opts.gap * 2) + opts.gap)));
386
+ const expanded = new Set(state.expanded);
387
+ // Nothing is raised while you are above the stack: the ring IS the
388
+ // relation plane up here.
389
+ const related = state.overview
390
+ ? []
391
+ : relatedNodes(graph, schema, focus, state.relation, opts.judged)
392
+ .filter((entry) => visible(entry.node))
393
+ .filter((entry) => current(entry.node));
394
+ /*
395
+ * With plane 1 empty, the focus takes the relation band too.
396
+ *
397
+ * The alternative is a void: context used to slide up under the focus
398
+ * whenever nothing was raised, which avoided a dead stripe through the
399
+ * middle but left a bigger one along the bottom, and the week's calendar
400
+ * stayed squeezed into 56% of a screen it could have filled. Growing the
401
+ * focus keeps the context row anchored where it always is, so raising
402
+ * something moves one band rather than re-composing the whole scene.
403
+ */
404
+ const focusHeight = related.length > 0
405
+ ? band.focusH
406
+ : band.contextY - band.focusY - opts.gap * 2;
407
+ /*
408
+ * Above the stack your interface STAYS, shrunk, in the middle of the ring.
409
+ *
410
+ * Not a picture of it and not a card standing in for it: the same view, at
411
+ * plane 0, at full fidelity, still live — the board is still a board, the
412
+ * calendar is still a calendar, and anything the platform lets you do to it
413
+ * you can still do. That is the whole point of rising: seeing what you are
414
+ * working on IN RELATION to everything else, rather than swapping it for a
415
+ * diagram of the schema.
416
+ *
417
+ * It can coexist with its own kind card now because the kinds plane has its
418
+ * own namespace — `aggregate:position` and `kind:position` are different
419
+ * things, which they always were.
420
+ */
421
+ /*
422
+ * The slot keeps the proportions of the thing it is showing, so the scale
423
+ * is uniform and the picture is not stretched.
424
+ */
425
+ const naturalW = groupWidth;
426
+ /*
427
+ * Generous on purpose: the view lays itself out here, so a short box means
428
+ * a scrollbar in the middle of the Graview showing one row of nine.
429
+ * Complete and small beats partial and larger.
430
+ *
431
+ * The whole canvas height rather than most of it, because the slot's own
432
+ * size does not depend on this — the scale is what absorbs it. At 0.92 the
433
+ * week and the board each came up a handful of pixels short and put a
434
+ * scroll region inside a picture drawn at 46%, which is a scrollbar nobody
435
+ * can use. Measured in `scripts/verify-shrunk.mjs`.
436
+ */
437
+ const naturalH = opts.height;
438
+ /*
439
+ * The live view is the TALLEST STRUCTURE in the picture, not a peer stamp.
440
+ *
441
+ * At 0.4 by 0.42 the shrunk interface read as one more card among the
442
+ * kinds — the same visual rank as a district of five nodes — and most of
443
+ * the scene was empty ground. Half the width and nearly half the height
444
+ * says what is actually true from up here: this is the thing you were
445
+ * standing in, and everything else is arranged around it.
446
+ */
447
+ const overviewScale = Math.min((spanW * 0.5) / naturalW, (opts.height * 0.48) / naturalH);
448
+ const overviewW = naturalW * overviewScale;
449
+ const overviewH = naturalH * overviewScale;
450
+ // ------------------------------------------------------- plane 0: focus
451
+ /*
452
+ * A focused GROUP, from altitude, is its district when its picture is only
453
+ * the framework's list: the district opens in place instead. A group with
454
+ * its own view keeps the scaled card — and then its district stays shut,
455
+ * so the same names are never drawn twice.
456
+ */
457
+ const plain = new Set(options.plainGroups ?? []);
458
+ const groupFocus = state.overview && !focus && focusKinds.length > 0 && state.focusId !== null;
459
+ const groupIsPlain = groupFocus && focusKinds.length > 0 && focusKinds.every((kind) => plain.has(kind));
460
+ if (groupFocus) {
461
+ /*
462
+ * THE DISTRICT OF THE KIND IN FOCUS IS NOT THE PERSON'S TO OPEN, and
463
+ * that is deliberate: its members are already the picture above, and
464
+ * drawing the same ten names twice is the one thing this layout will
465
+ * not do. A plain group opens in place INSTEAD of the framework's list;
466
+ * a group with a view of its own keeps its card shut.
467
+ *
468
+ * Which means the card must not OFFER to open it. It did — the control
469
+ * wrote `expand=kind:shift` into the stop, this threw it away on the
470
+ * next frame, and nothing moved. See the district card in
471
+ * `@graview/primitives`, which now says where the members are instead.
472
+ */
473
+ for (const kind of focusKinds) {
474
+ if (groupIsPlain)
475
+ expanded.add(kindCardId(kind));
476
+ else
477
+ expanded.delete(kindCardId(kind));
478
+ }
479
+ }
480
+ /**
481
+ * A DRIVE-IN, when the focused group's kind has a named picture: the
482
+ * screen will stand on that kind's plot once the city is placed, so the
483
+ * city need not slide aside for it. The showing is the one the address
484
+ * names, or the first the kind has.
485
+ */
486
+ const screenKind = groupFocus && !groupIsPlain && focusKinds.length === 1 ? focusKinds[0] : undefined;
487
+ const showings = screenKind ? (options.screens?.[screenKind] ?? []) : [];
488
+ const showing = showings.find((one) => one.as === state.within?.["view"]) ?? showings[0];
489
+ const driveIn = screenKind !== undefined && showing !== undefined;
490
+ /** The picture standing in the middle from altitude, when there is one: ground the city must not take. */
491
+ let stamp;
492
+ if (state.overview && (focus || focusKinds.length > 0) && state.focusId && !groupIsPlain) {
493
+ stamp = {
494
+ x: railLeft + (spanW - overviewW) / 2,
495
+ y: opts.height * 0.53 - overviewH / 2,
496
+ width: overviewW,
497
+ height: overviewH,
498
+ };
499
+ push({
500
+ id: state.focusId,
501
+ kind: focus ? focus.kind : focusKinds[0],
502
+ plane: 0,
503
+ // Centred in the span the city uses, so the picture and its city agree.
504
+ x: stamp.x,
505
+ y: stamp.y,
506
+ width: overviewW,
507
+ height: overviewH,
508
+ // Lay out as if it had the whole scene, then draw it small. The view
509
+ // is the view; only the picture is scaled.
510
+ natural: { width: naturalW, height: naturalH },
511
+ ...(focus
512
+ ? {}
513
+ : {
514
+ aggregate: {
515
+ kind: focusKinds.join("+"),
516
+ memberIds: focusGroup.map((node) => node.id),
517
+ label: options.plurals?.[state.focusId] ??
518
+ focusKinds.map((kind) => pluralOf(schema, kind)).join(" and "),
519
+ ...(focusRetired > 0 ? { retired: focusRetired } : {}),
520
+ },
521
+ }),
522
+ });
523
+ }
524
+ else if (state.overview) {
525
+ // nothing focused: the ring is the whole picture
526
+ }
527
+ else if (focus) {
528
+ push({
529
+ id: focus.id,
530
+ kind: focus.kind,
531
+ plane: 0,
532
+ x: railLeft + (spanW - detailWidth) / 2,
533
+ y: band.focusY,
534
+ width: detailWidth,
535
+ height: focusHeight,
536
+ });
537
+ }
538
+ else if (focusKinds.length > 0 && state.focusId) {
539
+ push({
540
+ id: state.focusId,
541
+ kind: focusKinds[0],
542
+ plane: 0,
543
+ x: railLeft + (spanW - groupWidth) / 2,
544
+ y: band.focusY,
545
+ width: groupWidth,
546
+ height: focusHeight,
547
+ aggregate: {
548
+ kind: focusKinds.join("+"),
549
+ memberIds: focusGroup.map((node) => node.id),
550
+ label: options.plurals?.[state.focusId] ??
551
+ focusKinds.map((kind) => pluralOf(schema, kind)).join(" and "),
552
+ ...(focusRetired > 0 ? { retired: focusRetired } : {}),
553
+ },
554
+ });
555
+ }
556
+ // --------------------------------------------------- plane 1: relations
557
+ /*
558
+ * A run of one or two cards takes wider ones. The cap exists to fit a
559
+ * crowd; holding a lone neighbour to crowd width drew one small slip in
560
+ * the middle distance of an otherwise empty band, with its caption
561
+ * stretched past both its edges.
562
+ */
563
+ /*
564
+ * A CROWD WRAPS. Twelve neighbours in one row gave each a slot 57 pixels
565
+ * wide under a chip 150 wide, and the band was a heap of overlapping
566
+ * labels with the lines between them cut to confetti. A slot is never
567
+ * narrower than a chip can be read in; past that the band takes another
568
+ * row, each row sharing the band's height.
569
+ */
570
+ /** The least a band row can be and still be read: a chip's height at a 16px rem. */
571
+ const READABLE_ROW = 26;
572
+ /** A crowded band's row: a group card's name and count, and the first of its members. */
573
+ const READABLE_CARD = 52;
574
+ /** The ground a caption needs above a crowded band's row. */
575
+ const CAPTION_GUTTER = 22;
576
+ const minRelationW = Math.round(opts.relationSize.width * 0.75);
577
+ const perRow = Math.max(1, Math.floor((spanW - opts.gap) / (minRelationW + opts.gap)));
578
+ /*
579
+ * THE BAND'S BUDGET (docs/scale.md). Everything the focus touches is drawn
580
+ * as itself while it fits as chips — a chip's height a row — and small
581
+ * apps keep exactly the band they had.
582
+ *
583
+ * Past that the band is CROWDED, and it is laid out for reading rather
584
+ * than for fitting: a row is as tall as a group card needs to say its
585
+ * name, its count and the first of its members; the gutter between rows
586
+ * is as tall as a caption; and each relation starts its own row unless
587
+ * all of it fits the rest of the current one. Each relation gets the
588
+ * cards the rows can give it, drawn as itself, as groups in its own
589
+ * declaration's words, or as its most relevant and one "+N more" — so an
590
+ * artist with a thousand songs is three captioned rows a person can read.
591
+ */
592
+ const scaleUnit = opts.unit / 16;
593
+ const chipRows = Math.max(2, Math.floor((band.relationH + opts.gap) / (READABLE_ROW * scaleUnit + opts.gap)));
594
+ const crowded = related.length > perRow * chipRows;
595
+ const bandOptions = {
596
+ schema,
597
+ graph,
598
+ focusId: focus?.id,
599
+ expanded,
600
+ ...(opts.relevance ? { relevance: opts.relevance } : {}),
601
+ ...(state.selection ? { selection: state.selection } : {}),
602
+ plural: (kind) => pluralOf(schema, kind),
603
+ ...(opts.today ? { today: opts.today } : {}),
604
+ };
605
+ const rowGap = crowded ? Math.max(opts.gap, CAPTION_GUTTER * scaleUnit) : opts.gap;
606
+ let drawn;
607
+ let rowsOf;
608
+ if (!crowded) {
609
+ drawn = related.map((entry) => ({ id: entry.node.id, kind: entry.node.kind, node: entry.node, ...(entry.via ? { via: entry.via } : {}) }));
610
+ rowsOf = Array.from({ length: Math.max(1, Math.ceil(drawn.length / perRow)) }, (_, r) => Array.from({ length: Math.min(perRow, drawn.length - r * perRow) }, (_, i) => r * perRow + i));
611
+ }
612
+ else {
613
+ const cardRows = Math.max(1, Math.floor((band.relationH + rowGap) / (READABLE_CARD * scaleUnit + rowGap)));
614
+ const runs = new Map();
615
+ for (const entry of related) {
616
+ const key = runOf(entry);
617
+ const run = runs.get(key);
618
+ if (run)
619
+ run.push(entry);
620
+ else
621
+ runs.set(key, [entry]);
622
+ }
623
+ const caps = bandCaps([...runs.values()].map((run) => run.length), perRow, cardRows);
624
+ const byRun = [...runs.values()].map((run, index) => bandOf(run, { ...bandOptions, budget: caps[index] }));
625
+ drawn = byRun.flat();
626
+ // Rows of indices into `drawn`, relation by relation.
627
+ const starts = byRun.map((_, index) => byRun.slice(0, index).reduce((sum, items) => sum + items.length, 0));
628
+ const seen = byRun.map(() => 0);
629
+ rowsOf = packRuns(byRun.map((items) => items.length), perRow).map((row) => row.map((run) => starts[run] + seen[run]++));
630
+ }
631
+ const relationRows = rowsOf.length;
632
+ const rowH = relationRows === 1 ? band.relationH : (band.relationH - rowGap * (relationRows - 1)) / relationRows;
633
+ const relationSize = fit(Math.max(...rowsOf.map((items) => items.length), 1), drawn.length <= 2 ? Math.round(opts.relationSize.width * 1.35) : opts.relationSize.width, rowH);
634
+ const relationPositions = [];
635
+ rowsOf.forEach((items, r) => {
636
+ const positions = row(items.length, relationSize, opts.gap, spanW, band.relationY + r * (rowH + rowGap));
637
+ items.forEach((index, i) => (relationPositions[index] = { ...positions[i], x: positions[i].x + railLeft }));
638
+ });
639
+ drawn.forEach((entry, index) => {
640
+ const position = relationPositions[index];
641
+ push({
642
+ id: entry.id,
643
+ kind: entry.kind,
644
+ plane: 1,
645
+ x: position.x,
646
+ y: position.y,
647
+ width: relationSize.width,
648
+ height: relationSize.height,
649
+ ...(entry.via ? { via: entry.via } : {}),
650
+ ...(entry.aggregate ? { aggregate: entry.aggregate } : {}),
651
+ ...(crowded && !entry.aggregate ? { compact: true } : {}),
652
+ });
653
+ });
654
+ /* ----------------------------------------------------- plane 2: the kinds
655
+ *
656
+ * EVERY declared kind, always, in the same place.
657
+ *
658
+ * This used to be "whatever is not on screen", which meant the row's
659
+ * membership changed as you navigated: the kind you were looking at
660
+ * vanished from it, and reappeared somewhere else the moment you looked
661
+ * at something else. That is unreadable as a map — you could not tell that
662
+ * the board WAS Positions, and Positions turning up at the bottom when you
663
+ * switched to training looked like a bug rather than the same card.
664
+ *
665
+ * A constant strip is what makes the plane a map: the kind in focus is
666
+ * marked as focused rather than removed, a raised kind is marked as
667
+ * raised, and nothing ever moves.
668
+ */
669
+ const shown = new Set(placed.keys());
670
+ for (const member of focusGroup)
671
+ shown.add(member.id);
672
+ const groups = new Map();
673
+ for (const node of graph.allNodes()) {
674
+ const list = groups.get(node.kind);
675
+ if (list)
676
+ list.push(node);
677
+ else
678
+ groups.set(node.kind, [node]);
679
+ }
680
+ const focusedKinds = new Set(focusKinds.length > 0 ? focusKinds : focus ? [focus.kind] : []);
681
+ const entries = [];
682
+ for (const kind of schema.kinds) {
683
+ if (hidden.has(kind))
684
+ continue;
685
+ const all = groups.get(kind) ?? [];
686
+ const members = all.filter(current);
687
+ entries.push({
688
+ id: kindCardId(kind),
689
+ kind,
690
+ members: members.sort(byStableKey),
691
+ retired: all.length - members.length,
692
+ ...(focusedKinds.has(kind) ? { focused: true } : {}),
693
+ });
694
+ }
695
+ /*
696
+ * A raised kind keeps its place, emptied.
697
+ *
698
+ * Raising a kind moves every member to plane 1, which used to delete the
699
+ * group from the context plane entirely — so the only sign of what was
700
+ * raised was the breadcrumb. The id is unchanged, so it sorts into exactly
701
+ * the position it held before: the picture you remember is the picture you
702
+ * get, and clicking it again drops the relation.
703
+ */
704
+ for (const entry of entries) {
705
+ if (entry.kind === state.relation)
706
+ entry.raised = true;
707
+ }
708
+ entries.sort(byStableKey);
709
+ /*
710
+ * RANKED, not merely listed.
711
+ *
712
+ * A strip of nine identical thumbnails says which kinds exist and nothing
713
+ * about which of them matter. What the focus actually touches is drawn full
714
+ * size; what it reaches only through something else is drawn smaller and
715
+ * tucked behind whatever it hangs off, so a nested relationship reads as
716
+ * nested. The ranking comes from the schema, which is what makes it stable:
717
+ * adding a node can never promote a kind and shuffle the row.
718
+ */
719
+ const ranking = rankKinds(schema, focusedKinds);
720
+ /*
721
+ * Ranking only says something when something is actually near. A focus
722
+ * whose kind declares no edges makes EVERY other kind secondary, and a
723
+ * strip where all nine cards shrank together carries no more information
724
+ * than one where none did — only less legibility.
725
+ *
726
+ * On the ring nothing ranks and nothing nests: up there every kind is a
727
+ * district of the same city, what-touches-what is the connectors' job,
728
+ * and a card tucked behind another is a pile — which is exactly the thing
729
+ * altitude exists to undo.
730
+ */
731
+ const ranked = !state.overview &&
732
+ schema.kinds.some((kind) => ranking.rankOf(kind) === "primary");
733
+ // Expanding an aggregate and collapsing it run through this one loop:
734
+ // an open group contributes its members, a closed one contributes itself.
735
+ const contextItems = [];
736
+ for (const entry of entries) {
737
+ /*
738
+ * In the stack an expanded aggregate DISSOLVES into its members. From
739
+ * altitude it OPENS instead: the card keeps its ring stop and shows its
740
+ * members in place, because dissolving up there would hand every member
741
+ * its own stop and re-flow the whole map.
742
+ */
743
+ if (!state.overview && expanded.has(entry.id) && entry.members.length > 0) {
744
+ for (const member of entry.members) {
745
+ contextItems.push({ id: member.id, kind: member.kind });
746
+ }
747
+ }
748
+ else {
749
+ const rank = ranked ? ranking.rankOf(entry.kind) : undefined;
750
+ const parent = rank === "secondary" ? ranking.parentOf(entry.kind) : undefined;
751
+ contextItems.push({
752
+ id: entry.id,
753
+ kind: entry.kind,
754
+ ...(entry.raised ? { raised: true } : {}),
755
+ ...(entry.focused ? { focused: true } : {}),
756
+ ...(state.overview && expanded.has(entry.id) ? { opened: true } : {}),
757
+ ...(rank ? { rank } : {}),
758
+ // Only nest under a card that is actually on the plane: an expanded
759
+ // parent has dissolved into its members and has nothing to hang off.
760
+ ...(parent && !expanded.has(kindCardId(parent))
761
+ ? { nestedUnder: kindCardId(parent) }
762
+ : {}),
763
+ aggregate: {
764
+ kind: entry.kind,
765
+ memberIds: entry.members.map((m) => m.id),
766
+ label: options.plurals?.[entry.kind] ?? pluralOf(schema, entry.kind),
767
+ ...(entry.retired > 0 ? { retired: entry.retired } : {}),
768
+ },
769
+ });
770
+ }
771
+ }
772
+ /*
773
+ * A nested card takes no slot of its own: it hangs off its parent's.
774
+ *
775
+ * Drawn first so the card it belongs to paints over it — same plane, same
776
+ * z-index, so the order here IS the stacking, and "behind" is the whole
777
+ * reading.
778
+ */
779
+ /*
780
+ * TWO of them, at most, and the rest take ordinary slots.
781
+ *
782
+ * Nesting is an emphasis device: "these hang off that". Six kinds sharing a
783
+ * parent turned it into the opposite — the fan divides the parent's width
784
+ * between them, so the coaching week drew FIXTURES, PLAYERS, POSITIONS,
785
+ * RULES, SQUADS and UNAVAILABILITY as six seventy-pixel slivers whose
786
+ * labels ran into each other and whose longest wrapped mid-word. An
787
+ * emphasis that costs legibility is not emphasis, it is damage.
788
+ *
789
+ * The overflow is not demoted — it keeps `rank: "secondary"`, so it is
790
+ * still drawn smaller and further back in its own slot. The ranking
791
+ * survives; only the pile does not. Which two nest is decided by the same
792
+ * stable sort everything else here uses, so this stays a pure function of
793
+ * the view.
794
+ */
795
+ const FANNED_PER_PARENT = 2;
796
+ /*
797
+ * AND ONLY WHEN THE ROW NEEDS THE ROOM. A tuck saves a slot; where the row
798
+ * holds every kind in a slot of its own there is no slot to save, and the
799
+ * tuck was a pile — Eras and Themes stacked on the Songs card of a shelf
800
+ * with room to spare, each label half under the card in front. A
801
+ * secondary kind keeps its rank there, drawn smaller and further back in
802
+ * its own slot.
803
+ */
804
+ const rowHolds = contextItems.length <= districtCapacity((zoomed ? 240 : opts.contextSize.width) * unit);
805
+ if (rowHolds)
806
+ for (const item of contextItems)
807
+ delete item.nestedUnder;
808
+ const fannedSoFar = new Map();
809
+ for (const item of contextItems) {
810
+ if (item.nestedUnder === undefined)
811
+ continue;
812
+ const already = fannedSoFar.get(item.nestedUnder) ?? 0;
813
+ if (already >= FANNED_PER_PARENT)
814
+ delete item.nestedUnder;
815
+ else
816
+ fannedSoFar.set(item.nestedUnder, already + 1);
817
+ }
818
+ const hanging = contextItems.filter((item) => item.nestedUnder !== undefined);
819
+ const inRow = contextItems.filter((item) => item.nestedUnder === undefined);
820
+ /*
821
+ * PAST WHAT THE ROW CAN READ, THE ROW SHEDS.
822
+ *
823
+ * With every card floored at a legible width, a domain with more kinds
824
+ * than the host is wide would run its districts off the edge — so the row
825
+ * holds as many as fit and hands the rest to one card that names them.
826
+ * That card is a district-shaped thing standing for several kinds, and
827
+ * pressing one of the names it lists goes to that district: no new
828
+ * vocabulary, the same gesture the row itself offers.
829
+ *
830
+ * Not at altitude: the ring lays every kind out around the ellipse and has
831
+ * the room, which is the whole reason to rise to it.
832
+ */
833
+ const rowCap = districtCapacity((zoomed ? 240 : opts.contextSize.width) * unit);
834
+ const sheds = !state.overview && inRow.length > rowCap;
835
+ /*
836
+ * The card that names the rest TAKES A SLOT, so the row keeps room for it
837
+ * — and where there is room for only one card, that one card is it. At
838
+ * the reader's largest text on a phone that is the honest answer: one
839
+ * legible list of every district, rather than two cards painting off the
840
+ * edge of the screen.
841
+ */
842
+ const keep = Math.max(0, rowCap - 1);
843
+ /*
844
+ * A KIND TUCKED BEHIND A KIND THE ROW SHED GOES WITH IT. Its parent has no
845
+ * slot to hang off, and the fallback drew it at the end of the row — at
846
+ * exactly the "+3 more" card's place, under it: a dealership's Deals and
847
+ * Service appointments were two cards stacked behind "+3 more", reachable
848
+ * neither by pointer nor from the list, which named only the three.
849
+ */
850
+ const shedIds = new Set(sheds ? inRow.slice(keep).map((item) => item.id) : []);
851
+ const tucked = hanging.filter((item) => !shedIds.has(item.nestedUnder));
852
+ const shedWithParent = hanging.filter((item) => shedIds.has(item.nestedUnder));
853
+ const slotted = sheds
854
+ ? [
855
+ ...inRow.slice(0, keep),
856
+ { id: BEYOND_CARD, kind: "", beyond: [...inRow.slice(keep), ...shedWithParent].map((item) => item.kind) },
857
+ ]
858
+ : inRow;
859
+ /*
860
+ * The overview is the SAME CARDS, on a ring instead of a row.
861
+ *
862
+ * Not a different surface. The kinds plane is already a constant map of
863
+ * every kind, so rising to the overview only has to move those cards —
864
+ * which means `interpolate` tweens them from the strip out into the ellipse
865
+ * for free, and what you were looking at recedes into the middle rather
866
+ * than being replaced by a picture of something else.
867
+ *
868
+ * The ellipse is a circle under a vertical squash, which is affine, so this
869
+ * is the same class of transform the plane model already uses.
870
+ */
871
+ const contextSize = state.overview
872
+ ? // Squat cards: a kind card holds a name, a count and a bar, and a tall
873
+ // one from altitude was mostly empty tint — a sticky note, not a
874
+ // building face.
875
+ // The base a district card has always been. The ring applies the
876
+ // reader's unit itself, because only the ring knows how much room is
877
+ // left to grow into.
878
+ { width: Math.min(220, spanW / 6.5), height: Math.min(92, opts.height * 0.125) }
879
+ : districtRow(slotted.length, (zoomed ? 240 : opts.contextSize.width) * unit, band.contextH);
880
+ /*
881
+ * THE CITY. From altitude the kinds are districts on the declaration's
882
+ * own map — a plot each, on the 2:1 lattice, one uniform scale and
883
+ * translate — so the picture has the same shape at every width and a
884
+ * district is on the corner a person remembers. `placeCity` keeps the
885
+ * collision shrink as a safety net; the placement is the map's.
886
+ */
887
+ const city = state.overview
888
+ ? placeCity(slotted.map((item) => ({
889
+ id: item.id,
890
+ kind: item.kind,
891
+ count: item.aggregate?.memberIds.length ?? 0,
892
+ // An opened district lays its members out inside its plot: room
893
+ // for a small grid, in the reader's unit.
894
+ opened: expanded.has(item.id) ? rosterHeight(rosterRows(item.aggregate?.memberIds.length ?? 0)) * unit : 0,
895
+ openedMin: expanded.has(item.id) ? rosterHeight(Math.min(ROSTER_KEPT, rosterRows(item.aggregate?.memberIds.length ?? 0))) * unit : 0,
896
+ // A district with showings carries their marquee under its name:
897
+ // its buttons, wrapped to the card, in the reader's unit.
898
+ ...((options.screens?.[item.kind]?.length ?? 0) > 0
899
+ ? { marquee: marqueeHeightFor((options.screens?.[item.kind] ?? []).map((place) => place.title), DISTRICT_MIN_WIDTH * unit) * unit }
900
+ : {}),
901
+ })), schema, contextSize, { width: opts.width, height: opts.height }, opts.inset ?? {}, {
902
+ ...(opts.cityOrder ? { order: opts.cityOrder } : {}),
903
+ ...(opts.plots ? { plots: opts.plots } : {}),
904
+ scale: unit,
905
+ minHeight: CARD_MIN_HEIGHT * unit,
906
+ ...(options.cityZoom && options.cityZoom !== 1 ? { zoom: options.cityZoom } : {}),
907
+ // Recorded, not applied: the cards already carry it, and the ground reads it to ride the same tween.
908
+ pan: { x: state.pan?.x ?? 0, y: state.pan?.y ?? 0 },
909
+ ...(stamp && !driveIn ? { avoid: [stamp] } : {}),
910
+ })
911
+ : null;
912
+ const plotOf = new Map(city?.placed.map((card) => [card.id, card.plot]) ?? []);
913
+ const contextPositions = city
914
+ ? city.placed.map(({ x, y, depth, width, height }) => ({ x, y, depth, width, height }))
915
+ : shelf(slotted.length, contextSize, opts.gap, spanW, band.contextY,
916
+ // Zoomed in, the shelf recedes further — present, quieter.
917
+ zoomed ? 0.97 : SHELF_DEPTH).map((position) => ({ ...position, x: position.x + railLeft }));
918
+ /*
919
+ * A secondary kind is drawn SMALLER and further back inside its own slot,
920
+ * sitting on the same baseline as its neighbours. Same row, same order,
921
+ * different weight — the eye reads the primaries first without anything
922
+ * having moved.
923
+ */
924
+ const SECONDARY = 0.74;
925
+ /*
926
+ * A tucked card still has to hold a name and a count. Half was sized
927
+ * against a context card twice as tall and clipped the moment the band
928
+ * became a strip of glyphs. It does not go much beyond this: at nearly the
929
+ * parent's size the two cards coincide, which reads as a rendering fault
930
+ * and puts the tucked one out of reach of a click.
931
+ */
932
+ const NESTED = 0.8;
933
+ /*
934
+ * A kind card holds a name and a count, and that is a fixed number of
935
+ * pixels. Every shrink here is proportional — secondary at 0.74, tucked at
936
+ * 0.8 of that, and tucked again by how many share a parent — so at a
937
+ * glyph-sized band the multiplications land under the content and the card
938
+ * clips. Proportion is right until it crosses the floor.
939
+ */
940
+ const TUCK_MIN_HEIGHT = 52 * unit;
941
+ const TUCK_MIN_WIDTH = 86 * unit;
942
+ /** Further back within the plane. 1 is the plane's own depth. */
943
+ const recede = (depth, by) => Math.min(1, depth + (1 - depth) * by);
944
+ const slotOf = new Map();
945
+ slotted.forEach((item, index) => {
946
+ const position = contextPositions[index];
947
+ // A ring stop carries its own size — nearness grows it. A shelf slot is
948
+ // uniform, and rank shrinks into it.
949
+ const slotW = position.width ?? contextSize.width;
950
+ const slotH = position.height ?? contextSize.height;
951
+ const shrink = item.rank === "secondary" ? SECONDARY : 1;
952
+ /*
953
+ * A SECONDARY DISTRICT IS QUIETER, NOT ILLEGIBLE.
954
+ *
955
+ * The height has had a floor since the band became a strip of glyphs;
956
+ * the width had none, so a rank shrink applied after the row's own
957
+ * sizing took a card that had just been floored at a readable width and
958
+ * put it back under it — 0.74 of 132 is 98, which is where a plural
959
+ * starts wrapping. The same mistake as the row's, one multiplication
960
+ * later. Proportion is right until it crosses the floor.
961
+ */
962
+ const width = Math.max(Math.min(slotW, DISTRICT_MIN_WIDTH * unit), slotW * shrink);
963
+ const height = Math.max(CARD_MIN_HEIGHT * unit, slotH * shrink);
964
+ slotOf.set(item.id, {
965
+ // Centred across the slot it was allotted, sitting on its baseline.
966
+ x: position.x + (slotW - width) / 2,
967
+ y: position.y + (slotH - height),
968
+ depth: item.rank === "secondary" ? recede(position.depth, 0.5) : position.depth,
969
+ width,
970
+ height,
971
+ });
972
+ });
973
+ const placedContext = [];
974
+ const lastSlot = contextPositions[contextPositions.length - 1];
975
+ /*
976
+ * Several kinds can hang off the same one, and stacking them at one point
977
+ * would draw a single card with two others hidden underneath it. They climb
978
+ * off their parent's top-right corner instead — a small pile, each one
979
+ * still a target, each one further back than the last.
980
+ */
981
+ const siblings = new Map();
982
+ for (const item of tucked) {
983
+ siblings.set(item.nestedUnder, (siblings.get(item.nestedUnder) ?? 0) + 1);
984
+ }
985
+ const seen = new Map();
986
+ for (const item of tucked) {
987
+ const parent = slotOf.get(item.nestedUnder);
988
+ if (parent) {
989
+ const count = siblings.get(item.nestedUnder) ?? 1;
990
+ const index = seen.get(item.nestedUnder) ?? 0;
991
+ seen.set(item.nestedUnder, index + 1);
992
+ /*
993
+ * PEEKING OVER the parent's top edge, from behind.
994
+ *
995
+ * Behind means further, and further means higher on screen — the same
996
+ * reading the whole depth model uses — so a kind reached through
997
+ * another stands behind it the way a building stands behind the one in
998
+ * front. The room above exists now that the focus stops clear of the
999
+ * shelf instead of reaching down over an arc.
1000
+ *
1001
+ * Below the parent was the old place, and it broke clicking: hovering
1002
+ * the parent grows it downward to reveal its note, which covered the
1003
+ * tuck under the pointer — the card you were reaching for disappeared
1004
+ * under the one it hangs off, and stayed covered while the pointer was
1005
+ * on it.
1006
+ *
1007
+ * Overlap is capped at just under half a card, because a card whose
1008
+ * MIDDLE is covered cannot be clicked — the point at the centre belongs
1009
+ * to whatever is drawn over it. Where several share a parent they
1010
+ * shrink to fit rather than piling up.
1011
+ */
1012
+ /*
1013
+ * Offset enough to read as a pile, not enough to hide a label.
1014
+ *
1015
+ * At 0.55 each card covered forty-five per cent of the one to its left
1016
+ * — and a kind card's label sits along its top edge, so the left card
1017
+ * of every pair read as half a word running into the next: "FIXTURES"
1018
+ * and "PLAYERS" drawn as "FIXTURESPLAYERS". The offset is a depth cue;
1019
+ * it does not have to cost the thing it is a cue about.
1020
+ */
1021
+ /*
1022
+ * AND THE STEP HAS TO CLEAR THE PLANE'S OWN SHRINK.
1023
+ *
1024
+ * A fan is laid out in layout units and DRAWN at the kinds plane's
1025
+ * scale, about each card's centre — so the drawn gap between two
1026
+ * tucks is `step − scale` of a card. At 0.86 against a plane drawn at
1027
+ * 0.78 that was eight per cent of air; drawn at 0.9 so the words can
1028
+ * be read, the same 0.86 became six pixels of one card sitting on the
1029
+ * next, and a pile that covers its neighbour's label is the thing
1030
+ * this number was lowered to stop.
1031
+ */
1032
+ const step = 0.98;
1033
+ const roomy = contextSize.width * NESTED;
1034
+ /*
1035
+ * Never smaller than the card's own content.
1036
+ *
1037
+ * The height followed the width so a fan of two shrank both, and at a
1038
+ * glyph-sized band that landed exactly on the height of a name plus a
1039
+ * count — so every tucked card in a pair clipped by four or five
1040
+ * pixels. A proportional rule is right until it crosses the floor;
1041
+ * below that the card is not smaller, it is broken.
1042
+ */
1043
+ /*
1044
+ * The whole fan fits the parent's slot plus its gap, never more.
1045
+ *
1046
+ * At 1.02 of the parent's width the fan of two spread to 1.86 widths,
1047
+ * centred — so it spilled almost half a card into the slot on either
1048
+ * side, and "PLAYERS" ran into "UNAVAILABILITY" while every automated
1049
+ * check counted the pile as deliberate. A tuck that leaves its
1050
+ * parent's ground is not tucked behind anything.
1051
+ */
1052
+ /*
1053
+ * The parent's own ground, and not the air beside it.
1054
+ *
1055
+ * This allowed the fan `parent.width + gap`, and the gap is not spare
1056
+ * room — it is the space that keeps one district off the next. With
1057
+ * the kinds plane drawn at 78% the spill fitted anyway; drawn at 90%
1058
+ * so its words can be read, the outermost tuck reached into the
1059
+ * neighbour and the audit counted the two as one pile. A tuck that
1060
+ * leaves its parent's ground is not tucked behind anything.
1061
+ */
1062
+ const width = Math.max(TUCK_MIN_WIDTH, Math.min(roomy, parent.width / (step * (count - 1) + 1)));
1063
+ const height = Math.max(TUCK_MIN_HEIGHT, contextSize.height * NESTED * (width / roomy));
1064
+ const spread = width * step;
1065
+ const fan = (count - 1) * spread + width;
1066
+ placedContext.push({
1067
+ ...item,
1068
+ x: parent.x + (parent.width - fan) / 2 + index * spread,
1069
+ /*
1070
+ * A FIXED bite behind the parent, not half the tuck.
1071
+ *
1072
+ * Sixteen pixels of the tuck's bottom sit behind the parent's top
1073
+ * edge — enough to read as "behind that one" at any card size,
1074
+ * little enough that the tuck's own label and its centre stay
1075
+ * clickable above the edge. The parent paints over the overlap, so
1076
+ * its name and its trouble bar are never covered.
1077
+ *
1078
+ * Never above the canvas: a card pushed off the top of the screen is
1079
+ * not tucked, it is gone.
1080
+ */
1081
+ y: Math.max(0, parent.y - height + Math.min(height * 0.5, 16)),
1082
+ width,
1083
+ height,
1084
+ depth: recede(parent.depth, 0.8),
1085
+ });
1086
+ }
1087
+ else if (lastSlot) {
1088
+ const width = contextSize.width * NESTED;
1089
+ const height = contextSize.height * NESTED;
1090
+ // A parent that never got a slot leaves nothing to hang off. The card
1091
+ // is still drawn, at the end of the row, rather than silently dropped.
1092
+ placedContext.push({
1093
+ ...item,
1094
+ x: lastSlot.x,
1095
+ y: lastSlot.y,
1096
+ width,
1097
+ height,
1098
+ depth: recede(lastSlot.depth ?? 1, 0.8),
1099
+ });
1100
+ }
1101
+ }
1102
+ for (const item of slotted) {
1103
+ placedContext.push({ ...item, ...slotOf.get(item.id) });
1104
+ }
1105
+ placedContext.forEach((item) => {
1106
+ push({
1107
+ id: item.id,
1108
+ kind: item.kind,
1109
+ /*
1110
+ * Plane 2 on the ring as well as in the strip.
1111
+ *
1112
+ * Not because they are peripheral — up here they are the whole subject —
1113
+ * but because plane 2 asks for GLYPH fidelity, which is the view that
1114
+ * says what a kind IS and how many there are. On plane 1 the position
1115
+ * aggregate rendered as the board lens's summary, so half the ring
1116
+ * described lenses and half described kinds.
1117
+ */
1118
+ plane: 2,
1119
+ x: item.x,
1120
+ y: item.y,
1121
+ width: item.width,
1122
+ height: item.height,
1123
+ ...(item.aggregate ? { aggregate: item.aggregate } : {}),
1124
+ ...(item.raised ? { raised: true } : {}),
1125
+ ...(item.focused ? { focused: true } : {}),
1126
+ ...(item.opened ? { opened: true } : {}),
1127
+ ...(item.opened
1128
+ ? {
1129
+ openedRows: Math.max(1,
1130
+ // The rows the room the city KEPT holds, once the chrome is paid for.
1131
+ Math.floor((Math.max(rosterHeight(Math.min(ROSTER_KEPT, rosterRows(item.aggregate?.memberIds.length ?? 0))), rosterHeight(rosterRows(item.aggregate?.memberIds.length ?? 0)) * (city?.frame.openedShare ?? 1)) -
1132
+ ROSTER_CHROME) /
1133
+ ROSTER_ROW)),
1134
+ }
1135
+ : {}),
1136
+ ...(item.rank ? { rank: item.rank } : {}),
1137
+ ...(item.nestedUnder ? { nestedUnder: item.nestedUnder } : {}),
1138
+ ...(item.beyond ? { beyond: item.beyond } : {}),
1139
+ ...(plotOf.has(item.id) ? { plot: plotOf.get(item.id) } : {}),
1140
+ depth: item.depth,
1141
+ });
1142
+ });
1143
+ /*
1144
+ * THE SCREEN STANDS ON ITS PLOT. Anchored to the plot's far edge — the
1145
+ * top vertex of its diamond — centred on the plot, sized by the plot's
1146
+ * side so a bigger neighbourhood has a bigger screen, floored so its
1147
+ * words can be read, and shrunk only as a last resort until it covers
1148
+ * no other district's nameplate. A picture over two kinds stands on the
1149
+ * road between their plots. The same natural size and shrink as before:
1150
+ * the interface, scaled, never re-laid-out small.
1151
+ */
1152
+ if (driveIn && city && stamp && state.focusId) {
1153
+ const frame = city.frame;
1154
+ const own = city.map.get(screenKind);
1155
+ const other = showing?.across ? city.map.get(showing.across) : undefined;
1156
+ if (own) {
1157
+ const cornerOf = (plot) => {
1158
+ const top = toIso(plot.col, plot.row, frame.cell);
1159
+ const centre = toIso(plot.col + plot.side / 2, plot.row + plot.side / 2, frame.cell);
1160
+ return {
1161
+ top: { x: frame.originX + top.x, y: frame.originY + top.y },
1162
+ centre: { x: frame.originX + centre.x, y: frame.originY + centre.y },
1163
+ };
1164
+ };
1165
+ const mine = cornerOf(own);
1166
+ /*
1167
+ * ON ITS OWN PLOT, always. A picture across two kinds — skills down,
1168
+ * drills across — used to stand on the road between their plots, and
1169
+ * landed on the other village with its own district's signpost and
1170
+ * board buried under it: the lens read as the drills', not the
1171
+ * skills'. Its rows are its kind; it stands at that village's back
1172
+ * kerb, and the road to the other kind is already on the ground.
1173
+ */
1174
+ void other;
1175
+ const anchorX = mine.centre.x;
1176
+ /*
1177
+ * Its foot is on the plot's far kerb — the back vertex of the diamond.
1178
+ * The nameplate no longer stands there (it is a signpost at the front
1179
+ * corner from altitude), so the screen needs no clearance above its
1180
+ * own card: it is a billboard at the back of the village.
1181
+ */
1182
+ const anchorBottom = mine.top.y + frame.cell * 0.1;
1183
+ /*
1184
+ * Cut to the picture. The lens lays itself out in a box as tall as the
1185
+ * window (`naturalH`) so nothing in it ever scrolls; the billboard
1186
+ * shows only as much of that box as the lens actually drew, when the
1187
+ * scene has measured it. Never taller than the box — a lens cannot
1188
+ * draw past it.
1189
+ */
1190
+ const drawnH = opts.screenHeight !== undefined && opts.screenHeight > 0
1191
+ ? Math.min(naturalH, Math.max(SCREEN_MIN_NATURAL_HEIGHT, Math.round(opts.screenHeight)))
1192
+ : naturalH;
1193
+ const aspect = drawnH / naturalW;
1194
+ /*
1195
+ * THE BILLBOARD IS THE POINT OF FLYING CLOSER. Chosen from altitude,
1196
+ * a lens is what the reader came to see, and it was capped at half
1197
+ * the span and under half the height — a window into the picture
1198
+ * rather than the picture — and the caps were the window's, so
1199
+ * zooming grew the city under it and never the board. It may take
1200
+ * most of the span now, and the caps grow with the zoom past the
1201
+ * "closer" the choice already brought, so zooming in enlarges the
1202
+ * board the way it enlarges everything else.
1203
+ */
1204
+ const grown = Math.max(1, (options.cityZoom ?? 1) / 1.5);
1205
+ const roomW = spanW * 0.78 * grown;
1206
+ const roomH = opts.height * 0.72 * grown;
1207
+ const floor = Math.min(DRIVE_IN_MIN_WIDTH * unit, spanW * 0.5);
1208
+ let width = Math.max(floor, Math.min(roomW, own.side * frame.cell * 2.4));
1209
+ if (width * aspect > roomH)
1210
+ width = roomH / aspect;
1211
+ const others = [...placed.values()].filter((node) => node.plane === 2 && node.id !== kindCardId(screenKind));
1212
+ const boxAt = (w) => {
1213
+ const h = w * aspect;
1214
+ // Its foot on the kerb, wherever that is: a billboard held inside the
1215
+ // window's top slid down over its own village; the camera brings it in.
1216
+ const y = anchorBottom - h;
1217
+ return { x: anchorX - w / 2, y, width: w, height: h };
1218
+ };
1219
+ let box = boxAt(width);
1220
+ /*
1221
+ * Another district's NAMEPLATE is wider than its box and floats above
1222
+ * it — a label overflows its building the way a map label does — so
1223
+ * the box is inflated by what the plate can reach before the screen
1224
+ * is judged against it.
1225
+ */
1226
+ /*
1227
+ * The plate is a SIGNPOST at the plot's front corner now, so the reach
1228
+ * to guard is below the card's foot, not above its top — and a
1229
+ * district's own village fills its box, so the box itself counts.
1230
+ */
1231
+ const covers = (b) => others.some((card) => {
1232
+ const unpanned = {
1233
+ x: card.x - (state.pan?.x ?? 0) - PLATE_REACH * unit,
1234
+ y: card.y - (state.pan?.y ?? 0),
1235
+ width: card.width + PLATE_REACH * unit * 2,
1236
+ height: card.height + PLATE_CLEARANCE * unit,
1237
+ };
1238
+ return b.x < unpanned.x + unpanned.width && unpanned.x < b.x + b.width && b.y < unpanned.y + unpanned.height && unpanned.y < b.y + b.height;
1239
+ });
1240
+ for (let w = width; covers(box) && w > floor + 1; w = Math.max(floor, w * 0.92))
1241
+ box = boxAt(w);
1242
+ const at = nodes.findIndex((node) => node.id === state.focusId);
1243
+ if (at !== -1) {
1244
+ /*
1245
+ * A BILLBOARD CAN BE MOVED, ON A LEASH.
1246
+ *
1247
+ * Its home is the back kerb of its own plot, which is where it
1248
+ * belongs: a picture of a kind, standing on that kind's land. But a
1249
+ * board planted to the millimetre is furniture, and a person wants
1250
+ * to nudge it off whatever it is covering.
1251
+ *
1252
+ * So a pin moves it, and the leash is what keeps it a picture OF
1253
+ * this village rather than a sheet floating over the city. Measured
1254
+ * in the city's own cells, so the same pin holds at every zoom —
1255
+ * pin it two cells north and it is two cells north whether you are
1256
+ * flown close or looking down on the whole map.
1257
+ *
1258
+ * Clamped HERE rather than where the drag is made, because a pin
1259
+ * arrives from a link as readily as from a hand, and a leash that
1260
+ * only the hand respects is not a leash.
1261
+ */
1262
+ const home = { x: box.x, y: box.y };
1263
+ const held = state.pins[nodes[at].id];
1264
+ const wandered = held ? { x: held.x - home.x, y: held.y - home.y } : { x: 0, y: 0 };
1265
+ const reach = Math.hypot(wandered.x, wandered.y);
1266
+ const leash = SCREEN_LEASH_CELLS * frame.cell;
1267
+ const pulled = reach > leash ? leash / reach : 1;
1268
+ const stand = {
1269
+ x: home.x + wandered.x * pulled,
1270
+ y: home.y + wandered.y * pulled,
1271
+ };
1272
+ const raised = {
1273
+ ...nodes[at],
1274
+ x: stand.x + (state.pan?.x ?? 0),
1275
+ y: stand.y + (state.pan?.y ?? 0),
1276
+ width: box.width,
1277
+ height: box.height,
1278
+ natural: { width: naturalW, height: drawnH },
1279
+ screenOf: screenKind,
1280
+ ...(held ? { pinned: true } : {}),
1281
+ };
1282
+ nodes[at] = raised;
1283
+ placed.set(raised.id, raised);
1284
+ }
1285
+ }
1286
+ }
1287
+ return {
1288
+ nodes,
1289
+ connectors: connectorsFor(graph, placed, state),
1290
+ width: opts.width,
1291
+ height: opts.height,
1292
+ ...(city ? { city: city.frame } : {}),
1293
+ };
1294
+ function push(node) {
1295
+ // A user pin overrides the computed position and survives graph changes
1296
+ // underneath: the layout keeps recomputing, the pin keeps winning.
1297
+ const pin = state.pins[node.id];
1298
+ const placedNode = pin
1299
+ ? { ...node, x: pin.x, y: pin.y, pinned: true }
1300
+ : { ...node, pinned: false };
1301
+ /*
1302
+ * The camera moves LAST, and moves everything.
1303
+ *
1304
+ * Applied here rather than as a transform on the stage, so it is part of
1305
+ * the one function that decides where things are: connectors are drawn
1306
+ * from these coordinates, the frame planner reads them, and both
1307
+ * renderers get panning without either of them learning about it. A pin
1308
+ * is stored unpanned for the same reason — pan the camera back and the
1309
+ * card is where you left it, relative to everything else.
1310
+ */
1311
+ const final = state.pan
1312
+ ? { ...placedNode, x: placedNode.x + state.pan.x, y: placedNode.y + state.pan.y }
1313
+ : placedNode;
1314
+ nodes.push(final);
1315
+ placed.set(final.id, final);
1316
+ }
1317
+ }
1318
+ /** Which plane a node ended up on, or null if it is not in the layout. */
1319
+ export function planeOf(result, id) {
1320
+ return result.nodes.find((node) => node.id === id)?.plane ?? null;
1321
+ }
1322
+ /**
1323
+ * THE PAN, APPLIED AFTER THE FACT — the same answer, without the arithmetic.
1324
+ *
1325
+ * The pan is baked into every card's coordinates at layout time, and that is
1326
+ * right: it is what lets two layouts be interpolated into motion, and it is
1327
+ * why connectors, the frame planner and both renderers get panning without
1328
+ * any of them learning about it.
1329
+ *
1330
+ * But it is applied at the END of a function that has already decided
1331
+ * everything else — communities, plots, band packing, the drive-in's own
1332
+ * sizing loop — and a drag changes nothing but the pan. Running all of that
1333
+ * again per pointer move cost rota's city 169 dropped frames and ten
1334
+ * seconds of blocked main thread in a two-second drag.
1335
+ *
1336
+ * So the pan comes out: lay the world out once at rest, and translate it.
1337
+ * The result is the same object `layout` would have returned — held to that
1338
+ * by `the-pan-is-a-translation` — and the caller can hold the expensive half
1339
+ * still while a hand is moving.
1340
+ *
1341
+ * The city's own origin and extent are unpanned by definition (the ground
1342
+ * rides `city.pan`), so only the cards, the lines between them and the
1343
+ * recorded pan move.
1344
+ */
1345
+ /**
1346
+ * A CARD UNDER THE HAND, moved without laying the world out again.
1347
+ *
1348
+ * A pin only overrides where one card is; everything else about the stop —
1349
+ * the band's grouping, the bundles, the districts — is what it was. Laying
1350
+ * it all out again on every pointer move put a full `layout()` and a full
1351
+ * bundle of every edge in every frame of a drag (docs/scale.md). This moves
1352
+ * the held card to the pin (unpanned, as a pin is stored) and re-aims the
1353
+ * lines that touch it. `null` when it cannot say the same thing a full
1354
+ * layout would — a drive-in's screen, whose pin places it differently —
1355
+ * and the caller lays it out with the pin instead. On release the pin goes
1356
+ * into the view and the full layout runs once.
1357
+ */
1358
+ export function holdLayout(placed, id, at) {
1359
+ const held = placed.nodes.find((node) => node.id === id);
1360
+ if (!held || held.screenOf !== undefined)
1361
+ return null;
1362
+ const moved = { ...held, x: at.x, y: at.y, pinned: true };
1363
+ const cx = moved.x + moved.width / 2;
1364
+ const cy = moved.y + moved.height / 2;
1365
+ return {
1366
+ ...placed,
1367
+ nodes: placed.nodes.map((node) => (node.id === id ? moved : node)),
1368
+ connectors: placed.connectors.map((connector) => connector.from === id
1369
+ ? { ...connector, x1: cx, y1: cy }
1370
+ : connector.to === id
1371
+ ? { ...connector, x2: cx, y2: cy }
1372
+ : connector),
1373
+ };
1374
+ }
1375
+ export function panLayout(placed, pan) {
1376
+ if (pan.x === 0 && pan.y === 0)
1377
+ return placed;
1378
+ return {
1379
+ ...placed,
1380
+ nodes: placed.nodes.map((node) => ({ ...node, x: node.x + pan.x, y: node.y + pan.y })),
1381
+ connectors: placed.connectors.map((connector) => ({
1382
+ ...connector,
1383
+ x1: connector.x1 + pan.x,
1384
+ y1: connector.y1 + pan.y,
1385
+ x2: connector.x2 + pan.x,
1386
+ y2: connector.y2 + pan.y,
1387
+ })),
1388
+ ...(placed.city ? { city: { ...placed.city, pan: { x: pan.x, y: pan.y } } } : {}),
1389
+ };
1390
+ }
1391
+ //# sourceMappingURL=layout.js.map