@graview/layout 0.1.28 → 0.1.29

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/dist/city.js CHANGED
@@ -1,252 +1,367 @@
1
1
  import {} from "@graview/core";
2
- import { cityExtent, cityMap, toIso } from "@graview/core/scene";
2
+ import { cityMap, PITCH, toIso } from "@graview/core/city";
3
+ /*
4
+ * THE NAME UNDER A DISTRICT from altitude, as the scene draws it: its
5
+ * plural in 12-pixel capitals, its count, its problems when it has any,
6
+ * and under them the bar of its states when its kind has a status — on a
7
+ * solid backing, a fixed size on the screen whatever the zoom. Said short,
8
+ * it is those three without the bar. In the reader's unit.
9
+ */
10
+ export function nameplateSize(name, count, problems, states, unit = 1) {
11
+ const digits = (n) => String(n).length * 7.4;
12
+ const line = name.length * 8.6 + 6 + digits(count) + 14 + (problems > 0 ? 8 + 14 + digits(problems) : 0);
13
+ return {
14
+ width: Math.max(line, states ? 168 + 16 : 0) * unit,
15
+ height: (states ? 38 : 22) * unit,
16
+ short: { width: line * unit, height: 22 * unit },
17
+ };
18
+ }
3
19
  /** Two drawn boxes sharing ground, with a little air required between them. */
4
20
  export function collides(a, b, air = 6) {
5
- return (a.x < b.x + b.width + air &&
6
- b.x < a.x + a.width + air &&
7
- a.y < b.y + b.height + air &&
8
- b.y < a.y + a.height + air);
21
+ return a.x < b.x + b.width + air && b.x < a.x + a.width + air && a.y < b.y + b.height + air && b.y < a.y + a.height + air;
9
22
  }
10
- /** The center of a plot's diamond, in lattice pixels at `cell`. */
11
- const centerOf = (plot, cell) => toIso(plot.col + plot.side / 2, plot.row + plot.side / 2, cell);
12
- export function placeCity(cards, schema, base, canvas, inset = {}, options = {}) {
23
+ /** Whether a box reaches into a plot's diamond: the nearest point of the box to its center, inside the diamond. */
24
+ const onDiamond = (box, center, halfW, halfH) => {
25
+ const px = Math.max(box.x, Math.min(center.x, box.x + box.width));
26
+ const py = Math.max(box.y, Math.min(center.y, box.y + box.height));
27
+ return Math.abs(px - center.x) / halfW + Math.abs(py - center.y) / halfH < 0.92;
28
+ };
29
+ /** The gap between a plot's front corner and the top of the name under it. */
30
+ const UNDER = 4;
31
+ /** How far the tallest block on a plot stands over its back corner, in cells: a name over the plot stands clear of it. */
32
+ const RISE = 0.45;
33
+ export function placeCity(cards, schema, canvas, inset = {}, options = {}) {
13
34
  const counts = {};
14
35
  for (const card of cards)
15
36
  counts[card.kind] = card.count;
16
- const map = cityMap(schema, {
17
- ...(options.order ? { order: options.order } : {}),
18
- counts,
19
- ...(options.plots ? { plots: options.plots } : {}),
37
+ const room = {
38
+ x: (inset.left ?? 0) + 8,
39
+ y: (inset.top ?? 0) + 8,
40
+ width: canvas.width - (inset.left ?? 0) - (inset.right ?? 0) - 16,
41
+ height: canvas.height - (inset.top ?? 0) - (inset.bottom ?? 0) - 16,
42
+ };
43
+ const avoid = options.avoid ?? [];
44
+ const tilt = options.tilt ?? 0.5;
45
+ const plate = (card) => card.nameplate ?? { width: 120, height: 34, short: { width: 90, height: 20 } };
46
+ /** Every plot's center in lattice pixels at one pixel a cell, and its side. */
47
+ const shapeOf = (map) => cards.map((card) => {
48
+ const plot = map.get(card.kind);
49
+ return { card, plot, center: toIso(plot.col + plot.side / 2, plot.row + plot.side / 2, 1, tilt) };
20
50
  });
21
- const left = inset.left ?? 0;
22
- const span = canvas.width - left - (inset.right ?? 0);
23
- const cx = left + span / 2;
24
- const cy = canvas.height * 0.53;
51
+ /** The extent of the drawn city at `cell`, names and all, about the lattice's origin. */
52
+ const extentAt = (shape, cell) => {
53
+ let minX = Infinity;
54
+ let maxX = -Infinity;
55
+ let minY = Infinity;
56
+ let maxY = -Infinity;
57
+ for (const { card, plot, center } of shape) {
58
+ const x = center.x * cell;
59
+ const y = center.y * cell;
60
+ const half = (plot.side * cell) / 2;
61
+ const name = card.opened > 0 ? plate(card).width : plate(card).short.width;
62
+ // A district too full for its rows says `+n` off its left edge: room for it, so the count is never cut by the window.
63
+ minX = Math.min(minX, x - Math.max(half + (card.count > 12 ? 30 : 0), name / 2));
64
+ maxX = Math.max(maxX, x + Math.max(half, name / 2));
65
+ // The tallest block stands about a cell above the plot's far corner; a name is always under its plot.
66
+ minY = Math.min(minY, y - half * tilt - cell * RISE);
67
+ maxY = Math.max(maxY, y + half * tilt + UNDER + plate(card).short.height);
68
+ }
69
+ return { minX, maxX, minY, maxY };
70
+ };
71
+ /** The largest cell at which the city fits the room. */
72
+ const fit = (shape) => {
73
+ let lo = 4;
74
+ let hi = 160;
75
+ for (let i = 0; i < 24; i++) {
76
+ const mid = (lo + hi) / 2;
77
+ const e = extentAt(shape, mid);
78
+ if (e.maxX - e.minX <= room.width && e.maxY - e.minY <= room.height)
79
+ lo = mid;
80
+ else
81
+ hi = mid;
82
+ }
83
+ return lo;
84
+ };
25
85
  /*
26
- * ONE SCALE. The map's bounding diamond, at one pixel per cell, is
27
- * fitted into the room the canvas has once a card's own size is taken
28
- * off each edge — a card is centered on its plot and must not leave the
29
- * canvas. The cell is then the same on both axes, which is the whole
30
- * point: a city that was squashed to fit a wide window and stretched to
31
- * fit a tall one had no shape of its own.
86
+ * WHICH WAY UP. The map runs up and to the right in a wide window and
87
+ * down in a tall one: whichever stands the city larger, wide when the
88
+ * two are equal — the way a thumbnail, with no window, draws it.
32
89
  */
33
- const extent = cityExtent(map);
34
- const corners = [
35
- toIso(extent.minCol, extent.minRow, 1),
36
- toIso(extent.maxCol, extent.minRow, 1),
37
- toIso(extent.minCol, extent.maxRow, 1),
38
- toIso(extent.maxCol, extent.maxRow, 1),
39
- ];
40
- const minX = Math.min(...corners.map((c) => c.x));
41
- const maxX = Math.max(...corners.map((c) => c.x));
42
- const minY = Math.min(...corners.map((c) => c.y));
43
- const maxY = Math.max(...corners.map((c) => c.y));
44
- const spread = { x: Math.max(1, maxX - minX), y: Math.max(0.5, maxY - minY) };
45
- const roomX = Math.max(40, span - base.width * 1.1);
46
- const roomY = Math.max(24, canvas.height * 0.62 - base.height * 1.1);
47
- const fitted = Math.max(18, Math.min(160, roomX / spread.x, roomY / spread.y));
48
- const scale = options.scale ?? 1;
49
- const minHeight = options.minHeight ?? 0;
50
- const avoid = options.avoid ?? [];
51
- /** Where the whole city is slid to stand beside the rails rather than under them (see below). */
52
- let nudge = 0;
53
- const at = (cell, asked, openedShare = 1, shift = { x: 0, y: 0 }) => {
54
- const originX = cx - ((minX + maxX) / 2) * cell + shift.x + nudge;
55
- const originY = cy - ((minY + maxY) / 2) * cell + shift.y;
56
- const size = { width: base.width * asked, height: base.height * asked };
57
- const rows = cards.map((card) => centerOf(map.get(card.kind), cell).y);
90
+ const mapsAt = (pitch) => {
91
+ const hints = { ...(options.order ? { order: options.order } : {}), counts, ...(options.plots ? { plots: options.plots } : {}), pitch };
92
+ const wideMap = cityMap(schema, hints);
93
+ const tallMap = cityMap(schema, { ...hints, upright: true });
94
+ const wide = fit(shapeOf(wideMap));
95
+ const tall = fit(shapeOf(tallMap));
96
+ const map = canvas.height > canvas.width && tall >= wide ? tallMap : wideMap;
97
+ return { map, fitted: map === tallMap ? tall : wide };
98
+ };
99
+ let shape = [];
100
+ let map = new Map();
101
+ let fitted = 0;
102
+ /** The city at `cell`, its middle at the room's middle shifted by `shift`; each district's box is its plot's. */
103
+ const at = (cell, shift = { x: 0, y: 0 }) => {
104
+ const e = extentAt(shape, cell);
105
+ const originX = room.x + room.width / 2 - (e.minX + e.maxX) / 2 + shift.x;
106
+ const originY = room.y + room.height / 2 - (e.minY + e.maxY) / 2 + shift.y;
107
+ return { originX, originY, cell };
108
+ };
109
+ const draw = (where) => {
110
+ const { originX, originY, cell } = where;
111
+ const rows = shape.map(({ center }) => center.y);
58
112
  const lowest = Math.max(...rows);
59
113
  const highest = Math.min(...rows);
60
- return cards.map((card) => {
61
- const plot = map.get(card.kind);
62
- const center = centerOf(plot, cell);
63
- /*
64
- * NEARNESS FROM THE ROW. Lower on the ground is nearer the viewer,
65
- * exactly as the bottom of the ring was: 1 there, 0 at the top, and
66
- * the depth number everything else reads (`1 − near·0.65`) is
67
- * unchanged so the plane styles and the altitude opacity are too.
68
- */
114
+ return shape.map(({ card, plot, center }) => {
115
+ const x = originX + center.x * cell;
116
+ const y = originY + center.y * cell;
117
+ const halfW = (plot.side * cell) / 2;
118
+ const halfH = halfW * tilt;
69
119
  const near = lowest === highest ? 0.5 : (center.y - highest) / (lowest - highest);
70
- const grow = 0.85 + near * 0.45;
71
- const width = size.width * grow;
72
- const base = Math.max(minHeight, size.height * grow);
73
- const height = base + (card.marquee ?? 0) + (card.opened > 0 ? Math.max(card.openedMin ?? 0, card.opened * openedShare) : 0);
74
- /*
75
- * NOT HELD INSIDE THE CANVAS. The ring pushed its near card back in
76
- * from the edge; a city has a shape, and a district pushed off its
77
- * corner to fit a phone is a district on the wrong corner. A city
78
- * wider than the window is reached by panning, which the camera
79
- * bounds to the map's extent for exactly this reason.
80
- */
81
- /*
82
- * The NAMEPLATE is centered on the plot; what hangs under it — a
83
- * marquee, an opened listing — grows DOWNWARD, so the top of the card
84
- * stays where the plot's far half is, and a screen standing at the
85
- * far edge is never covered by its own district's growth.
86
- */
87
- return {
88
- id: card.id,
89
- x: originX + center.x - width / 2,
90
- y: originY + center.y - base / 2,
91
- width,
92
- height,
93
- depth: 1 - near * 0.65,
94
- plot,
95
- };
120
+ return { card, plot, x, y, halfW, halfH, depth: 1 - near * 0.65 };
96
121
  });
97
122
  };
98
- const crowded = (placed) => placed.some((one, index) => placed.some((other, index2) => index2 !== index && collides(one, other))) ||
99
- placed.some((one) => avoid.some((box) => collides(one, box)));
100
- /*
101
- * The reader's unit is given back first, down to the size a card has
102
- * always been; and when the cards still stand in each other at that
103
- * size, the CITY grows — the cell widens until every district has its
104
- * own ground — rather than the cards shrinking below what their names
105
- * need. A district's legibility is not the window's to take.
106
- */
107
123
  /*
108
- * THE CITY SLIDES ASIDE for the picture in the middle, when there is one.
109
- * A district on the map's own center stands exactly where the live view
110
- * stands, and no amount of growing moves it — scaling about the center
111
- * keeps the center where it is. So the map is tried in place first, and
112
- * then shifted: by the width of what it must clear, right, left, down,
113
- * up, and the diagonals — the first shift with every district on ground
114
- * of its own is the one kept. No picture in the middle means no shift.
124
+ * A NAME STANDS UNDER ITS OWN DISTRICT, never beside a neighbor or over
125
+ * one, where it would read as the neighbor's. Where the names under
126
+ * their districts would meet one another or another district's ground,
127
+ * the districts stand further apart — a wider street, on the same
128
+ * lattice — rather than any name moving: the narrowest spacing at which
129
+ * every name fits under its own district, whole or short.
115
130
  */
116
- const shifts = (cell) => {
117
- if (avoid.length === 0)
118
- return [{ x: 0, y: 0 }];
119
- const dx = Math.max(...avoid.map((box) => box.width)) / 2 + cell;
120
- const dy = Math.max(...avoid.map((box) => box.height)) / 2 + cell / 2;
121
- const out = [{ x: 0, y: 0 }];
122
- for (const share of [0.5, 0.75, 1, 1.25]) {
123
- const sx = dx * share;
124
- const sy = dy * share;
125
- out.push({ x: sx, y: 0 }, { x: -sx, y: 0 }, { x: 0, y: sy }, { x: 0, y: -sy }, { x: sx, y: sy }, { x: -sx, y: sy }, { x: sx, y: -sy }, { x: -sx, y: -sy });
126
- }
127
- return out;
131
+ const fitsUnder = (drawn, window = true) => {
132
+ const said = [];
133
+ // In the order the names are said below, most pressing first, and clear of the chrome as they must be.
134
+ const pressing = [...drawn].sort((a, b) => (b.card.weight ?? b.card.count) - (a.card.weight ?? a.card.count) || cards.indexOf(a.card) - cards.indexOf(b.card));
135
+ return pressing.filter((one) => [plate(one.card), plate(one.card).short].some((size) => {
136
+ const box = plateBox(one, size);
137
+ const clear = (!window || (box.x >= 0 && box.y >= 0 && box.x + box.width <= canvas.width && box.y + box.height <= canvas.height)) &&
138
+ !said.some((other) => collides(box, other, 4)) &&
139
+ !drawn.some((other) => other !== one && onDiamond(box, other, other.halfW, other.halfH)) &&
140
+ !avoid.some((other) => collides(box, other, 0));
141
+ if (clear)
142
+ said.push(box);
143
+ return clear;
144
+ })).length;
128
145
  };
129
- /** How much of the city is off the canvas: the shift that shows the most is the one kept. */
130
- const right = canvas.width - (inset.right ?? 0);
131
- const outside = (placed) => placed.reduce((sum, card) => {
132
- const visibleW = Math.max(0, Math.min(card.x + card.width, canvas.width) - Math.max(card.x, 0));
133
- const visibleH = Math.max(0, Math.min(card.y + card.height, canvas.height) - Math.max(card.y, 0));
134
- return sum + card.width * card.height - visibleW * visibleH;
135
- }, 0);
136
- const settle = (cell, asked, openedShare) => {
137
- let best;
138
- let fallback;
139
- for (const shift of shifts(cell)) {
140
- const placed = at(cell, asked, openedShare, shift);
141
- if (crowded(placed)) {
142
- fallback ??= { placed, shift };
143
- continue;
144
- }
145
- const off = outside(placed);
146
- if (!best || off < best.off - 0.5)
147
- best = { placed, shift, off };
148
- }
149
- return best ?? fallback;
150
- };
151
- let cell = fitted;
152
- let asked = scale;
153
- let openedShare = 1;
154
- let settled = settle(cell, asked, openedShare);
155
- while (crowded(settled.placed) && asked > 1.001) {
156
- asked = Math.max(1, asked - 0.05);
157
- settled = settle(cell, asked, openedShare);
158
- }
146
+ /** The showings hanging under a district's name, as wide as they are drawn and as tall, from under its whole name. */
147
+ const marqueeBox = (one) => one.card.marquee ? { x: one.x - one.card.marquee.width / 2, y: one.y + one.halfH + UNDER + plate(one.card).height, width: one.card.marquee.width, height: one.card.marquee.height } : null;
148
+ /** Whether a name would stand on another district's showings. */
149
+ const onShowings = (box, one, drawn) => drawn.some((other) => {
150
+ if (other === one)
151
+ return false;
152
+ const hung = marqueeBox(other);
153
+ return hung !== null && collides(box, hung, 4);
154
+ });
155
+ /** Where a name stands: under the plot's front corner, over its back corner and the blocks on it, or beside its right corner. */
156
+ const plateBox = (one, size, at = "under") => at === "aside"
157
+ ? { x: one.x + one.halfW + UNDER, y: one.y - size.height / 2, width: size.width, height: size.height }
158
+ : at === "above"
159
+ ? { x: one.x - size.width / 2, y: one.y - one.halfH - UNDER - RISE * where.cell - size.height, width: size.width, height: size.height }
160
+ : { x: one.x - size.width / 2, y: one.y + one.halfH + UNDER, width: size.width, height: size.height };
161
+ /** Whether a district — its ground and the least of its name — stands on something it may not. */
162
+ const blocked = (drawn) => drawn.some((one) => avoid.some((box) => onDiamond(box, one, one.halfW, one.halfH) || collides(box, plateBox(one, plate(one.card).short), 0)));
159
163
  /*
160
- * An opened district's listing is the luxury and the city is the picture:
161
- * in a short canvas the listing gives up its room before the city has to
162
- * grow past the window, exactly as the ring's listing gave up its room
163
- * before the ring gave up being a ring.
164
+ * THE CITY STEPS ASIDE for what stands in the middle, when something
165
+ * does: tried in place, then shifted by the width of what it must clear,
166
+ * right, left, down, up and the diagonals, then drawn smaller — the
167
+ * first that leaves every district its own ground, and keeps every one
168
+ * of them and its name inside the window, is kept. A city pushed off the
169
+ * edge to clear the picture is a city cropped; smaller is better.
164
170
  */
165
- while (crowded(settled.placed) && openedShare > 0.3) {
166
- openedShare = Math.max(0.3, openedShare - 0.1);
167
- settled = settle(cell, asked, openedShare);
168
- }
171
+ const inWindow = (drawn) => drawn.every((one) => {
172
+ const name = plateBox(one, plate(one.card).short);
173
+ return one.x - one.halfW >= 0 && one.x + one.halfW <= canvas.width && one.y - one.halfH >= 0 && name.x >= 0 && name.x + name.width <= canvas.width && name.y + name.height <= canvas.height;
174
+ });
169
175
  /*
170
- * A SMALLER CITY BEFORE A CITY OFF THE EDGE. With a picture standing in
171
- * the middle, the map fitted to the room may not stand beside it and
172
- * stay on the canvas; a tighter lattice usually can, and the districts
173
- * keep their size — only the streets between them shorten. So the cell
174
- * is tried smaller, down to where the districts would touch, and the
175
- * largest cell whose every district is on the canvas is the one kept.
176
- * Only when no cell will do does the city grow past the window.
176
+ * Where no spacing lets every name stand under its district — a phone,
177
+ * with names as wide as the city — the spacing that says the most of
178
+ * them, the narrowest of those: a wider street that says no more names
179
+ * only draws the city smaller.
177
180
  */
178
- if (crowded(settled.placed) || outside(settled.placed) > 0) {
179
- let best = crowded(settled.placed) ? undefined : { cell, settled, off: outside(settled.placed) };
180
- for (let share = 0.9; share >= 0.3; share -= 0.1) {
181
- const tighter = fitted * share;
182
- const trial = settle(tighter, asked, openedShare);
183
- if (crowded(trial.placed))
184
- continue;
185
- const off = outside(trial.placed);
186
- if (!best || off < best.off - 0.5)
187
- best = { cell: tighter, settled: trial, off };
188
- if (off === 0)
189
- break;
190
- }
191
- if (best) {
192
- cell = best.cell;
193
- settled = best.settled;
194
- }
181
+ let best = { pitch: PITCH, said: -1 };
182
+ for (const pitch of [PITCH, PITCH + 1, PITCH + 2, PITCH + 3, PITCH + 5, PITCH + 7]) {
183
+ ({ map, fitted } = mapsAt(pitch));
184
+ shape = shapeOf(map);
185
+ const said = fitsUnder(draw(at(fitted)));
186
+ if (said > best.said)
187
+ best = { pitch, said };
188
+ if (said === cards.length)
189
+ break;
195
190
  }
196
- for (let grown = 0; crowded(settled.placed) && grown < 80; grown++) {
197
- cell *= 1.06;
198
- settled = settle(cell, asked, openedShare);
191
+ if (best.pitch !== PITCH + 7 || best.said !== cards.length) {
192
+ ({ map, fitted } = mapsAt(best.pitch));
193
+ shape = shapeOf(map);
194
+ }
195
+ let where = at(fitted);
196
+ if (avoid.length > 0 && blocked(draw(where))) {
197
+ let found;
198
+ for (let share = 1; share >= 0.3 && !found; share -= 0.05) {
199
+ const cell = fitted * share;
200
+ const dx = Math.max(...avoid.map((box) => box.width)) / 2 + cell;
201
+ const dy = Math.max(...avoid.map((box) => box.height)) / 2 + cell / 2;
202
+ for (const s of [0, 0.5, 0.75, 1, 1.25]) {
203
+ for (const [ux, uy] of [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [-1, 1], [1, -1], [-1, -1]]) {
204
+ const trial = at(cell, { x: ux * dx * s, y: uy * dy * s });
205
+ const drawn = draw(trial);
206
+ if (!blocked(drawn) && inWindow(drawn)) {
207
+ found = trial;
208
+ break;
209
+ }
210
+ if (s === 0)
211
+ break;
212
+ }
213
+ if (found)
214
+ break;
215
+ }
216
+ }
217
+ if (found)
218
+ where = found;
199
219
  }
200
220
  /*
201
- * BESIDE THE RAILS, NOT UNDER THEM. The map is centered on its lattice's
202
- * bounding diamond, and the districts are not: a card is centered on its
203
- * plot and the plots do not fill the diamond, so a city grown just wide
204
- * enough for its names stood twenty-five pixels left of the room it
205
- * fitted — and the leftmost district under the inspector. When the
206
- * districts fit between the rails, the city slides the least distance
207
- * that puts every one of them there; when they do not, the camera pans
208
- * to them, as before.
221
+ * CLOSE ENOUGH TO READ. Where no spacing says every name inside the
222
+ * window — a phone, whose names are as wide as its city — the camera
223
+ * starts closer, by the least that lets every name stand under its own
224
+ * district: the city runs past the window's edges, the signs on those
225
+ * edges say what is there, and a finger pans to it. A city drawn whole
226
+ * but too small to name is a city nobody can press.
209
227
  */
210
- // With a picture standing in the middle the city has already stepped aside for it, and that placement stands.
211
- if (avoid.length === 0) {
212
- const lowX = Math.min(...settled.placed.map((card) => card.x));
213
- const highX = Math.max(...settled.placed.map((card) => card.x + card.width));
214
- const slide = highX - lowX > right - left ? 0 : lowX < left ? left - lowX : highX > right ? right - highX : 0;
215
- if (slide !== 0) {
216
- nudge = slide;
217
- const slid = at(cell, asked, openedShare, settled.shift);
218
- if (crowded(slid))
219
- nudge = 0;
220
- else
221
- settled = { placed: slid, shift: settled.shift };
228
+ const closerBy = (where, by) => {
229
+ const mid = { x: canvas.width / 2, y: canvas.height / 2 };
230
+ return { cell: where.cell * by, originX: mid.x + (where.originX - mid.x) * by, originY: mid.y + (where.originY - mid.y) * by };
231
+ };
232
+ let closer = 1;
233
+ if (fitsUnder(draw(where)) < cards.length) {
234
+ /*
235
+ * Each spacing at the least closer camera that names every district,
236
+ * and of those the one whose city stands smallest on the screen: a
237
+ * wider street can say them all from further out. Where none does
238
+ * within three times closer, the one that says the most.
239
+ */
240
+ let chosen = { said: fitsUnder(draw(where), false), area: Infinity, pitch: -1, by: 1 };
241
+ const before = { map, shape, fitted, where };
242
+ for (const pitch of [PITCH, PITCH + 1, PITCH + 2, PITCH + 3, PITCH + 5, PITCH + 7]) {
243
+ ({ map, fitted } = mapsAt(pitch));
244
+ shape = shapeOf(map);
245
+ const from = at(fitted);
246
+ for (let by = 1; by <= 3.001; by += 0.1) {
247
+ const said = fitsUnder(draw(closerBy(from, by)), false);
248
+ const e = extentAt(shape, fitted * by);
249
+ const area = (e.maxX - e.minX) * (e.maxY - e.minY);
250
+ if (said > chosen.said || (said === chosen.said && area < chosen.area))
251
+ chosen = { said, area, pitch, by };
252
+ if (said === cards.length)
253
+ break;
254
+ }
255
+ }
256
+ if (chosen.pitch < 0)
257
+ ({ map, shape, fitted, where } = before);
258
+ else {
259
+ ({ map, fitted } = mapsAt(chosen.pitch));
260
+ shape = shapeOf(map);
261
+ closer = chosen.by;
262
+ where = closerBy(at(fitted), closer);
263
+ }
264
+ /*
265
+ * WHAT IS CHOSEN STAYS IN SIGHT: a city drawn past the window moves,
266
+ * the least it can, to keep the chosen district and its name inside —
267
+ * the sheet it opened takes the foot of a phone, and a district past
268
+ * the edge could not be pressed again or asked for its acts.
269
+ */
270
+ const kept = draw(where).filter((one) => options.keep?.includes(one.card.kind));
271
+ if (kept.length > 0) {
272
+ const boxes = kept.flatMap((one) => [{ x: one.x - one.halfW, y: one.y - one.halfH, width: one.halfW * 2, height: one.halfH * 2 }, plateBox(one, plate(one.card))]);
273
+ const left = Math.min(...boxes.map((b) => b.x));
274
+ const right = Math.max(...boxes.map((b) => b.x + b.width));
275
+ const top = Math.min(...boxes.map((b) => b.y));
276
+ const bottom = Math.max(...boxes.map((b) => b.y + b.height));
277
+ const by = (low, high, span) => (high - low > span - 16 ? span / 2 - (low + high) / 2 : low < 8 ? 8 - low : high > span - 8 ? span - 8 - high : 0);
278
+ where = { ...where, originX: where.originX + by(left, right, canvas.width), originY: where.originY + by(top, bottom, canvas.height) };
222
279
  }
223
280
  }
224
281
  /*
225
282
  * FLYING CLOSER. The fit above is the whole map in the window; a zoom is
226
- * the camera brought in afterwards, so the map keeps its shape and only
227
- * the cell grows — the city may run past the window, and the camera
228
- * pans to reach it.
283
+ * the camera brought in afterwards, about the canvas's middle, so the map
284
+ * keeps its shape and only the cell grows — the city may run past the
285
+ * window, and the camera pans to reach it. The names stay their size.
286
+ */
287
+ const zoom = options.zoom && options.zoom !== 1 ? options.zoom : 1;
288
+ if (zoom !== 1)
289
+ where = closerBy(where, zoom);
290
+ const drawn = draw(where);
291
+ /*
292
+ * THE NAMES, MOST PRESSING FIRST. Each is tried whole under its plot,
293
+ * then over it, then short under and over, then beside it; one that would
294
+ * stand on a name already placed, on another district's ground, on the
295
+ * chrome or past the window's edge says nothing until the camera comes
296
+ * closer.
297
+ * Its district is still there, and still says its name to the keyboard.
229
298
  */
230
- if (options.zoom && options.zoom !== 1) {
231
- cell *= options.zoom;
232
- nudge *= options.zoom;
233
- settled = settle(cell, asked, openedShare);
299
+ const said = [];
300
+ const sayOf = new Map();
301
+ const order = [...drawn].sort((a, b) => (b.card.weight ?? b.card.count) - (a.card.weight ?? a.card.count) || cards.indexOf(a.card) - cards.indexOf(b.card));
302
+ for (const one of order) {
303
+ // Under its own district, whole or short: never over it or beside it, where it would read as a neighbor's.
304
+ const sizes = [
305
+ ["full", plate(one.card), "under"],
306
+ ["short", plate(one.card).short, "under"],
307
+ ];
308
+ let chosen = "none";
309
+ /*
310
+ * THE MOST PRESSING NAME IS ALWAYS SAID. Where its district is hemmed in
311
+ * on every side — a phone, a column of kinds a cell apart — it may stand
312
+ * over the edge of a neighbor's ground, on its solid backing, rather
313
+ * than leave the district the page is about unnamed or without its bar.
314
+ * Every other name keeps off every other district's ground.
315
+ */
316
+ const first = one === order[0];
317
+ const as = (over, kind) => sizes.filter(([name]) => name.startsWith(kind)).map(([n, size, at]) => [n, size, at, over]);
318
+ // On clear ground first, whole and then short; only then over a neighbor's, whole and then short.
319
+ const tries = first ? [...as(false, "full"), ...as(false, "short"), ...as(true, "full"), ...as(true, "short")] : sizes.map(([n, size, at]) => [n, size, at, false]);
320
+ for (const [name, size, at, over] of tries) {
321
+ const box = plateBox(one, size, at);
322
+ // Past the window's edge only counts for the whole map; flown closer, the camera pans to it.
323
+ const inside = zoom !== 1 || closer !== 1 || (box.x >= 0 && box.y >= 0 && box.x + box.width <= canvas.width && box.y + box.height <= canvas.height);
324
+ const clear = inside &&
325
+ !said.some((other) => collides(box, other, 4)) &&
326
+ (over || !drawn.some((other) => other !== one && onDiamond(box, other, other.halfW, other.halfH))) &&
327
+ // Nor on another district's showings, where it would cover a picture's name somebody is reading.
328
+ !onShowings(box, one, drawn) &&
329
+ !avoid.some((other) => collides(box, other, 0));
330
+ if (clear) {
331
+ chosen = name;
332
+ said.push(box);
333
+ break;
334
+ }
335
+ }
336
+ sayOf.set(one.card.id, chosen);
234
337
  }
235
- const placed = settled.placed;
236
- const originX = cx - ((minX + maxX) / 2) * cell + settled.shift.x + nudge;
237
- const originY = cy - ((minY + maxY) / 2) * cell + settled.shift.y;
338
+ const placed = drawn.map((one) => ({
339
+ id: one.card.id,
340
+ x: one.x - one.halfW,
341
+ y: one.y - one.halfH,
342
+ width: one.halfW * 2,
343
+ height: one.halfH * 2 + (one.card.opened > 0 ? Math.max(one.card.openedMin ?? 0, one.card.opened) : 0),
344
+ depth: one.depth,
345
+ plot: one.plot,
346
+ nameplate: sayOf.get(one.card.id) ?? "none",
347
+ }));
348
+ const e = extentAt(shape, where.cell);
238
349
  const frame = {
239
- cell,
240
- originX,
241
- originY,
350
+ cell: where.cell,
351
+ originX: where.originX,
352
+ originY: where.originY,
242
353
  pan: options.pan ?? { x: 0, y: 0 },
243
- extent: {
244
- x: originX + minX * cell,
245
- y: originY + minY * cell,
246
- width: spread.x * cell,
247
- height: spread.y * cell,
248
- },
249
- openedShare,
354
+ extent: { x: where.originX + e.minX, y: where.originY + e.minY, width: e.maxX - e.minX, height: e.maxY - e.minY },
355
+ openedShare: 1,
356
+ ...(tilt !== 0.5 ? { tilt } : {}),
357
+ ...(drawn.some((one) => one.card.marquee)
358
+ ? {
359
+ showings: drawn.flatMap((one) => {
360
+ const hung = marqueeBox(one);
361
+ return hung ? [{ id: one.card.id, x: hung.x - (one.x - one.halfW), y: hung.y - (one.y - one.halfH), width: hung.width, height: hung.height }] : [];
362
+ }),
363
+ }
364
+ : {}),
250
365
  };
251
366
  return { placed, frame, map };
252
367
  }
@@ -259,14 +374,16 @@ export function placeCity(cards, schema, base, canvas, inset = {}, options = {})
259
374
  * and by the tests alike, so "reachable" means one thing.
260
375
  */
261
376
  export function cameraLimit(result) {
262
- const slack = { x: result.width * 0.45, y: result.height * 0.45 };
377
+ // The reader's window is what a panel over the scene leaves, not the whole painted ground.
378
+ const { width, height } = result.visible ?? result;
379
+ const slack = { x: width * 0.45, y: height * 0.45 };
263
380
  if (!result.city)
264
381
  return slack;
265
382
  const { extent } = result.city;
266
383
  const margin = 24;
267
384
  return {
268
- x: Math.max(slack.x, extent.x + extent.width - result.width + margin, -extent.x + margin),
269
- y: Math.max(slack.y, extent.y + extent.height - result.height + margin, -extent.y + margin),
385
+ x: Math.max(slack.x, extent.x + extent.width - width + margin, -extent.x + margin),
386
+ y: Math.max(slack.y, extent.y + extent.height - height + margin, -extent.y + margin),
270
387
  };
271
388
  }
272
389
  /** How far in from an edge a district brought into the window stands, at the least: room for its nameplate's height. */
@@ -283,7 +400,7 @@ const EDGE_CLEAR = 48;
283
400
  export function districtsPastTheEdge(result) {
284
401
  if (!result.city)
285
402
  return [];
286
- const { width, height } = result;
403
+ const { width, height } = result.visible ?? result;
287
404
  const out = [];
288
405
  for (const node of result.nodes) {
289
406
  if (!node.id.startsWith("kind:") || Math.round(node.plane) !== 2)