@a2f0/skyline 0.1.7 → 0.2.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a2f0/skyline",
3
- "version": "0.1.7",
3
+ "version": "0.2.2",
4
4
  "description": "Interactive Chicago skyline viewer and reusable Three.js building models",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -18,6 +18,10 @@
18
18
  "types": "./lib/skyline-package.d.ts",
19
19
  "import": "./lib/skyline-package.js"
20
20
  },
21
+ "./three": {
22
+ "types": "./lib/skyline-three.d.ts",
23
+ "import": "./lib/skyline-three.js"
24
+ },
21
25
  "./scene": {
22
26
  "types": "./lib/skyline-scene.d.ts",
23
27
  "import": "./lib/skyline-scene.js"
@@ -37,6 +41,11 @@
37
41
  "peerDependencies": { "three": "~0.186.0" },
38
42
  "peerDependenciesMeta": { "three": { "optional": true } },
39
43
  "dependencies": { "@types/three": "0.186.0" },
44
+ "overrides": {
45
+ "miniflare": {
46
+ "sharp": "0.35.5"
47
+ }
48
+ },
40
49
  "scripts": {
41
50
  "agent-tool": "agent-tool",
42
51
  "agents:sync": "bun run agent-tool skills install --apply",
@@ -51,16 +60,18 @@
51
60
  "prepublishOnly": "bun run check",
52
61
  "fidelity": "bun scripts/fidelity-report.ts",
53
62
  "renders": "bun scripts/render-study.ts",
63
+ "vendor:three": "bun scripts/vendor-three.ts",
54
64
  "deploy": "scripts/deploy.sh",
55
65
  "verify:deploy": "bun scripts/verify-deploy.ts"
56
66
  },
57
67
  "devDependencies": {
58
- "@a2f0/agent-tool": "0.1.4",
68
+ "@a2f0/agent-tool": "0.1.9",
59
69
  "@tsconfig/strictest": "^2.0.8",
60
70
  "@types/bun": "^1.4.2",
61
- "three": "0.186.0",
71
+ "esbuild": "0.28.2",
72
+ "three": "0.186.1",
62
73
  "playwright": "1.63.0",
63
74
  "typescript": "^7.0.2",
64
- "wrangler": "4.131.2"
75
+ "wrangler": "4.148.0"
65
76
  }
66
77
  }
@@ -0,0 +1,16 @@
1
+ import { showBuildingDetail } from "./building-detail.js";
2
+ // building-detail.html?building=<id>: one building's detail on a page of its own.
3
+ const life = new AbortController();
4
+ try {
5
+ showBuildingDetail({
6
+ root: document,
7
+ id: new URLSearchParams(location.search).get("building"),
8
+ frame: document.querySelector(".detail-root"),
9
+ signal: life.signal,
10
+ });
11
+ }
12
+ catch (error) {
13
+ // A detail that fails part way releases what it started; study-loader.js says why.
14
+ life.abort(error);
15
+ throw error;
16
+ }
@@ -8,47 +8,54 @@
8
8
  <link rel="icon" href="data:," />
9
9
  <title>Building Detail — Chicago Skyline</title>
10
10
  <link rel="stylesheet" href="study.css" />
11
- <!-- Floated over the skyline viewer, the page leaves its links to the viewer's panel. -->
11
+ <!-- Framed by another page, the page leaves its links to the frame's own controls. -->
12
12
  <script>if (window.frameElement) document.documentElement.classList.add("framed");</script>
13
13
  </head>
14
- <body class="building-detail">
15
- <header>
16
- <div>
17
- <p class="eyebrow">Chicago skyline / building detail</p>
18
- <h1 id="building-name">Building detail</h1>
19
- <p class="address" id="building-heights"></p>
20
- </div>
21
- <nav class="study-links" aria-label="Studies"><a href="skyline-study.html">Skyline study →</a><a class="back" href="index.html">← back to skyline</a></nav>
22
- </header>
23
- <main>
24
- <div class="detail-model">
25
- <div class="viewport" id="viewport">
26
- <canvas id="building" tabindex="0" role="img" aria-label="Interactive 3D model of the building" aria-describedby="camera-hint"></canvas>
27
- <span class="view-label" id="view-label">three-quarter view</span>
28
- <p class="loading" id="loading" role="status">Preparing the building…</p>
29
- <p class="hint" id="camera-hint">Drag to orbit · scroll or pinch to zoom<br />Arrow keys rotate · + / − zoom · Home resets</p>
30
- </div>
31
- <div class="toolbar" aria-label="Building detail controls">
32
- <div class="button-group" role="group" aria-label="Camera views">
33
- <button type="button" data-view="front">front</button>
34
- <button type="button" data-view="quarter" aria-pressed="true">three-quarter</button>
35
- <button type="button" data-view="side">side</button>
36
- <button type="button" id="reset">reset view</button>
37
- </div>
38
- <div class="button-group" role="group" aria-label="Model display">
39
- <button type="button" id="wireframe" aria-pressed="false">wireframe</button>
40
- <button type="button" id="turntable" aria-pressed="false" aria-describedby="motion-status">turntable</button>
41
- </div>
14
+ <body>
15
+ <!-- The viewer's panel renders .detail-root in a shadow root of its own (skyline-3d.ts). -->
16
+ <div class="detail-root">
17
+ <div class="detail-frame">
18
+ <div class="building-detail">
19
+ <header>
20
+ <div>
21
+ <p class="eyebrow">Chicago skyline / building detail</p>
22
+ <h1 id="building-name">Building detail</h1>
23
+ <p class="address" id="building-heights"></p>
24
+ </div>
25
+ <nav class="study-links" aria-label="Studies"><a href="skyline-study.html">Skyline study →</a><a class="back" href="index.html">← back to skyline</a></nav>
26
+ </header>
27
+ <main>
28
+ <div class="detail-model">
29
+ <div class="viewport" id="viewport">
30
+ <canvas id="building" tabindex="0" role="img" aria-label="Interactive 3D model of the building" aria-describedby="camera-hint"></canvas>
31
+ <span class="view-label" id="view-label">three-quarter view</span>
32
+ <p class="loading" id="loading" role="status">Preparing the building…</p>
33
+ <p class="hint" id="camera-hint">Drag to orbit · scroll or pinch to zoom<br />Arrow keys rotate · + / − zoom · Home resets</p>
34
+ </div>
35
+ <div class="toolbar" aria-label="Building detail controls">
36
+ <div class="button-group" role="group" aria-label="Camera views">
37
+ <button type="button" data-view="front">front</button>
38
+ <button type="button" data-view="quarter" aria-pressed="true">three-quarter</button>
39
+ <button type="button" data-view="side">side</button>
40
+ <button type="button" id="reset">reset view</button>
41
+ </div>
42
+ <div class="button-group" role="group" aria-label="Model display">
43
+ <button type="button" id="wireframe" aria-pressed="false">wireframe</button>
44
+ <button type="button" id="turntable" aria-pressed="false" aria-describedby="motion-status">turntable</button>
45
+ </div>
46
+ </div>
47
+ </div>
48
+ <div class="notes">
49
+ <p id="building-note"></p>
50
+ <p id="building-facts"></p>
51
+ <p id="motion-status" role="status">Camera moves only when you interact or start the turntable.</p>
52
+ </div>
53
+ <noscript><p>Enable JavaScript to explore the 3D model.</p></noscript>
54
+ </main>
42
55
  </div>
43
56
  </div>
44
- <div class="notes">
45
- <p id="building-note"></p>
46
- <p id="building-facts"></p>
47
- <p id="motion-status" role="status">Camera moves only when you interact or start the turntable.</p>
48
- </div>
49
- <noscript><p>Enable JavaScript to explore the 3D model.</p></noscript>
50
- </main>
51
- <div class="tooltip" id="tooltip" aria-hidden="true" hidden></div>
52
- <script src="study-loader.js" data-study="./building-detail.js"></script>
57
+ <div class="tooltip" id="tooltip" aria-hidden="true" hidden></div>
58
+ </div>
59
+ <script src="study-loader.js" data-study="./building-detail-page.js"></script>
53
60
  </body>
54
61
  </html>
@@ -4,33 +4,36 @@ import { skylineAzimuth } from "./skyline-comparison.js";
4
4
  import { geographicBuildings } from "./models/skyline-geography-data.js";
5
5
  import { createGeographicBuilding, footprintMetrics } from "./models/skyline-geography.js";
6
6
  // One mapped building of the 3D skyline, alone on its own platform, as the building study
7
- // shows Crain: building-detail.html?building=<id>. The skyline viewer floats it over the
8
- // skyline from a building's context menu.
9
- const id = new URLSearchParams(location.search).get("building");
10
- const record = geographicBuildings.find((entry) => entry.id === id);
11
- if (record)
12
- showBuilding(record);
13
- else {
14
- document.querySelector("#loading").textContent = "No mapped building by that name. Right-click a building on the 3D skyline to see its detail.";
15
- document.querySelectorAll("button").forEach((button) => { button.disabled = true; });
7
+ // shows Crain: building-detail.html?building=<id>, and the panel the 3D skyline floats over
8
+ // the skyline from a building's context menu.
9
+ export function showBuildingDetail({ root, id, frame, signal }) {
10
+ const record = geographicBuildings.find((entry) => entry.id === id);
11
+ if (record)
12
+ showBuilding(record, root, frame, signal);
13
+ else {
14
+ root.querySelector("#loading").textContent = "No mapped building by that name. Right-click a building on the 3D skyline to see its detail.";
15
+ root.querySelectorAll("button").forEach((button) => { button.disabled = true; });
16
+ }
16
17
  }
17
- function showBuilding(record) {
18
+ function showBuilding(record, root, frame, signal) {
18
19
  const footprint = footprintMetrics(record.footprint.coordinates);
19
20
  // Centred on its mapped outline: ground coordinates are east and north, the scene's x and -z.
20
21
  const model = createGeographicBuilding(record, [-footprint.center[0], footprint.center[1]]);
21
22
  // The page names the building before the 3D view starts, so a failed start leaves it named.
22
- document.title = `${record.name} — Building Detail`;
23
- document.querySelector("#building-name").textContent = record.name;
24
- document.querySelector("#building").setAttribute("aria-label", `Interactive 3D model of ${record.name}`);
23
+ if (root instanceof Document)
24
+ root.title = `${record.name} — Building Detail`;
25
+ root.querySelector("#building-name").textContent = record.name;
26
+ root.querySelector("#building").setAttribute("aria-label", `Interactive 3D model of ${record.name}`);
27
+ const document = root instanceof Document ? root : root.ownerDocument;
25
28
  const number = (n) => `${Number(n.toFixed(1))} m`;
26
- const heights = document.querySelector("#building-heights");
29
+ const heights = root.querySelector("#building-heights");
27
30
  // As the skyline study's table gives them.
28
31
  const osm = record.heightSource.startsWith("https://www.openstreetmap.org/way/") ? " (OSM)" : "";
29
- const source = link(record.heightSource, record.heightFromDrawing ? `${number(record.height)}, measured on the drawing` : `${number(record.height)} architectural${osm}`);
32
+ const source = link(document, record.heightSource, record.heightFromDrawing ? `${number(record.height)}, measured on the drawing` : `${number(record.height)} architectural${osm}`);
30
33
  heights.replaceChildren(source, ...record.tipHeight !== record.height ? [` · tip ${number(record.tipHeight)}`] : []);
31
- document.querySelector("#building-note").textContent = record.note;
32
- const way = link(`https://www.openstreetmap.org/way/${record.footprint.way}`, `OpenStreetMap way ${record.footprint.way}`);
33
- document.querySelector("#building-facts").replaceChildren(`Mapped outline ${footprint.size[0].toFixed(1)} × ${footprint.size[1].toFixed(1)} m, from `, way, `. Model: ${model.triangleCount.toLocaleString("en-US")} triangles.`);
34
+ root.querySelector("#building-note").textContent = record.note;
35
+ const way = link(document, `https://www.openstreetmap.org/way/${record.footprint.way}`, `OpenStreetMap way ${record.footprint.way}`);
36
+ root.querySelector("#building-facts").replaceChildren(`Mapped outline ${footprint.size[0].toFixed(1)} × ${footprint.size[1].toFixed(1)} m, from `, way, `. Model: ${model.triangleCount.toLocaleString("en-US")} triangles.`);
34
37
  const min = new THREE.Vector3(Infinity, Infinity, Infinity), max = new THREE.Vector3(-Infinity, -Infinity, -Infinity);
35
38
  model.building.updateMatrixWorld(true);
36
39
  model.building.traverse((child) => {
@@ -60,6 +63,9 @@ function showBuilding(record) {
60
63
  side: { azimuth: skylineAzimuth + Math.PI / 2, polar: Math.PI / 2 - 0.12, label: "side view" },
61
64
  };
62
65
  createBuildingStudy({
66
+ root,
67
+ frame,
68
+ signal,
63
69
  models: [model],
64
70
  views,
65
71
  target: [0, height * 0.48, 0],
@@ -74,7 +80,7 @@ function showBuilding(record) {
74
80
  });
75
81
  }
76
82
  // Sources open beside the page, so a detail floated over the skyline stays where it is.
77
- function link(href, text) {
83
+ function link(document, href, text) {
78
84
  const anchor = document.createElement("a");
79
85
  anchor.href = href;
80
86
  anchor.textContent = text;
package/site/index.html CHANGED
@@ -5,21 +5,17 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <meta name="robots" content="noindex" />
7
7
  <meta name="theme-color" content="#000000" />
8
+ <link rel="icon" href="data:," />
8
9
  <title>Chicago Skyline — Living Night</title>
9
- <script>
10
- if (new URLSearchParams(location.search).get("embed") === "1") {
11
- document.documentElement.dataset.embedded = "true";
12
- }
13
- </script>
10
+ <!-- viewer-page.js mounts the viewer from skyline-viewer.js, its styles and skyline-3d.html,
11
+ and starts the scene's code; fetch them alongside it. -->
12
+ <link rel="modulepreload" href="skyline-viewer.js" />
13
+ <link rel="preload" href="viewer.css" as="style" />
14
+ <link rel="preload" href="study.css" as="style" />
14
15
  <style>
15
16
  :root {
16
17
  color-scheme: dark;
17
18
  background: #000;
18
- font-family: Georgia, "Times New Roman", serif;
19
- }
20
-
21
- * {
22
- box-sizing: border-box;
23
19
  }
24
20
 
25
21
  html,
@@ -32,376 +28,28 @@
32
28
  background: #000;
33
29
  }
34
30
 
35
- .stars,
36
- .scene {
37
- position: fixed;
38
- inset: 0;
39
- display: block;
40
- width: 100%;
41
- height: 100%;
42
- border: 0;
43
- background: transparent;
44
- }
45
-
46
- .stars {
47
- z-index: 0;
48
- pointer-events: none;
49
- }
50
-
51
- .scene {
52
- z-index: 1;
53
- }
54
-
55
- .scene[hidden] {
56
- display: none;
57
- }
58
-
59
- .controls {
60
- position: fixed;
61
- z-index: 2;
62
- top: max(12px, env(safe-area-inset-top));
63
- left: 50%;
64
- display: flex;
65
- flex-wrap: wrap;
66
- align-items: baseline;
67
- justify-content: center;
68
- gap: 10px clamp(18px, 2vw, 28px);
69
- width: max-content;
70
- max-width: calc(100% - 24px);
71
- transform: translateX(-50%);
72
- color: #fff;
73
- font-size: clamp(18px, 1.85vw, 26px);
74
- line-height: 1;
75
- white-space: nowrap;
76
- }
77
-
78
- .controls button,
79
- .controls a {
80
- appearance: none;
31
+ .notice {
32
+ box-sizing: border-box;
81
33
  margin: 0;
82
- border: 0;
83
- border-radius: 0;
84
- padding: 0;
85
- background: transparent;
86
- color: inherit;
87
- font: inherit;
88
- line-height: inherit;
89
- text-decoration: underline;
90
- text-decoration-thickness: 1px;
91
- text-underline-offset: 3px;
92
- cursor: pointer;
93
- opacity: 0.88;
94
- }
95
-
96
- html[data-embedded="true"] .controls {
97
- display: none;
98
- }
99
-
100
- .controls button:hover,
101
- .controls a:hover,
102
- .controls button:focus-visible,
103
- .controls a:focus-visible,
104
- .controls button[aria-pressed="true"] {
105
- color: #fff;
106
- opacity: 1;
107
- text-decoration-thickness: 2px;
108
- }
109
-
110
- .controls button:focus-visible,
111
- .controls a:focus-visible {
112
- outline: 1px solid #fff;
113
- outline-offset: 6px;
114
- }
115
-
116
- .original-return {
117
- position: fixed;
118
- z-index: 2;
119
- right: max(12px, env(safe-area-inset-right));
120
- bottom: max(5px, env(safe-area-inset-bottom));
121
- border: 1px solid #393939;
122
- border-radius: 2px;
123
- padding: 5px 8px;
124
- background: #101010;
34
+ padding: 24px;
125
35
  color: #aaa;
126
- font: 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
127
- cursor: pointer;
128
- }
129
-
130
- .original-return:hover { color: #fff; border-color: #999; background: #202020; }
131
- .original-return:focus-visible { outline: 1px solid #fff; outline-offset: 2px; }
132
-
133
- .motion-status {
134
- flex-basis: 100%;
135
- margin: 0;
136
- color: #999;
137
- font: 12px/1.5 system-ui, sans-serif;
138
- text-align: center;
139
- white-space: normal;
140
- }
141
-
142
- .visually-hidden {
143
- position: absolute;
144
- width: 1px;
145
- height: 1px;
146
- padding: 0;
147
- margin: -1px;
148
- overflow: hidden;
149
- clip: rect(0, 0, 0, 0);
150
- white-space: nowrap;
151
- border: 0;
152
- }
153
-
154
- @media (max-width: 520px) {
155
- .controls {
156
- top: max(16px, env(safe-area-inset-top));
157
- gap: 10px 18px;
158
- }
36
+ font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
159
37
  }
160
38
  </style>
161
39
  </head>
162
40
  <body>
163
- <main id="stage">
164
- <iframe
165
- class="stars"
166
- id="stars"
167
- src="stars.svg"
168
- title="Twinkling night sky"
169
- aria-hidden="true"
170
- tabindex="-1"
171
- ></iframe>
172
- <iframe
173
- class="scene"
174
- id="skyline-3d-scene"
175
- src="skyline-3d.html"
176
- title="3D Chicago skyline"
177
- ></iframe>
178
- <iframe
179
- class="scene"
180
- id="scene"
181
- title="Animated Chicago skyline at night"
182
- hidden
183
- ></iframe>
184
- <iframe
185
- class="scene"
186
- id="webgl-scene"
187
- data-src="skyline-webgl.html"
188
- title="WebGL Chicago skyline prototype"
189
- hidden
190
- ></iframe>
191
-
192
- <nav class="controls" aria-label="Skyline controls">
193
- <button type="button" id="fullscreen" aria-pressed="false">fullscreen</button>
194
- <button type="button" id="toggle-skyline" aria-pressed="false">show original</button>
195
- <button type="button" id="toggle-webgl" aria-pressed="false">show webgl</button>
196
- <button type="button" id="toggle-enhanced" aria-pressed="false">show enhanced</button>
197
- <a href="building-study.html">3d building</a>
198
- <a href="skyline-study.html">skyline study</a>
199
- <button type="button" id="debug-motion" aria-pressed="false" aria-describedby="motion-status">debug motion</button>
200
- <p class="motion-status" id="motion-status" role="status">Checking motion preference…</p>
201
- </nav>
202
-
203
- <button type="button" class="original-return" id="return-skyline-3d" title="Return to the 3D skyline" hidden>3d skyline</button>
204
- <button type="button" class="original-return" id="embedded-original" title="Compare with the original skyline SVG" hidden>show original</button>
205
- <p class="visually-hidden" id="status" aria-live="polite"></p>
206
- </main>
207
-
41
+ <!-- The viewer renders into a shadow root inside this element, as in a host's page. -->
42
+ <main id="viewer"><noscript><p class="notice">Enable JavaScript to explore the Chicago skyline.</p></noscript></main>
208
43
  <script>
209
- const fullscreenButton = document.querySelector("#fullscreen");
210
- const status = document.querySelector("#status");
211
- const scene = document.querySelector("#scene");
212
- const webglScene = document.querySelector("#webgl-scene");
213
- const skyline3dScene = document.querySelector("#skyline-3d-scene");
214
- const stars = document.querySelector("#stars");
215
- const debugMotionButton = document.querySelector("#debug-motion");
216
- const motionStatus = document.querySelector("#motion-status");
217
- const originalReturn = document.querySelector("#return-skyline-3d");
218
- const embeddedOriginal = document.querySelector("#embedded-original");
219
- const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
220
- // Modes shown in their own page. The 3D skyline loads with the viewer; the others load
221
- // the first time they are chosen.
222
- const framedScenes = { webgl: webglScene, "3d": skyline3dScene };
223
- // The viewer opens on the 3D skyline. Each toggle shows its mode and, pressed again,
224
- // returns to the 3D skyline.
225
- const homeMode = "3d";
226
- const home = { label: "3d skyline", title: "Return to the 3D skyline", status: "3D skyline displayed" };
227
- const toggles = {
228
- original: {
229
- button: document.querySelector("#toggle-skyline"),
230
- label: "show original",
231
- title: "Compare with the original skyline SVG",
232
- status: "Original skyline displayed",
233
- },
234
- webgl: {
235
- button: document.querySelector("#toggle-webgl"),
236
- label: "show webgl",
237
- title: "Preview the WebGL skyline prototype",
238
- status: "WebGL skyline prototype displayed",
239
- },
240
- enhanced: {
241
- button: document.querySelector("#toggle-enhanced"),
242
- label: "show enhanced",
243
- title: "Show the enhanced interactive skyline drawing",
244
- status: "Enhanced interactive skyline displayed",
245
- },
246
- };
247
- let debugMotion = false;
248
- let skylineMode = homeMode;
249
- // Keep SVG recovery reachable until the first 3D frame succeeds, even if
250
- // the scene document or its bootstrap script never initializes.
251
- let sceneUnavailable = true;
252
-
253
- function updateSkylineView() {
254
- const framedScene = framedScenes[skylineMode];
255
-
256
- if (framedScene && !framedScene.getAttribute("src")) {
257
- framedScene.src = framedScene.dataset.src;
258
- }
259
- for (const [mode, frame] of Object.entries(framedScenes)) {
260
- frame.hidden = mode !== skylineMode;
261
- }
262
- if (!framedScene) {
263
- const showingOriginal = skylineMode === "original";
264
- const nextSource = showingOriginal ? "skyline-original-fit.svg" : "skyline-animated.svg";
265
- if (scene.getAttribute("src") !== nextSource) {
266
- scene.src = nextSource;
267
- }
268
- scene.title = showingOriginal
269
- ? "Original Chicago skyline"
270
- : "Animated Chicago skyline at night";
271
- }
272
- scene.hidden = Boolean(framedScene);
273
- originalReturn.hidden = skylineMode !== "original";
274
- embeddedOriginal.hidden = document.documentElement.dataset.embedded !== "true" || skylineMode !== homeMode || !sceneUnavailable;
275
-
276
- for (const [mode, toggle] of Object.entries(toggles)) {
277
- const showing = mode === skylineMode;
278
- toggle.button.textContent = showing ? home.label : toggle.label;
279
- toggle.button.setAttribute("aria-pressed", String(showing));
280
- toggle.button.title = showing ? home.title : toggle.title;
281
- }
282
- }
283
-
284
- function showSkylineMode(mode) {
285
- skylineMode = mode;
286
- updateSkylineView();
287
- status.textContent = mode === homeMode ? home.status : toggles[mode].status;
288
- }
289
-
290
- for (const [mode, toggle] of Object.entries(toggles)) {
291
- toggle.button.addEventListener("click", () => {
292
- showSkylineMode(skylineMode === mode ? homeMode : mode);
293
- });
294
- }
295
-
296
- // The scene's bottom shortcut uses the viewer's mode switch. Only this scene, on
297
- // this origin and while it is displayed, can request the original drawing.
298
- window.addEventListener("message", (event) => {
299
- if (event.origin !== location.origin || event.source !== skyline3dScene.contentWindow) return;
300
- if (event.data?.type === "skyline:unavailable" || event.data?.type === "skyline:ready") {
301
- sceneUnavailable = event.data.type === "skyline:unavailable";
302
- updateSkylineView();
303
- return;
304
- }
305
- if (event.data?.type !== "skyline:show-original" || skylineMode !== homeMode) return;
306
- showSkylineMode("original");
307
- originalReturn.focus();
308
- });
309
- originalReturn.addEventListener("click", () => {
310
- showSkylineMode(homeMode);
311
- const sceneDocument = skyline3dScene.contentDocument;
312
- const star = sceneDocument?.querySelector("#menu-toggle");
313
- const focus = star?.getAttribute("aria-expanded") === "true" ? sceneDocument.querySelector("#show-original") : star;
314
- const target = !sceneUnavailable && focus?.matches(":enabled") ? focus : null;
315
- target?.focus();
316
- if (!target || sceneDocument?.activeElement !== target) {
317
- (embeddedOriginal.hidden ? toggles.original.button : embeddedOriginal).focus();
318
- }
319
- });
320
- embeddedOriginal.addEventListener("click", () => {
321
- showSkylineMode("original");
322
- originalReturn.focus();
323
- });
324
-
325
- updateSkylineView();
326
-
327
- function updateMotionStatus() {
328
- const preference = reducedMotion.matches ? "reduced" : "allowed";
329
- const animation = debugMotion
330
- ? (reducedMotion.matches ? "debug override active" : "debug animation")
331
- : (reducedMotion.matches ? "stars paused" : "stars twinkling");
332
- motionStatus.textContent = `System motion: ${preference} · ${animation}`;
333
- debugMotionButton.textContent = debugMotion ? "stop debug" : "debug motion";
334
- debugMotionButton.setAttribute("aria-pressed", String(debugMotion));
335
- debugMotionButton.title = debugMotion
336
- ? "Return to the system motion setting"
337
- : "Preview slightly stronger brightness changes at the same gentle pace, even when reduced motion is enabled";
338
- }
339
-
340
- debugMotionButton.addEventListener("click", () => {
341
- debugMotion = !debugMotion;
342
- // Keep a fragment when stopping so the randomized sky does not reload.
343
- stars.src = `stars.svg#${debugMotion ? "debug-motion" : ""}`;
344
- updateMotionStatus();
345
- });
346
-
347
- reducedMotion.addEventListener("change", updateMotionStatus);
348
- updateMotionStatus();
349
-
350
- async function toggleFullscreen() {
351
- try {
352
- if (document.fullscreenElement) {
353
- await document.exitFullscreen();
354
- } else {
355
- await document.documentElement.requestFullscreen();
356
- }
357
- } catch {
358
- status.textContent = "Fullscreen is not available in this browser";
359
- }
360
- }
361
-
362
- fullscreenButton.addEventListener("click", toggleFullscreen);
363
-
364
- document.addEventListener("fullscreenchange", () => {
365
- const isFullscreen = Boolean(document.fullscreenElement);
366
- fullscreenButton.textContent = isFullscreen ? "exit fullscreen" : "fullscreen";
367
- fullscreenButton.setAttribute("aria-pressed", String(isFullscreen));
368
- status.textContent = isFullscreen ? "Fullscreen enabled" : "Fullscreen exited";
369
- });
370
-
371
- if (!document.fullscreenEnabled) {
372
- fullscreenButton.hidden = true;
373
- }
374
-
375
- function fullscreenShortcut(event) {
376
- if (event.key.toLowerCase() === "f" && !event.metaKey && !event.ctrlKey && !event.altKey) {
377
- event.preventDefault();
378
- toggleFullscreen();
379
- }
380
- }
381
-
382
- document.addEventListener("keydown", fullscreenShortcut);
383
- // Keys pressed inside a scene, such as on the 3D skyline's canvas, go to its frame's own
384
- // window, so listen there too. A frame from another origin, as under file://, keeps them.
385
- function listenInFrame(frame) {
386
- try {
387
- frame.contentWindow.addEventListener("keydown", fullscreenShortcut);
388
- } catch {}
389
- }
390
- for (const frame of [skyline3dScene, scene, webglScene]) {
391
- listenInFrame(frame);
392
- frame.addEventListener("load", () => listenInFrame(frame));
393
- }
394
-
395
- // The controls lie over the top of the 3D skyline's frame, which floats a building's
396
- // detail below them.
397
- const controls = document.querySelector(".controls");
398
- function insetSkyline() {
399
- try {
400
- skyline3dScene.contentDocument.documentElement.style.setProperty("--viewer-controls", `${Math.ceil(controls.getBoundingClientRect().bottom)}px`);
401
- } catch {}
402
- }
403
- new ResizeObserver(insetSkyline).observe(controls);
404
- skyline3dScene.addEventListener("load", insetSkyline);
44
+ // Modules do not load from file://, and the sources hold no compiled modules.
45
+ function showNotice(text) {
46
+ const notice = document.createElement("p");
47
+ notice.className = "notice";
48
+ notice.textContent = text;
49
+ document.querySelector("#viewer").replaceChildren(notice);
50
+ }
51
+ if (location.protocol === "file:") showNotice("Start a local server with python3 -m http.server -d dist, then open http://localhost:8000 to explore the skyline.");
405
52
  </script>
53
+ <script type="module" src="viewer-page.js" onerror="showNotice('Build the site with bun run build:site, then serve dist/ with python3 -m http.server -d dist to explore the skyline.')"></script>
406
54
  </body>
407
55
  </html>