@a2f0/skyline 0.2.62 → 0.2.63

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.2.62",
3
+ "version": "0.2.63",
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);
@@ -15,6 +15,21 @@ const prototypes = {
15
15
  enhanced: { src: "skyline-animated.svg", title: "Animated Chicago skyline at night" },
16
16
  webgl: { src: "skyline-webgl.html", title: "WebGL Chicago skyline prototype" },
17
17
  };
18
+ export function sceneTransition(status, event) {
19
+ switch (event) {
20
+ case "ready":
21
+ return status === "booting" ? "ready" : status;
22
+ case "failed":
23
+ return status === "booting" ? "unavailable" : status;
24
+ case "lost":
25
+ return "unavailable";
26
+ default:
27
+ return assertNever(event);
28
+ }
29
+ }
30
+ function assertNever(value) {
31
+ throw new Error(`Unhandled scene event: ${String(value)}`);
32
+ }
18
33
  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
34
  function element(document, tag, properties = {}, attributes = {}) {
20
35
  const created = Object.assign(document.createElement(tag), properties);
@@ -48,16 +63,13 @@ export function mount(root, { assets, signal, controls, colour, theme, attributi
48
63
  viewer.append(stars, scene, drawing, originalReturn, embeddedOriginal, status);
49
64
  root.append(viewer);
50
65
  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;
66
+ // Both show original buttons wait while the scene boots, until its first frame, a failure
67
+ // to load, or a lost graphics context; the drawing stays reachable while it is unavailable.
68
+ let sceneStatus = "booting";
57
69
  const frames = {};
58
70
  const toolbar = navigation ? createToolbar() : null;
59
71
  function updateView() {
60
- viewer.toggleAttribute("data-booting", sceneUnavailable && mode === "3d");
72
+ viewer.toggleAttribute("data-booting", sceneStatus !== "ready" && mode === "3d");
61
73
  scene.hidden = mode !== "3d";
62
74
  if (mode === "original" && !drawing.getAttribute("src"))
63
75
  drawing.src = new URL("skyline-original-fit.svg", assets).href;
@@ -67,7 +79,7 @@ export function mount(root, { assets, signal, controls, colour, theme, attributi
67
79
  for (const [name, frame] of Object.entries(frames))
68
80
  frame.hidden = name !== mode;
69
81
  originalReturn.hidden = mode !== "original";
70
- embeddedOriginal.hidden = navigation || mode !== "3d" || booting || !sceneUnavailable;
82
+ embeddedOriginal.hidden = navigation || mode !== "3d" || sceneStatus !== "unavailable";
71
83
  if (toolbar) {
72
84
  for (const [name, toggle] of Object.entries(toggles)) {
73
85
  const button = toolbar.querySelector(`#${toggle.id}`), showing = name === mode;
@@ -75,9 +87,13 @@ export function mount(root, { assets, signal, controls, colour, theme, attributi
75
87
  button.setAttribute("aria-pressed", String(showing));
76
88
  button.title = showing ? home.title : toggle.title;
77
89
  }
78
- toolbar.querySelector(`#${toggles.original.id}`).hidden = booting;
90
+ toolbar.querySelector(`#${toggles.original.id}`).hidden = sceneStatus === "booting";
79
91
  }
80
92
  }
93
+ function sendScene(event) {
94
+ sceneStatus = sceneTransition(sceneStatus, event);
95
+ updateView();
96
+ }
81
97
  function showMode(next) {
82
98
  mode = next;
83
99
  updateView();
@@ -99,7 +115,7 @@ export function mount(root, { assets, signal, controls, colour, theme, attributi
99
115
  showMode("3d");
100
116
  const star = root.querySelector("#menu-toggle");
101
117
  const focus = star?.getAttribute("aria-expanded") === "true" ? root.querySelector("#show-original") : star;
102
- const target = !sceneUnavailable && focus?.matches(":enabled") ? focus : null;
118
+ const target = sceneStatus === "ready" && focus?.matches(":enabled") ? focus : null;
103
119
  target?.focus({ preventScroll: true });
104
120
  if (!target || root.activeElement !== target) {
105
121
  (embeddedOriginal.hidden ? toolbar?.querySelector(`#${toggles.original.id}`) : embeddedOriginal)?.focus({ preventScroll: true });
@@ -156,8 +172,7 @@ export function mount(root, { assets, signal, controls, colour, theme, attributi
156
172
  return new Promise(() => { });
157
173
  const message = element(document, "p", { className: "loading", textContent: SCENE_FAILURE }, { role: "status" });
158
174
  scene.append(message);
159
- booting = false;
160
- updateView();
175
+ sendScene("failed");
161
176
  throw error;
162
177
  }
163
178
  if (signal.aborted)
@@ -192,15 +207,11 @@ export function mount(root, { assets, signal, controls, colour, theme, attributi
192
207
  colour,
193
208
  onShowOriginal: showOriginal,
194
209
  onReady() {
195
- sceneUnavailable = false;
196
- booting = false;
197
- updateView();
210
+ sendScene("ready");
198
211
  resolve();
199
212
  },
200
213
  onUnavailable() {
201
- sceneUnavailable = true;
202
- booting = false;
203
- updateView();
214
+ sendScene("lost");
204
215
  },
205
216
  });
206
217
  });
@@ -213,8 +224,7 @@ export function mount(root, { assets, signal, controls, colour, theme, attributi
213
224
  // press away.
214
225
  scene.querySelector("#loading").textContent = SCENE_FAILURE;
215
226
  scene.querySelectorAll("button, input").forEach((control) => { control.disabled = true; });
216
- booting = false;
217
- updateView();
227
+ sendScene("failed");
218
228
  throw error;
219
229
  }
220
230
  }