@a2f0/skyline 0.1.7 → 0.2.1
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/README.md +3 -3
- package/docs/package.md +163 -25
- package/lib/skyline-package.d.ts +26 -18
- package/lib/skyline-package.js +10 -26
- package/lib/skyline-shell.d.ts +69 -0
- package/lib/skyline-shell.js +115 -0
- package/lib/skyline-three.d.ts +2 -0
- package/lib/skyline-three.js +7 -0
- package/lib/three-engine.d.ts +16 -0
- package/lib/three-engine.js +12 -0
- package/package.json +15 -4
- package/site/building-detail-page.js +16 -0
- package/site/building-detail.html +45 -38
- package/site/building-detail.js +25 -19
- package/site/index.html +21 -373
- package/site/markup.js +36 -0
- package/site/skyline-3d-page.js +12 -0
- package/site/skyline-3d.html +7 -11
- package/site/skyline-3d.js +396 -310
- package/site/skyline-shell.js +115 -0
- package/site/skyline-viewer.js +321 -0
- package/site/stars.svg +8 -6
- package/site/study-loader.js +0 -2
- package/site/study-viewer.js +94 -36
- package/site/study.css +57 -30
- package/site/three-engine.js +12 -0
- package/site/vendor/three-r186.bundle.js +4228 -0
- package/site/vendor/three-r186.js +11 -4228
- package/site/viewer-page.js +15 -0
- package/site/viewer.css +147 -0
package/site/skyline-3d.js
CHANGED
|
@@ -1,334 +1,420 @@
|
|
|
1
|
-
import { createBuildingStudy } from "./study-viewer.js";
|
|
1
|
+
import { createBuildingStudy, frameBox } from "./study-viewer.js";
|
|
2
2
|
import { createGeographicSkyline } from "./skyline-scene.js";
|
|
3
3
|
import { createGeographicRoads } from "./models/skyline-geography.js";
|
|
4
4
|
import { celebrations } from "./models/celebrations.js";
|
|
5
|
+
import { loadMarkup, loadStylesheet } from "./markup.js";
|
|
5
6
|
// The skyline viewer's full-screen 3D mode: every mapped building, seen through the
|
|
6
|
-
// drawing's own camera and framed as
|
|
7
|
-
// where its drawn one does until the camera moves.
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
ground
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
// The
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const eligible = illuminated.filter((model) => model.illumination.presets.some(({ id }) => id === button.dataset["celebration"]));
|
|
57
|
-
const active = eligible.filter((model) => model.illumination.active === button.dataset["celebration"]).length;
|
|
58
|
-
button.setAttribute("aria-pressed", active === 0 ? "false" : active === eligible.length ? "true" : "mixed");
|
|
59
|
-
}
|
|
60
|
-
celebrationStatus.hidden = false;
|
|
61
|
-
celebrationStatus.textContent = targets.map((model) => {
|
|
62
|
-
const preset = model.illumination.presets.find(({ id }) => id === model.illumination.active);
|
|
63
|
-
const name = model.building.userData["geography"]?.name ?? model.building.name;
|
|
64
|
-
return preset ? `${preset.lines.join(" ")} · ${name}${preset.adapted ? " · adapted tribute" : ""}` : `Celebratory lights off · ${name}`;
|
|
65
|
-
}).join("; ");
|
|
66
|
-
celebrationAnnouncement.textContent = celebrationStatus.textContent;
|
|
67
|
-
viewer.requestRender();
|
|
68
|
-
}
|
|
69
|
-
for (const button of celebrationButtons) {
|
|
70
|
-
const preset = celebrations.find(({ id }) => id === button.dataset["celebration"]);
|
|
71
|
-
if (!preset)
|
|
72
|
-
throw new Error(`Unknown toolbar celebration: ${button.dataset["celebration"]}`);
|
|
73
|
-
const displays = illuminated.flatMap((model) => {
|
|
74
|
-
const message = model.illumination.presets.find(({ id }) => id === preset.id);
|
|
75
|
-
return message ? [`${message.lines.join(" ")}${message.adapted ? " (adapted tribute)" : ""} · ${model.building.userData["geography"]?.name ?? model.building.name}`] : [];
|
|
7
|
+
// drawing's own camera and framed as the viewer frames the drawing, so each tower stands
|
|
8
|
+
// where its drawn one does until the camera moves. It renders into skyline-3d.html's own
|
|
9
|
+
// document, or into the viewer's shadow root, from the same markup.
|
|
10
|
+
export function startSkyline3d({ root, frame, assets, signal, onShowOriginal, onReady, onUnavailable }) {
|
|
11
|
+
const document = root instanceof Document ? root : root.ownerDocument;
|
|
12
|
+
const listening = { signal };
|
|
13
|
+
const { models, ground, settings, drawingView, comparisonViews, labels } = createGeographicSkyline();
|
|
14
|
+
// The mapped streets show as grey roadways across the whole platform. The study's
|
|
15
|
+
// centerlines, footprint outlines and grid are annotations; the outlines return with the
|
|
16
|
+
// footprints control.
|
|
17
|
+
ground.group.children.forEach((child) => { child.visible = false; });
|
|
18
|
+
const { platform } = settings;
|
|
19
|
+
const roads = createGeographicRoads({
|
|
20
|
+
x: [platform.x - platform.width / 2, platform.x + platform.width / 2],
|
|
21
|
+
z: [platform.z - platform.depth / 2, platform.z + platform.depth / 2],
|
|
22
|
+
}, 0x2c2c2c);
|
|
23
|
+
const { azimuth } = drawingView;
|
|
24
|
+
// The study renders its first frame as it starts; the scene says so once its controls,
|
|
25
|
+
// menus and detail are wired too, at the end.
|
|
26
|
+
const viewer = createBuildingStudy({
|
|
27
|
+
root,
|
|
28
|
+
frame,
|
|
29
|
+
signal,
|
|
30
|
+
onUnavailable,
|
|
31
|
+
models,
|
|
32
|
+
extras: [ground.group, roads],
|
|
33
|
+
defaultView: "skyline",
|
|
34
|
+
// The skyline study's presets, so reduced motion, which stops dragging, still leaves
|
|
35
|
+
// views to step between, and its ground plan and height comparison, which name each
|
|
36
|
+
// building.
|
|
37
|
+
views: {
|
|
38
|
+
skyline: drawingView,
|
|
39
|
+
quarter: { azimuth: azimuth + 0.3, polar: Math.PI / 2 - 0.28, label: "three-quarter view" },
|
|
40
|
+
side: { azimuth: azimuth + Math.PI / 2, polar: Math.PI / 2 - 0.14, label: "side view" },
|
|
41
|
+
...comparisonViews,
|
|
42
|
+
},
|
|
43
|
+
labels,
|
|
44
|
+
fov: 6,
|
|
45
|
+
...settings,
|
|
46
|
+
platform: { ...settings.platform, color: 0x161616 },
|
|
47
|
+
near: 10,
|
|
48
|
+
far: 10000,
|
|
49
|
+
minimumDistanceRatio: 0.1,
|
|
50
|
+
// Twice the viewer's default reach, so the whole mapped city sits well inside the frame
|
|
51
|
+
// from every view.
|
|
52
|
+
maximumDistanceRatio: 4,
|
|
53
|
+
minimumZoom: 0.25,
|
|
54
|
+
maximumZoom: 24,
|
|
55
|
+
enablePan: true,
|
|
56
|
+
minimumCameraHeight: 1,
|
|
76
57
|
});
|
|
77
|
-
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
58
|
+
// A toolbar badge applies one celebration across the skyline, using each
|
|
59
|
+
// building's own supported wording. Context menus change only their building.
|
|
60
|
+
const illuminated = models.filter((model) => model.illumination);
|
|
61
|
+
const celebrationStatus = root.querySelector("#celebration-status");
|
|
62
|
+
const celebrationAnnouncement = root.querySelector("#celebration-announcement");
|
|
63
|
+
const celebrationButtons = [...root.querySelectorAll("[data-celebration]")];
|
|
64
|
+
function setCelebration(targets, next) {
|
|
65
|
+
targets.forEach((model) => model.illumination.set(model.illumination.presets.some(({ id }) => id === next) ? next : null));
|
|
66
|
+
for (const button of celebrationButtons) {
|
|
67
|
+
const eligible = illuminated.filter((model) => model.illumination.presets.some(({ id }) => id === button.dataset["celebration"]));
|
|
68
|
+
const active = eligible.filter((model) => model.illumination.active === button.dataset["celebration"]).length;
|
|
69
|
+
button.setAttribute("aria-pressed", active === 0 ? "false" : active === eligible.length ? "true" : "mixed");
|
|
70
|
+
}
|
|
71
|
+
celebrationStatus.hidden = false;
|
|
72
|
+
celebrationStatus.textContent = targets.map((model) => {
|
|
73
|
+
const preset = model.illumination.presets.find(({ id }) => id === model.illumination.active);
|
|
74
|
+
const name = model.building.userData["geography"]?.name ?? model.building.name;
|
|
75
|
+
return preset ? `${preset.lines.join(" ")} · ${name}${preset.adapted ? " · adapted tribute" : ""}` : `Celebratory lights off · ${name}`;
|
|
76
|
+
}).join("; ");
|
|
77
|
+
celebrationAnnouncement.textContent = celebrationStatus.textContent;
|
|
92
78
|
viewer.requestRender();
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
79
|
+
}
|
|
80
|
+
for (const button of celebrationButtons) {
|
|
81
|
+
const preset = celebrations.find(({ id }) => id === button.dataset["celebration"]);
|
|
82
|
+
if (!preset)
|
|
83
|
+
throw new Error(`Unknown toolbar celebration: ${button.dataset["celebration"]}`);
|
|
84
|
+
const displays = illuminated.flatMap((model) => {
|
|
85
|
+
const message = model.illumination.presets.find(({ id }) => id === preset.id);
|
|
86
|
+
return message ? [`${message.lines.join(" ")}${message.adapted ? " (adapted tribute)" : ""} · ${model.building.userData["geography"]?.name ?? model.building.name}`] : [];
|
|
87
|
+
});
|
|
88
|
+
button.title = `${preset.label}: ${displays.join("; ")} · click to light all; click again to turn off`;
|
|
89
|
+
// A badge no mapped building can display is left out rather than greyed out.
|
|
90
|
+
button.hidden = displays.length === 0;
|
|
91
|
+
button.addEventListener("click", () => {
|
|
92
|
+
const next = button.getAttribute("aria-pressed") === "true" ? null : preset.id;
|
|
93
|
+
const targets = next === null ? illuminated.filter((model) => model.illumination.active === preset.id) : illuminated;
|
|
94
|
+
setCelebration(targets, next);
|
|
95
|
+
}, listening);
|
|
96
|
+
}
|
|
97
|
+
// The control bar's ground toggles, each pressed while what it toggles shows.
|
|
98
|
+
for (const [id, object] of [["streets", roads], ["footprints", ground.footprints]]) {
|
|
99
|
+
const button = root.querySelector(`#${id}`);
|
|
100
|
+
button.addEventListener("click", () => {
|
|
101
|
+
object.visible = !object.visible;
|
|
102
|
+
button.setAttribute("aria-pressed", String(object.visible));
|
|
103
|
+
viewer.requestRender();
|
|
104
|
+
}, listening);
|
|
105
|
+
}
|
|
106
|
+
// The star in the middle of the bar opens and closes the toolbar. Each group unfolds from
|
|
107
|
+
// the star: clipped open from its `--fold` inset while it fades in, one beside the star
|
|
108
|
+
// slides out from it to its edge of the bar, and it folds back the same way before it hides. A toggle mid-way reverses the moving
|
|
109
|
+
// fold from where it is. Closing groups are inert at once, and focus inside them returns to
|
|
110
|
+
// the star first; reduced motion shows and hides them at once. Escape closes the toolbar from
|
|
111
|
+
// inside the bar.
|
|
112
|
+
const bar = root.querySelector(".control-bar");
|
|
113
|
+
const menuToggle = root.querySelector("#menu-toggle");
|
|
114
|
+
// This shortcut belongs to the viewer; the standalone scene has no drawing to switch to.
|
|
115
|
+
// Returning from the drawing keeps this scene and its camera intact.
|
|
116
|
+
const showOriginal = root.querySelector("#show-original");
|
|
117
|
+
if (onShowOriginal) {
|
|
108
118
|
showOriginal.hidden = false;
|
|
109
|
-
showOriginal.addEventListener("click",
|
|
119
|
+
showOriginal.addEventListener("click", onShowOriginal, listening);
|
|
110
120
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
{
|
|
127
|
-
{ clipPath: "inset(-4px)", opacity: 1, translate: "0 0" },
|
|
128
|
-
], { duration: foldDuration, easing: "cubic-bezier(0.2, 0.7, 0.2, 1)", fill: "both" });
|
|
129
|
-
}
|
|
130
|
-
function setMenu(open) {
|
|
131
|
-
menuToggle.setAttribute("aria-expanded", String(open));
|
|
132
|
-
menuToggle.title = open ? "Hide the controls" : "Show the controls";
|
|
133
|
-
if (!open && groups.some((group) => group.contains(document.activeElement)))
|
|
134
|
-
menuToggle.focus();
|
|
135
|
-
groups.forEach((group) => { group.inert = !open; });
|
|
136
|
-
if (open)
|
|
137
|
-
bar.classList.add("open");
|
|
138
|
-
if (reducedMotion.matches) {
|
|
139
|
-
folds.forEach((fold) => fold.cancel());
|
|
140
|
-
folds = [];
|
|
141
|
-
bar.classList.toggle("open", open);
|
|
142
|
-
return;
|
|
121
|
+
const groups = [...bar.querySelectorAll(".button-group")];
|
|
122
|
+
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
|
|
123
|
+
// The groups' folds while they move, and which way: toward open, or back toward closed.
|
|
124
|
+
const foldDuration = 300;
|
|
125
|
+
let folds = [];
|
|
126
|
+
let unfolding = false;
|
|
127
|
+
function unfold(group) {
|
|
128
|
+
// A group in the star's row starts against the star; one in a row under it opens in place.
|
|
129
|
+
const star = menuToggle.getBoundingClientRect(), box = group.getBoundingClientRect();
|
|
130
|
+
const beside = box.top < star.bottom && box.bottom > star.top;
|
|
131
|
+
const shift = !beside ? 0 : box.right <= star.left ? star.left - box.right : box.left >= star.right ? star.right - box.left : 0;
|
|
132
|
+
// Open, the clip stands off the group far enough to keep its buttons' focus rings.
|
|
133
|
+
return group.animate([
|
|
134
|
+
{ clipPath: getComputedStyle(group).getPropertyValue("--fold").trim(), opacity: 0, translate: `${shift}px 0` },
|
|
135
|
+
{ clipPath: "inset(-4px)", opacity: 1, translate: "0 0" },
|
|
136
|
+
], { duration: foldDuration, easing: "cubic-bezier(0.2, 0.7, 0.2, 1)", fill: "both" });
|
|
143
137
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
138
|
+
function setMenu(open) {
|
|
139
|
+
menuToggle.setAttribute("aria-expanded", String(open));
|
|
140
|
+
menuToggle.title = open ? "Hide the controls" : "Show the controls";
|
|
141
|
+
if (!open && groups.some((group) => group.contains(root.activeElement)))
|
|
142
|
+
menuToggle.focus({ preventScroll: true });
|
|
143
|
+
groups.forEach((group) => { group.inert = !open; });
|
|
144
|
+
if (open)
|
|
145
|
+
bar.classList.add("open");
|
|
146
|
+
if (reducedMotion.matches) {
|
|
147
|
+
folds.forEach((fold) => fold.cancel());
|
|
148
|
+
folds = [];
|
|
149
|
+
bar.classList.toggle("open", open);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
// Reverse a fold still moving the other way; otherwise start a fresh one from its end. A
|
|
153
|
+
// fold that has yet to leave its starting end, toggled back before its first frame, would
|
|
154
|
+
// seek to its far end on reverse(), so it settles where it stands instead.
|
|
155
|
+
if (folds.length && folds.every((fold) => fold.playState === "running" || fold.playState === "paused")) {
|
|
156
|
+
if (unfolding !== open) {
|
|
157
|
+
const unmoved = folds.every((fold) => { const time = Number(fold.currentTime ?? 0); return unfolding ? time <= 0 : time >= foldDuration; });
|
|
158
|
+
if (unmoved) {
|
|
159
|
+
folds.forEach((fold) => fold.cancel());
|
|
160
|
+
folds = [];
|
|
161
|
+
unfolding = open;
|
|
162
|
+
bar.classList.toggle("open", open);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
folds.forEach((fold) => fold.reverse());
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
folds.forEach((fold) => fold.cancel());
|
|
170
|
+
folds = groups.map(unfold);
|
|
171
|
+
if (!open)
|
|
172
|
+
folds.forEach((fold) => fold.reverse());
|
|
173
|
+
}
|
|
174
|
+
unfolding = open;
|
|
175
|
+
const moving = folds;
|
|
176
|
+
void Promise.allSettled(moving.map((fold) => fold.finished)).then(() => {
|
|
177
|
+
if (folds !== moving || (menuToggle.getAttribute("aria-expanded") === "true") !== open)
|
|
155
178
|
return;
|
|
179
|
+
folds.forEach((fold) => fold.cancel());
|
|
180
|
+
folds = [];
|
|
181
|
+
if (!open)
|
|
182
|
+
bar.classList.remove("open");
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
menuToggle.addEventListener("click", () => setMenu(menuToggle.getAttribute("aria-expanded") !== "true"), listening);
|
|
186
|
+
bar.addEventListener("keydown", (event) => {
|
|
187
|
+
if (event.key !== "Escape" || menuToggle.getAttribute("aria-expanded") !== "true")
|
|
188
|
+
return;
|
|
189
|
+
event.preventDefault();
|
|
190
|
+
menuToggle.focus({ preventScroll: true });
|
|
191
|
+
setMenu(false);
|
|
192
|
+
}, listening);
|
|
193
|
+
// Right-clicking a building, or a long press on it where the browser raises a context menu
|
|
194
|
+
// for one, as Chrome on Android does, opens its context menu at the pointer: the building's
|
|
195
|
+
// name over its detail link and available lighting choices. A right-drag still pans, so the menu opens only for a press that stays put: on
|
|
196
|
+
// its release where the menu event comes with the press, as on macOS. Escape, Tab, a press
|
|
197
|
+
// elsewhere, the wheel, or a resize closes it.
|
|
198
|
+
const canvas = root.querySelector("#building");
|
|
199
|
+
const menu = root.querySelector("#building-menu");
|
|
200
|
+
const menuTitle = root.querySelector("#building-menu-title");
|
|
201
|
+
const detailLink = root.querySelector("#building-detail-link");
|
|
202
|
+
const lightingMenu = root.querySelector("#building-lighting-menu");
|
|
203
|
+
let menuBuilding = "";
|
|
204
|
+
let press = null;
|
|
205
|
+
let pendingMenu = null;
|
|
206
|
+
function openMenu(building, x, y) {
|
|
207
|
+
menuBuilding = building.building.userData["buildingId"];
|
|
208
|
+
menuTitle.textContent = building.building.userData["geography"]?.name ?? building.building.name;
|
|
209
|
+
detailLink.href = new URL(`building-detail.html?building=${encodeURIComponent(menuBuilding)}`, assets).href;
|
|
210
|
+
lightingMenu.replaceChildren();
|
|
211
|
+
lightingMenu.hidden = !building.illumination;
|
|
212
|
+
if (building.illumination) {
|
|
213
|
+
const choices = [
|
|
214
|
+
{ id: null, label: "Lights off", title: "Turn off this building's celebratory lights" },
|
|
215
|
+
...building.illumination.presets.map((preset) => ({
|
|
216
|
+
id: preset.id,
|
|
217
|
+
label: `${preset.label} · ${preset.lines.join(" ")}${preset.adapted ? " (adapted)" : ""}`,
|
|
218
|
+
title: `${preset.label} lighting${preset.adapted ? " · adapted tribute" : ""} · select again to turn off`,
|
|
219
|
+
})),
|
|
220
|
+
];
|
|
221
|
+
for (const choice of choices) {
|
|
222
|
+
const button = document.createElement("button");
|
|
223
|
+
button.type = "button";
|
|
224
|
+
button.setAttribute("role", "menuitemradio");
|
|
225
|
+
button.setAttribute("aria-checked", String(building.illumination.active === choice.id));
|
|
226
|
+
button.tabIndex = -1;
|
|
227
|
+
button.dataset["lighting"] = choice.id ?? "off";
|
|
228
|
+
button.textContent = choice.label;
|
|
229
|
+
button.title = choice.title;
|
|
230
|
+
button.addEventListener("click", () => {
|
|
231
|
+
setCelebration([building], building.illumination.active === choice.id ? null : choice.id);
|
|
232
|
+
closeMenu();
|
|
233
|
+
});
|
|
234
|
+
lightingMenu.append(button);
|
|
156
235
|
}
|
|
157
|
-
folds.forEach((fold) => fold.reverse());
|
|
158
236
|
}
|
|
237
|
+
menu.hidden = false;
|
|
238
|
+
menu.scrollTop = 0;
|
|
239
|
+
// At the pointer, turned back from the scene's right and bottom edges, in the scene's own
|
|
240
|
+
// CSS pixels.
|
|
241
|
+
const width = menu.offsetWidth, height = menu.offsetHeight, box = frameBox(frame);
|
|
242
|
+
const left = (x - box.left) / box.scaleX, top = (y - box.top) / box.scaleY;
|
|
243
|
+
menu.style.left = `${Math.max(4, left + width > box.width - 4 ? left - width : left)}px`;
|
|
244
|
+
menu.style.top = `${Math.max(4, top + height > box.height - 4 ? top - height : top)}px`;
|
|
245
|
+
detailLink.focus({ preventScroll: true });
|
|
159
246
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
247
|
+
function closeMenu() {
|
|
248
|
+
pendingMenu = null;
|
|
249
|
+
if (menu.hidden)
|
|
250
|
+
return;
|
|
251
|
+
if (menu.contains(root.activeElement))
|
|
252
|
+
canvas.focus({ preventScroll: true });
|
|
253
|
+
menu.hidden = true;
|
|
165
254
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
255
|
+
// A press anywhere else on the page closes the menu. The window cannot see inside shadow
|
|
256
|
+
// roots, the viewer's or a host's closed one around it, so the scene's own root marks a
|
|
257
|
+
// press on the menu as it passes, and the window closes the menu for any other.
|
|
258
|
+
let menuPress = null;
|
|
259
|
+
root.addEventListener("pointerdown", (event) => { if (event.composedPath().includes(menu))
|
|
260
|
+
menuPress = event; }, { capture: true, signal });
|
|
261
|
+
window.addEventListener("pointerdown", (event) => {
|
|
262
|
+
if (event !== menuPress)
|
|
263
|
+
closeMenu();
|
|
264
|
+
menuPress = null;
|
|
265
|
+
}, listening);
|
|
266
|
+
canvas.addEventListener("pointerdown", (event) => {
|
|
267
|
+
press = { x: event.clientX, y: event.clientY, mouse: event.pointerType === "mouse", down: true, moved: false };
|
|
268
|
+
}, listening);
|
|
269
|
+
window.addEventListener("pointermove", (event) => {
|
|
270
|
+
if (press?.down && Math.hypot(event.clientX - press.x, event.clientY - press.y) > 4)
|
|
271
|
+
press.moved = true;
|
|
272
|
+
}, listening);
|
|
273
|
+
window.addEventListener("pointerup", () => {
|
|
274
|
+
if (!press?.down)
|
|
170
275
|
return;
|
|
276
|
+
press.down = false;
|
|
277
|
+
const pending = pendingMenu;
|
|
278
|
+
pendingMenu = null;
|
|
279
|
+
if (pending && !press.moved)
|
|
280
|
+
openMenu(pending.building, pending.x, pending.y);
|
|
281
|
+
}, listening);
|
|
282
|
+
window.addEventListener("pointercancel", () => {
|
|
283
|
+
if (press)
|
|
284
|
+
press.down = false;
|
|
285
|
+
pendingMenu = null;
|
|
286
|
+
}, listening);
|
|
287
|
+
canvas.addEventListener("contextmenu", (event) => {
|
|
288
|
+
const building = viewer.buildingAt(event.clientX, event.clientY);
|
|
289
|
+
if (!building)
|
|
290
|
+
return closeMenu();
|
|
291
|
+
event.preventDefault();
|
|
292
|
+
if (press?.down && press.mouse)
|
|
293
|
+
pendingMenu = { building, x: event.clientX, y: event.clientY };
|
|
294
|
+
else if (!(press?.mouse && press.moved))
|
|
295
|
+
openMenu(building, event.clientX, event.clientY);
|
|
296
|
+
}, listening);
|
|
297
|
+
canvas.addEventListener("wheel", closeMenu, { passive: true, signal });
|
|
298
|
+
window.addEventListener("blur", closeMenu, listening);
|
|
299
|
+
// A resize of the shown scene closes the menu, and settles a fold at once, since a fold
|
|
300
|
+
// measures its slide from the layout it starts in. The scene first appearing, or appearing
|
|
301
|
+
// again after the drawing, changes nothing that is open: its notice can come late, after a
|
|
302
|
+
// menu has opened.
|
|
303
|
+
let shown = null;
|
|
304
|
+
const resizing = new ResizeObserver(([entry]) => {
|
|
305
|
+
const size = entry.contentRect;
|
|
306
|
+
if (shown?.width && shown.height && (shown.width !== size.width || shown.height !== size.height)) {
|
|
307
|
+
closeMenu();
|
|
308
|
+
folds.forEach((fold) => fold.finish());
|
|
309
|
+
}
|
|
310
|
+
shown = size;
|
|
311
|
+
});
|
|
312
|
+
resizing.observe(frame);
|
|
313
|
+
// Released as soon as it exists, so a scene that fails part way lets it go too.
|
|
314
|
+
signal.addEventListener("abort", () => {
|
|
315
|
+
resizing.disconnect();
|
|
171
316
|
folds.forEach((fold) => fold.cancel());
|
|
172
317
|
folds = [];
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const lightingMenu = document.querySelector("#building-lighting-menu");
|
|
197
|
-
let menuBuilding = "";
|
|
198
|
-
let press = null;
|
|
199
|
-
let pendingMenu = null;
|
|
200
|
-
function openMenu(building, x, y) {
|
|
201
|
-
menuBuilding = building.building.userData["buildingId"];
|
|
202
|
-
menuTitle.textContent = building.building.userData["geography"]?.name ?? building.building.name;
|
|
203
|
-
detailLink.href = `building-detail.html?building=${encodeURIComponent(menuBuilding)}`;
|
|
204
|
-
lightingMenu.replaceChildren();
|
|
205
|
-
lightingMenu.hidden = !building.illumination;
|
|
206
|
-
if (building.illumination) {
|
|
207
|
-
const choices = [
|
|
208
|
-
{ id: null, label: "Lights off", title: "Turn off this building's celebratory lights" },
|
|
209
|
-
...building.illumination.presets.map((preset) => ({
|
|
210
|
-
id: preset.id,
|
|
211
|
-
label: `${preset.label} · ${preset.lines.join(" ")}${preset.adapted ? " (adapted)" : ""}`,
|
|
212
|
-
title: `${preset.label} lighting${preset.adapted ? " · adapted tribute" : ""} · select again to turn off`,
|
|
213
|
-
})),
|
|
214
|
-
];
|
|
215
|
-
for (const choice of choices) {
|
|
216
|
-
const button = document.createElement("button");
|
|
217
|
-
button.type = "button";
|
|
218
|
-
button.setAttribute("role", "menuitemradio");
|
|
219
|
-
button.setAttribute("aria-checked", String(building.illumination.active === choice.id));
|
|
220
|
-
button.tabIndex = -1;
|
|
221
|
-
button.dataset["lighting"] = choice.id ?? "off";
|
|
222
|
-
button.textContent = choice.label;
|
|
223
|
-
button.title = choice.title;
|
|
224
|
-
button.addEventListener("click", () => {
|
|
225
|
-
setCelebration([building], building.illumination.active === choice.id ? null : choice.id);
|
|
226
|
-
closeMenu();
|
|
227
|
-
});
|
|
228
|
-
lightingMenu.append(button);
|
|
318
|
+
}, { once: true });
|
|
319
|
+
menu.addEventListener("keydown", (event) => {
|
|
320
|
+
if (event.key === "Escape" || event.key === "Tab") {
|
|
321
|
+
event.preventDefault();
|
|
322
|
+
closeMenu();
|
|
323
|
+
}
|
|
324
|
+
else if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
|
|
325
|
+
event.preventDefault();
|
|
326
|
+
const items = [...menu.querySelectorAll('[role="menuitem"], [role="menuitemradio"]')];
|
|
327
|
+
const index = items.findIndex((item) => item === root.activeElement);
|
|
328
|
+
const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1
|
|
329
|
+
: (index + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length;
|
|
330
|
+
const item = items[next];
|
|
331
|
+
if (!item)
|
|
332
|
+
return;
|
|
333
|
+
// Focus without scrolling, which could scroll the host page, then bring the item into
|
|
334
|
+
// the menu's own view, in the menu's own CSS pixels, which a host's transform leaves
|
|
335
|
+
// alone. The menu is the item's offset parent.
|
|
336
|
+
item.focus({ preventScroll: true });
|
|
337
|
+
if (item.offsetTop < menu.scrollTop)
|
|
338
|
+
menu.scrollTop = item.offsetTop;
|
|
339
|
+
else if (item.offsetTop + item.offsetHeight > menu.scrollTop + menu.clientHeight)
|
|
340
|
+
menu.scrollTop = item.offsetTop + item.offsetHeight - menu.clientHeight;
|
|
229
341
|
}
|
|
342
|
+
}, listening);
|
|
343
|
+
detailLink.addEventListener("click", (event) => {
|
|
344
|
+
closeMenu();
|
|
345
|
+
// A modified click opens the detail's own page, as any link does.
|
|
346
|
+
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
|
|
347
|
+
return;
|
|
348
|
+
event.preventDefault();
|
|
349
|
+
openDetail(menuBuilding, menuTitle.textContent ?? "");
|
|
350
|
+
}, listening);
|
|
351
|
+
// The detail floats in a panel over the skyline, in a shadow root of its own, so its markup
|
|
352
|
+
// and its study's element names stay apart from the scene's, as a frame once kept them. Each
|
|
353
|
+
// detail gets a fresh one, so the page's history stays as it was, and closing the panel
|
|
354
|
+
// releases it and its 3D view. The skyline stays live around it, and another building's menu
|
|
355
|
+
// replaces the detail. Escape closes it from anywhere in the scene or the detail, unless an
|
|
356
|
+
// open menu or toolbar takes it first, and focus returns to the skyline.
|
|
357
|
+
const detailPanel = root.querySelector("#building-detail");
|
|
358
|
+
const detailClose = root.querySelector("#detail-close");
|
|
359
|
+
let detail = null;
|
|
360
|
+
function clearDetail() {
|
|
361
|
+
detail?.abort();
|
|
362
|
+
detail = null;
|
|
363
|
+
detailPanel.querySelector(".detail-host")?.remove();
|
|
230
364
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
365
|
+
function openDetail(id, name) {
|
|
366
|
+
clearDetail();
|
|
367
|
+
const controller = new AbortController();
|
|
368
|
+
detail = controller;
|
|
369
|
+
const host = document.createElement("div");
|
|
370
|
+
host.className = "detail-host";
|
|
371
|
+
const detailRoot = host.attachShadow({ mode: "open" });
|
|
372
|
+
detailPanel.setAttribute("aria-label", `${name} — Building Detail`);
|
|
373
|
+
detailPanel.append(host);
|
|
374
|
+
detailPanel.hidden = false;
|
|
375
|
+
detailClose.focus({ preventScroll: true });
|
|
376
|
+
const page = new URL(`building-detail.html?building=${encodeURIComponent(id)}`, assets);
|
|
377
|
+
const stylesheet = loadStylesheet(detailRoot, new URL("study.css", assets));
|
|
378
|
+
Promise.all([loadMarkup(page, controller.signal), import("./building-detail.js"), stylesheet]).then(([markupPage, { showBuildingDetail }]) => {
|
|
379
|
+
const markup = markupPage.querySelector(".detail-root");
|
|
380
|
+
if (controller.signal.aborted)
|
|
381
|
+
return;
|
|
382
|
+
if (!markup)
|
|
383
|
+
throw new Error(`${page.pathname} has no building detail.`);
|
|
384
|
+
// The panel names the detail and closes it, so the detail leaves out its own links.
|
|
385
|
+
markup.classList.add("framed");
|
|
386
|
+
detailRoot.append(document.importNode(markup, true));
|
|
387
|
+
showBuildingDetail({ root: detailRoot, id, frame: detailRoot.querySelector(".detail-root"), signal: controller.signal });
|
|
388
|
+
}).catch((error) => {
|
|
389
|
+
if (controller.signal.aborted)
|
|
390
|
+
return;
|
|
391
|
+
// A detail that fails part way is released at once, renderer and listeners included,
|
|
392
|
+
// rather than when the panel closes.
|
|
393
|
+
controller.abort(error);
|
|
394
|
+
console.error(error);
|
|
395
|
+
const message = document.createElement("p");
|
|
396
|
+
message.className = "loading";
|
|
397
|
+
message.setAttribute("role", "status");
|
|
398
|
+
message.textContent = "The building detail could not load. Check that WebGL 2 is enabled, then reload.";
|
|
399
|
+
detailRoot.querySelector(".detail-root")?.remove();
|
|
400
|
+
detailRoot.append(message);
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
function closeDetail() {
|
|
404
|
+
if (detailPanel.hidden)
|
|
405
|
+
return;
|
|
406
|
+
clearDetail();
|
|
407
|
+
detailPanel.hidden = true;
|
|
244
408
|
canvas.focus({ preventScroll: true });
|
|
245
|
-
menu.hidden = true;
|
|
246
|
-
}
|
|
247
|
-
window.addEventListener("pointerdown", (event) => { if (!menu.contains(event.target))
|
|
248
|
-
closeMenu(); }, { capture: true });
|
|
249
|
-
canvas.addEventListener("pointerdown", (event) => {
|
|
250
|
-
press = { x: event.clientX, y: event.clientY, mouse: event.pointerType === "mouse", down: true, moved: false };
|
|
251
|
-
});
|
|
252
|
-
window.addEventListener("pointermove", (event) => {
|
|
253
|
-
if (press?.down && Math.hypot(event.clientX - press.x, event.clientY - press.y) > 4)
|
|
254
|
-
press.moved = true;
|
|
255
|
-
});
|
|
256
|
-
window.addEventListener("pointerup", () => {
|
|
257
|
-
if (!press?.down)
|
|
258
|
-
return;
|
|
259
|
-
press.down = false;
|
|
260
|
-
const pending = pendingMenu;
|
|
261
|
-
pendingMenu = null;
|
|
262
|
-
if (pending && !press.moved)
|
|
263
|
-
openMenu(pending.building, pending.x, pending.y);
|
|
264
|
-
});
|
|
265
|
-
window.addEventListener("pointercancel", () => {
|
|
266
|
-
if (press)
|
|
267
|
-
press.down = false;
|
|
268
|
-
pendingMenu = null;
|
|
269
|
-
});
|
|
270
|
-
canvas.addEventListener("contextmenu", (event) => {
|
|
271
|
-
const building = viewer.buildingAt(event.clientX, event.clientY);
|
|
272
|
-
if (!building)
|
|
273
|
-
return closeMenu();
|
|
274
|
-
event.preventDefault();
|
|
275
|
-
if (press?.down && press.mouse)
|
|
276
|
-
pendingMenu = { building, x: event.clientX, y: event.clientY };
|
|
277
|
-
else if (!(press?.mouse && press.moved))
|
|
278
|
-
openMenu(building, event.clientX, event.clientY);
|
|
279
|
-
});
|
|
280
|
-
canvas.addEventListener("wheel", closeMenu, { passive: true });
|
|
281
|
-
window.addEventListener("resize", closeMenu);
|
|
282
|
-
window.addEventListener("blur", closeMenu);
|
|
283
|
-
menu.addEventListener("keydown", (event) => {
|
|
284
|
-
if (event.key === "Escape" || event.key === "Tab") {
|
|
285
|
-
event.preventDefault();
|
|
286
|
-
closeMenu();
|
|
287
409
|
}
|
|
288
|
-
|
|
410
|
+
function closeDetailOnEscape(event) {
|
|
411
|
+
if (event.key !== "Escape" || event.defaultPrevented || detailPanel.hidden)
|
|
412
|
+
return;
|
|
289
413
|
event.preventDefault();
|
|
290
|
-
|
|
291
|
-
const index = items.findIndex((item) => item === document.activeElement);
|
|
292
|
-
const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1
|
|
293
|
-
: (index + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length;
|
|
294
|
-
items[next]?.focus();
|
|
414
|
+
closeDetail();
|
|
295
415
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
|
|
301
|
-
return;
|
|
302
|
-
event.preventDefault();
|
|
303
|
-
openDetail(menuBuilding, menuTitle.textContent ?? "");
|
|
304
|
-
});
|
|
305
|
-
// The detail floats in a panel over the skyline, in a frame of its own. Each detail gets a
|
|
306
|
-
// fresh frame, so the page's history stays as it was, and closing the panel removes the frame
|
|
307
|
-
// and its 3D view. The skyline stays live around it, and another building's menu replaces the
|
|
308
|
-
// detail. Escape closes it from anywhere on the page or in the detail, unless an open menu or
|
|
309
|
-
// toolbar takes it first, and focus returns to the skyline.
|
|
310
|
-
const detailPanel = document.querySelector("#building-detail");
|
|
311
|
-
const detailClose = document.querySelector("#detail-close");
|
|
312
|
-
function openDetail(id, name) {
|
|
313
|
-
detailPanel.querySelector("iframe")?.remove();
|
|
314
|
-
const frame = document.createElement("iframe");
|
|
315
|
-
frame.title = `${name} — Building Detail`;
|
|
316
|
-
frame.src = `building-detail.html?building=${encodeURIComponent(id)}`;
|
|
317
|
-
frame.addEventListener("load", () => frame.contentWindow?.addEventListener("keydown", closeDetailOnEscape));
|
|
318
|
-
detailPanel.append(frame);
|
|
319
|
-
if (!detailPanel.open)
|
|
320
|
-
detailPanel.show();
|
|
321
|
-
detailClose.focus();
|
|
322
|
-
}
|
|
323
|
-
function closeDetailOnEscape(event) {
|
|
324
|
-
if (event.key !== "Escape" || event.defaultPrevented || !detailPanel.open)
|
|
325
|
-
return;
|
|
326
|
-
event.preventDefault();
|
|
327
|
-
detailPanel.close();
|
|
416
|
+
root.addEventListener("keydown", closeDetailOnEscape, listening);
|
|
417
|
+
detailClose.addEventListener("click", closeDetail, listening);
|
|
418
|
+
signal.addEventListener("abort", clearDetail, { once: true });
|
|
419
|
+
onReady?.();
|
|
328
420
|
}
|
|
329
|
-
window.addEventListener("keydown", closeDetailOnEscape);
|
|
330
|
-
detailPanel.addEventListener("close", () => {
|
|
331
|
-
detailPanel.querySelector("iframe")?.remove();
|
|
332
|
-
canvas.focus({ preventScroll: true });
|
|
333
|
-
});
|
|
334
|
-
detailClose.addEventListener("click", () => detailPanel.close());
|