@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.
- package/LICENSE +96 -0
- package/README.md +20 -0
- package/dist/band.d.ts +103 -0
- package/dist/band.d.ts.map +1 -0
- package/dist/band.js +344 -0
- package/dist/band.js.map +1 -0
- package/dist/city.d.ts +161 -0
- package/dist/city.d.ts.map +1 -0
- package/dist/city.js +319 -0
- package/dist/city.js.map +1 -0
- package/dist/ids.d.ts +79 -0
- package/dist/ids.d.ts.map +1 -0
- package/dist/ids.js +115 -0
- package/dist/ids.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/interpolate.d.ts +27 -0
- package/dist/interpolate.d.ts.map +1 -0
- package/dist/interpolate.js +217 -0
- package/dist/interpolate.js.map +1 -0
- package/dist/label-fit.d.ts +115 -0
- package/dist/label-fit.d.ts.map +1 -0
- package/dist/label-fit.js +162 -0
- package/dist/label-fit.js.map +1 -0
- package/dist/layout.d.ts +99 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +1391 -0
- package/dist/layout.js.map +1 -0
- package/dist/rank.d.ts +35 -0
- package/dist/rank.d.ts.map +1 -0
- package/dist/rank.js +84 -0
- package/dist/rank.js.map +1 -0
- package/dist/related.d.ts +42 -0
- package/dist/related.d.ts.map +1 -0
- package/dist/related.js +207 -0
- package/dist/related.js.map +1 -0
- package/dist/types.d.ts +404 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +27 -0
- package/dist/types.js.map +1 -0
- package/dist/view-state.d.ts +189 -0
- package/dist/view-state.d.ts.map +1 -0
- package/dist/view-state.js +350 -0
- package/dist/view-state.js.map +1 -0
- 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
|