@a2f0/skyline 0.2.62 → 0.2.64

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 CHANGED
@@ -23,7 +23,7 @@ Serve `dist/` over HTTP rather than opening files directly: WebGL and the 3D pag
23
23
 
24
24
  | Page | Shows | Docs |
25
25
  | --- | --- | --- |
26
- | `index.html` | The viewer, mounted as the package mounts it: opens on the 3D skyline, with the enhanced drawing, the original drawing, and a WebGL prototype one button away | [The viewer](docs/viewer.md) |
26
+ | `index.html` | The viewer, mounted as the package mounts it: opens on the 3D skyline, with the original drawing one button away and links to the enhanced drawing and the WebGL prototype | [The viewer](docs/viewer.md) |
27
27
  | `skyline-3d.html` | The full-screen 3D skyline on its own: camera views, streets, footprints, celebratory lights, and a building menu | [Full-screen 3D skyline](docs/viewer.md#full-screen-3d-skyline) |
28
28
  | `building-detail.html?building=<id>` | One mapped building alone on its platform | [Building menu and detail](docs/viewer.md#building-menu-and-detail) |
29
29
  | `skyline-study.html` | The 3D scene over the drawing, in the geographic layout or the original drawing's fit | [3D studies](docs/studies.md#skyline-study) |
package/docs/package.md CHANGED
@@ -312,8 +312,8 @@ Before 0.2, the viewer ran in an iframe. Hosts upgrading:
312
312
  - If the viewer's or scene's code fails to load, as on a dropped connection, the
313
313
  browser keeps that failure for the page: later mounts on the same page fail the same
314
314
  way until it reloads. A frame used to retry with a new document.
315
- - The site's own `index.html` shows two prototypes from its toolbar, the hover-scripted
316
- enhanced drawing and the WebGL prototype, as pages of their own in frames; a host's
315
+ - The site's own `index.html` links two prototypes from its toolbar, the hover-scripted
316
+ enhanced drawing and the WebGL prototype, which open as pages of their own; a host's
317
317
  viewer never shows them.
318
318
 
319
319
  ## Import a scene or a building
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a2f0/skyline",
3
- "version": "0.2.62",
3
+ "version": "0.2.64",
4
4
  "description": "Interactive Chicago skyline viewer and reusable Three.js building models",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -6,6 +6,41 @@ import { createGeographicRoads, geographicOrigin } from "./models/skyline-geogra
6
6
  import { celebrations } from "./models/celebrations.js";
7
7
  import { createDaylight, daylightGround, deferSkylineColour } from "./models/colour-materials.js";
8
8
  import { loadMarkup, loadStylesheet } from "./markup.js";
9
+ /**
10
+ * The press's next state, and the menu to open now, if any. A mouse press holds the menu its
11
+ * event asks for until it is released without moving, so a right-drag still pans; a touch's
12
+ * long press, or an event after a release, opens at once, unless a mouse dragged first.
13
+ */
14
+ export function pressTransition(press, event) {
15
+ const stay = { press, open: null };
16
+ switch (event.type) {
17
+ case "down":
18
+ return { press: { status: "down", x: event.x, y: event.y, mouse: event.mouse, moved: false, pending: null }, open: null };
19
+ case "move":
20
+ if (press.status !== "down" || press.moved || Math.hypot(event.x - press.x, event.y - press.y) <= 4)
21
+ return stay;
22
+ return { press: { ...press, moved: true }, open: null };
23
+ case "up":
24
+ if (press.status !== "down")
25
+ return stay;
26
+ return { press: { status: "released", mouse: press.mouse, moved: press.moved }, open: press.moved ? null : press.pending };
27
+ case "cancel":
28
+ if (press.status !== "down")
29
+ return stay;
30
+ return { press: { status: "released", mouse: press.mouse, moved: press.moved }, open: null };
31
+ case "menu":
32
+ if (press.status === "down" && press.mouse)
33
+ return { press: { ...press, pending: event.menu }, open: null };
34
+ return { press, open: press.status !== "idle" && press.mouse && press.moved ? null : event.menu };
35
+ case "dismiss":
36
+ return press.status === "down" && press.pending ? { press: { ...press, pending: null }, open: null } : stay;
37
+ default:
38
+ return assertNever(event);
39
+ }
40
+ }
41
+ function assertNever(value) {
42
+ throw new Error(`Unhandled press event: ${JSON.stringify(value)}`);
43
+ }
9
44
  // The skyline viewer's full-screen 3D mode: every mapped building, seen through the
10
45
  // drawing's own camera and framed as the viewer frames the drawing, so each tower stands
11
46
  // where its drawn one does until the camera moves. It renders into skyline-3d.html's own
@@ -266,8 +301,13 @@ export function startSkyline3d({ root, frame, assets, signal, colour: initiallyC
266
301
  const detailLink = root.querySelector("#building-detail-link");
267
302
  const lightingMenu = root.querySelector("#building-lighting-menu");
268
303
  let menuBuilding = "";
269
- let press = null;
270
- let pendingMenu = null;
304
+ let press = { status: "idle" };
305
+ function sendPress(event) {
306
+ const next = pressTransition(press, event);
307
+ press = next.press;
308
+ if (next.open)
309
+ openMenu(next.open.building, next.open.x, next.open.y);
310
+ }
271
311
  function openMenu(building, x, y) {
272
312
  menuBuilding = building.building.userData["buildingId"];
273
313
  menuTitle.textContent = building.building.userData["geography"]?.name ?? building.building.name;
@@ -310,7 +350,7 @@ export function startSkyline3d({ root, frame, assets, signal, colour: initiallyC
310
350
  detailLink.focus({ preventScroll: true });
311
351
  }
312
352
  function closeMenu() {
313
- pendingMenu = null;
353
+ sendPress({ type: "dismiss" });
314
354
  if (menu.hidden)
315
355
  return;
316
356
  if (menu.contains(root.activeElement))
@@ -329,35 +369,17 @@ export function startSkyline3d({ root, frame, assets, signal, colour: initiallyC
329
369
  menuPress = null;
330
370
  }, listening);
331
371
  canvas.addEventListener("pointerdown", (event) => {
332
- press = { x: event.clientX, y: event.clientY, mouse: event.pointerType === "mouse", down: true, moved: false };
333
- }, listening);
334
- window.addEventListener("pointermove", (event) => {
335
- if (press?.down && Math.hypot(event.clientX - press.x, event.clientY - press.y) > 4)
336
- press.moved = true;
337
- }, listening);
338
- window.addEventListener("pointerup", () => {
339
- if (!press?.down)
340
- return;
341
- press.down = false;
342
- const pending = pendingMenu;
343
- pendingMenu = null;
344
- if (pending && !press.moved)
345
- openMenu(pending.building, pending.x, pending.y);
346
- }, listening);
347
- window.addEventListener("pointercancel", () => {
348
- if (press)
349
- press.down = false;
350
- pendingMenu = null;
372
+ sendPress({ type: "down", x: event.clientX, y: event.clientY, mouse: event.pointerType === "mouse" });
351
373
  }, listening);
374
+ window.addEventListener("pointermove", (event) => sendPress({ type: "move", x: event.clientX, y: event.clientY }), listening);
375
+ window.addEventListener("pointerup", () => sendPress({ type: "up" }), listening);
376
+ window.addEventListener("pointercancel", () => sendPress({ type: "cancel" }), listening);
352
377
  canvas.addEventListener("contextmenu", (event) => {
353
378
  const building = viewer.buildingAt(event.clientX, event.clientY);
354
379
  if (!building)
355
380
  return closeMenu();
356
381
  event.preventDefault();
357
- if (press?.down && press.mouse)
358
- pendingMenu = { building, x: event.clientX, y: event.clientY };
359
- else if (!(press?.mouse && press.moved))
360
- openMenu(building, event.clientX, event.clientY);
382
+ sendPress({ type: "menu", menu: { building, x: event.clientX, y: event.clientY } });
361
383
  }, listening);
362
384
  canvas.addEventListener("wheel", closeMenu, { passive: true, signal });
363
385
  window.addEventListener("blur", closeMenu, listening);
@@ -5,9 +5,6 @@
5
5
  preserveAspectRatio="xMidYMax meet"
6
6
  version="1.1"
7
7
  id="svg6766"
8
- role="img"
9
- aria-label="Chicago skyline at night"
10
- aria-describedby="skyline-description"
11
8
  style="color-scheme:dark"
12
9
  sodipodi:docname="skyline-animated.svg"
13
10
  inkscape:version="1.1 (c4e8f9e, 2021-05-24)"
@@ -144,12 +141,22 @@
144
141
  .building-tooltip {
145
142
  pointer-events: none;
146
143
  }
144
+ .back-link text {
145
+ fill: #a8a8a8;
146
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
147
+ text-decoration: underline;
148
+ }
149
+ .back-link:hover text,
150
+ .back-link:focus-visible text {
151
+ fill: #ffffff;
152
+ }
147
153
  @media (prefers-reduced-motion: reduce) {
148
154
  .interactive-building.is-active { transition: none; }
149
155
  }
150
156
  ]]></style>
151
157
  </defs>
152
- <g id="skyline-position" transform="translate(1105.5923,-87.26366)">
158
+ <!-- The drawing is one image; the root stays a plain document so the link back reaches assistive technology. -->
159
+ <g id="skyline-position" transform="translate(1105.5923,-87.26366)" role="img" aria-label="Chicago skyline at night" aria-describedby="skyline-description">
153
160
  <g
154
161
  inkscape:groupmode="layer"
155
162
  id="layer4"
@@ -18670,6 +18677,17 @@
18670
18677
  style="display:inline;stroke-width:1.33526" />
18671
18678
  </g>
18672
18679
  </g>
18680
+ <!-- Opened on its own, the drawing links back to the viewer; the script keeps it in the top-left corner. -->
18681
+ <a
18682
+ id="back-link"
18683
+ class="back-link"
18684
+ href="index.html">
18685
+ <text
18686
+ id="back-link-text"
18687
+ font-size="70"
18688
+ x="40"
18689
+ y="110">← back to skyline</text>
18690
+ </a>
18673
18691
  <g
18674
18692
  id="building-tooltip"
18675
18693
  class="building-tooltip"
@@ -18788,6 +18806,21 @@
18788
18806
  }
18789
18807
  }
18790
18808
 
18809
+ // The way back to the viewer: 12px text, 12px in from the window's top left, at every
18810
+ // window size.
18811
+ const backLink = document.querySelector("#back-link-text");
18812
+ function placeBackLink() {
18813
+ const matrix = svg.getScreenCTM();
18814
+ if (!matrix) return;
18815
+ const scale = Math.hypot(matrix.a, matrix.b) || 1;
18816
+ const start = svgPoint(0, 0, matrix.inverse());
18817
+ backLink.setAttribute("font-size", 12 / scale);
18818
+ backLink.setAttribute("x", start.x + 12 / scale);
18819
+ backLink.setAttribute("y", start.y + 24 / scale);
18820
+ }
18821
+ placeBackLink();
18822
+ window.addEventListener("resize", placeBackLink, { passive: true });
18823
+
18791
18824
  svg.addEventListener("pointerover", updateHover, { passive: true });
18792
18825
  svg.addEventListener("pointermove", updateHover, { passive: true });
18793
18826
  svg.addEventListener("pointerleave", deactivate);
@@ -1,20 +1,33 @@
1
1
  import { loadMarkup } from "./markup.js";
2
2
  import { provideThree } from "./three-engine.js";
3
- // What each of the site toolbar's mode buttons shows, says and announces. The viewer opens
4
- // on the 3D skyline; each toggle shows its mode and, pressed again, returns to it.
3
+ // What the site toolbar's mode button shows, says and announces. The viewer opens on the 3D
4
+ // skyline; the toggle shows the original drawing and, pressed again, returns to it.
5
5
  const home = { label: "3d skyline", title: "Return to the 3D skyline", status: "3D skyline displayed" };
6
6
  const toggles = {
7
7
  original: { id: "toggle-skyline", label: "show original", title: "Compare with the original skyline SVG", status: "Original skyline displayed" },
8
- webgl: { id: "toggle-webgl", label: "show webgl", title: "Preview the WebGL skyline prototype", status: "WebGL skyline prototype displayed" },
9
- enhanced: { id: "toggle-enhanced", label: "show enhanced", title: "Show the enhanced interactive skyline drawing", status: "Enhanced interactive skyline displayed" },
10
- };
11
- // The two prototypes the site's toolbar shows in place are pages of their own, as the
12
- // studies are: the hover-scripted drawing and the WebGL prototype. Only the site's own
13
- // index.html shows them, in frames; a host's viewer never does.
14
- const prototypes = {
15
- enhanced: { src: "skyline-animated.svg", title: "Animated Chicago skyline at night" },
16
- webgl: { src: "skyline-webgl.html", title: "WebGL Chicago skyline prototype" },
17
8
  };
9
+ // The two prototypes are pages of their own, as the studies are: the hover-scripted drawing
10
+ // and the WebGL prototype. The site's toolbar links to them, and each page keeps its own
11
+ // scripts and a way back; a host's viewer never shows them.
12
+ const prototypes = [
13
+ { id: "link-webgl", href: "skyline-webgl.html", label: "webgl prototype", title: "Open the WebGL skyline prototype", ct: "viewer.webgl" },
14
+ { id: "link-enhanced", href: "skyline-animated.svg", label: "enhanced drawing", title: "Open the enhanced interactive skyline drawing", ct: "viewer.enhanced" },
15
+ ];
16
+ export function sceneTransition(status, event) {
17
+ switch (event) {
18
+ case "ready":
19
+ return status === "booting" ? "ready" : status;
20
+ case "failed":
21
+ return status === "booting" ? "unavailable" : status;
22
+ case "lost":
23
+ return "unavailable";
24
+ default:
25
+ return assertNever(event);
26
+ }
27
+ }
28
+ function assertNever(value) {
29
+ throw new Error(`Unhandled scene event: ${String(value)}`);
30
+ }
18
31
  const SCENE_FAILURE = "The 3D preview could not load. Check that WebGL 2 is enabled, then reload. You can still compare the SVG or return to the skyline.";
19
32
  function element(document, tag, properties = {}, attributes = {}) {
20
33
  const created = Object.assign(document.createElement(tag), properties);
@@ -48,26 +61,18 @@ export function mount(root, { assets, signal, controls, colour, theme, attributi
48
61
  viewer.append(stars, scene, drawing, originalReturn, embeddedOriginal, status);
49
62
  root.append(viewer);
50
63
  let mode = "3d";
51
- // Keep the drawing reachable if the scene's markup or code fails to load,
52
- // or its graphics context is lost.
53
- let sceneUnavailable = true;
54
- // Both show original buttons wait while the scene boots, until its first frame,
55
- // a failure to load, or a lost graphics context.
56
- let booting = true;
57
- const frames = {};
64
+ // Both show original buttons wait while the scene boots, until its first frame, a failure
65
+ // to load, or a lost graphics context; the drawing stays reachable while it is unavailable.
66
+ let sceneStatus = "booting";
58
67
  const toolbar = navigation ? createToolbar() : null;
59
68
  function updateView() {
60
- viewer.toggleAttribute("data-booting", sceneUnavailable && mode === "3d");
69
+ viewer.toggleAttribute("data-booting", sceneStatus !== "ready" && mode === "3d");
61
70
  scene.hidden = mode !== "3d";
62
71
  if (mode === "original" && !drawing.getAttribute("src"))
63
72
  drawing.src = new URL("skyline-original-fit.svg", assets).href;
64
73
  drawing.hidden = mode !== "original";
65
- if (mode === "enhanced" || mode === "webgl")
66
- frames[mode] ??= createPrototype(mode);
67
- for (const [name, frame] of Object.entries(frames))
68
- frame.hidden = name !== mode;
69
74
  originalReturn.hidden = mode !== "original";
70
- embeddedOriginal.hidden = navigation || mode !== "3d" || booting || !sceneUnavailable;
75
+ embeddedOriginal.hidden = navigation || mode !== "3d" || sceneStatus !== "unavailable";
71
76
  if (toolbar) {
72
77
  for (const [name, toggle] of Object.entries(toggles)) {
73
78
  const button = toolbar.querySelector(`#${toggle.id}`), showing = name === mode;
@@ -75,9 +80,13 @@ export function mount(root, { assets, signal, controls, colour, theme, attributi
75
80
  button.setAttribute("aria-pressed", String(showing));
76
81
  button.title = showing ? home.title : toggle.title;
77
82
  }
78
- toolbar.querySelector(`#${toggles.original.id}`).hidden = booting;
83
+ toolbar.querySelector(`#${toggles.original.id}`).hidden = sceneStatus === "booting";
79
84
  }
80
85
  }
86
+ function sendScene(event) {
87
+ sceneStatus = sceneTransition(sceneStatus, event);
88
+ updateView();
89
+ }
81
90
  function showMode(next) {
82
91
  mode = next;
83
92
  updateView();
@@ -99,7 +108,7 @@ export function mount(root, { assets, signal, controls, colour, theme, attributi
99
108
  showMode("3d");
100
109
  const star = root.querySelector("#menu-toggle");
101
110
  const focus = star?.getAttribute("aria-expanded") === "true" ? root.querySelector("#show-original") : star;
102
- const target = !sceneUnavailable && focus?.matches(":enabled") ? focus : null;
111
+ const target = sceneStatus === "ready" && focus?.matches(":enabled") ? focus : null;
103
112
  target?.focus({ preventScroll: true });
104
113
  if (!target || root.activeElement !== target) {
105
114
  (embeddedOriginal.hidden ? toolbar?.querySelector(`#${toggles.original.id}`) : embeddedOriginal)?.focus({ preventScroll: true });
@@ -156,8 +165,7 @@ export function mount(root, { assets, signal, controls, colour, theme, attributi
156
165
  return new Promise(() => { });
157
166
  const message = element(document, "p", { className: "loading", textContent: SCENE_FAILURE }, { role: "status" });
158
167
  scene.append(message);
159
- booting = false;
160
- updateView();
168
+ sendScene("failed");
161
169
  throw error;
162
170
  }
163
171
  if (signal.aborted)
@@ -192,15 +200,11 @@ export function mount(root, { assets, signal, controls, colour, theme, attributi
192
200
  colour,
193
201
  onShowOriginal: showOriginal,
194
202
  onReady() {
195
- sceneUnavailable = false;
196
- booting = false;
197
- updateView();
203
+ sendScene("ready");
198
204
  resolve();
199
205
  },
200
206
  onUnavailable() {
201
- sceneUnavailable = true;
202
- booting = false;
203
- updateView();
207
+ sendScene("lost");
204
208
  },
205
209
  });
206
210
  });
@@ -213,8 +217,7 @@ export function mount(root, { assets, signal, controls, colour, theme, attributi
213
217
  // press away.
214
218
  scene.querySelector("#loading").textContent = SCENE_FAILURE;
215
219
  scene.querySelectorAll("button, input").forEach((control) => { control.disabled = true; });
216
- booting = false;
217
- updateView();
220
+ sendScene("failed");
218
221
  throw error;
219
222
  }
220
223
  }
@@ -285,6 +288,9 @@ export function mount(root, { assets, signal, controls, colour, theme, attributi
285
288
  toggleButton.addEventListener("click", () => showMode(mode === name ? "3d" : name), listening);
286
289
  nav.append(toggleButton);
287
290
  }
291
+ for (const prototype of prototypes) {
292
+ nav.append(element(document, "a", { id: prototype.id, href: new URL(prototype.href, assets).href, title: prototype.title, textContent: prototype.label }, { "data-ct": prototype.ct }));
293
+ }
288
294
  nav.append(element(document, "a", { href: new URL("building-study.html", assets).href, textContent: "3d building" }, { "data-ct": "viewer.building-study" }), element(document, "a", { href: new URL("skyline-study.html", assets).href, textContent: "skyline study" }, { "data-ct": "viewer.skyline-study" }));
289
295
  const debugButton = button("debug-motion", "debug motion", { "aria-describedby": "stars-motion-status" });
290
296
  const motionStatus = element(document, "p", { className: "motion-status", id: "stars-motion-status" }, { role: "status" });
@@ -319,17 +325,4 @@ export function mount(root, { assets, signal, controls, colour, theme, attributi
319
325
  resizing.observe(scene);
320
326
  signal.addEventListener("abort", () => resizing.disconnect(), { once: true });
321
327
  }
322
- // A prototype's page, in a frame of its own, shown in place the first time it is chosen.
323
- // Keys pressed inside it go to its own window, so F listens there too.
324
- function createPrototype(name) {
325
- const frame = element(document, "iframe", { className: "scene", id: `${name}-scene`, title: prototypes[name].title, src: new URL(prototypes[name].src, assets).href });
326
- frame.addEventListener("load", () => {
327
- try {
328
- frame.contentWindow?.addEventListener("keydown", fullscreenShortcut, listening);
329
- }
330
- catch { /* A frame from another origin keeps its keys. */ }
331
- }, listening);
332
- viewer.insertBefore(frame, originalReturn);
333
- return frame;
334
- }
335
328
  }
@@ -73,6 +73,26 @@
73
73
  white-space: nowrap;
74
74
  }
75
75
 
76
+ .back {
77
+ position: fixed;
78
+ z-index: 3;
79
+ top: max(12px, env(safe-area-inset-top));
80
+ left: max(12px, env(safe-area-inset-left));
81
+ color: #a8a8a8;
82
+ font-size: 12px;
83
+ text-decoration: underline;
84
+ text-decoration-thickness: 1px;
85
+ text-underline-offset: 3px;
86
+ }
87
+ .back:hover,
88
+ .back:focus-visible {
89
+ color: #fff;
90
+ text-decoration-thickness: 2px;
91
+ }
92
+ .back:focus-visible {
93
+ outline: 1px solid #fff;
94
+ outline-offset: 4px;
95
+ }
76
96
  [hidden] {
77
97
  display: none !important;
78
98
  }
@@ -80,6 +100,7 @@
80
100
  <script type="module" src="click-tracking-page.js"></script>
81
101
  </head>
82
102
  <body>
103
+ <a class="back" id="back-link" href="index.html">← back to skyline</a>
83
104
  <canvas id="skyline" role="img" aria-label="Interactive WebGL rendering of the Chicago skyline"></canvas>
84
105
  <p class="loading" id="loading" role="status">preparing WebGL building layers…</p>
85
106
  <div class="tooltip" id="tooltip" aria-hidden="true" hidden></div>
@@ -435,7 +456,7 @@
435
456
  }
436
457
 
437
458
  const sourceRoot = sourceDocument.documentElement;
438
- sourceRoot.querySelectorAll("script, #building-tooltip").forEach((element) => element.remove());
459
+ sourceRoot.querySelectorAll("script, #building-tooltip, #back-link").forEach((element) => element.remove());
439
460
  sourceRoot.setAttribute("width", "850");
440
461
  sourceRoot.setAttribute("height", "278");
441
462
  const sourceHost = document.createElement("div");