@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.d.ts +70 -38
- package/dist/city.d.ts.map +1 -1
- package/dist/city.js +336 -219
- package/dist/city.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/interpolate.d.ts.map +1 -1
- package/dist/interpolate.js +3 -0
- package/dist/interpolate.js.map +1 -1
- package/dist/layout.d.ts +0 -16
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +83 -210
- package/dist/layout.js.map +1 -1
- package/dist/sizes.d.ts +2 -0
- package/dist/sizes.d.ts.map +1 -1
- package/dist/sizes.js +4 -1
- package/dist/sizes.js.map +1 -1
- package/dist/types.d.ts +80 -9
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/view-moves.d.ts +35 -0
- package/dist/view-moves.d.ts.map +1 -0
- package/dist/view-moves.js +77 -0
- package/dist/view-moves.js.map +1 -0
- package/dist/view-state.d.ts +2 -24
- package/dist/view-state.d.ts.map +1 -1
- package/dist/view-state.js +5 -72
- package/dist/view-state.js.map +1 -1
- package/dist/view.d.ts +4 -6
- package/dist/view.d.ts.map +1 -1
- package/dist/view.js +4 -5
- package/dist/view.js.map +1 -1
- package/package.json +10 -2
package/dist/city.js
CHANGED
|
@@ -1,252 +1,367 @@
|
|
|
1
1
|
import {} from "@graview/core";
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
/**
|
|
11
|
-
const
|
|
12
|
-
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
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
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
/**
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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,
|
|
227
|
-
* the cell grows — the city may run past the
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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 =
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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 -
|
|
269
|
-
y: Math.max(slack.y, extent.y + extent.height -
|
|
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)
|