@a2f0/skyline 0.2.61 → 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 +5 -5
- package/site/skyline-3d.js +48 -26
- package/site/skyline-viewer.js +30 -20
- package/site/vendor/ct-client.js +13 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@a2f0/skyline",
|
|
3
|
-
"version": "0.2.
|
|
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": {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"access": "public"
|
|
12
12
|
},
|
|
13
13
|
"type": "module",
|
|
14
|
-
"packageManager": "bun@1.4.
|
|
14
|
+
"packageManager": "bun@1.4.4",
|
|
15
15
|
"sideEffects": [
|
|
16
16
|
"./site/**/*.js"
|
|
17
17
|
],
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
}
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@a2f0/ct-client": "0.1.
|
|
63
|
+
"@a2f0/ct-client": "0.1.7",
|
|
64
64
|
"@types/three": "0.186.0"
|
|
65
65
|
},
|
|
66
66
|
"scripts": {
|
|
@@ -94,9 +94,9 @@
|
|
|
94
94
|
}
|
|
95
95
|
},
|
|
96
96
|
"devDependencies": {
|
|
97
|
-
"@a2f0/agent-tool": "0.1.
|
|
97
|
+
"@a2f0/agent-tool": "0.1.40",
|
|
98
98
|
"@tsconfig/strictest": "^2.0.8",
|
|
99
|
-
"@types/bun": "^1.4.
|
|
99
|
+
"@types/bun": "^1.4.4",
|
|
100
100
|
"esbuild": "0.28.2",
|
|
101
101
|
"playwright": "1.64.1",
|
|
102
102
|
"three": "0.186.1",
|
package/site/skyline-3d.js
CHANGED
|
@@ -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 =
|
|
270
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/site/skyline-viewer.js
CHANGED
|
@@ -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
|
-
//
|
|
52
|
-
// or
|
|
53
|
-
let
|
|
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",
|
|
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" ||
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
booting = false;
|
|
197
|
-
updateView();
|
|
210
|
+
sendScene("ready");
|
|
198
211
|
resolve();
|
|
199
212
|
},
|
|
200
213
|
onUnavailable() {
|
|
201
|
-
|
|
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
|
-
|
|
217
|
-
updateView();
|
|
227
|
+
sendScene("failed");
|
|
218
228
|
throw error;
|
|
219
229
|
}
|
|
220
230
|
}
|
package/site/vendor/ct-client.js
CHANGED
|
@@ -13,7 +13,7 @@ function safePage(value) {
|
|
|
13
13
|
const path = value.split(/[?#]/, 1)[0] ?? "/";
|
|
14
14
|
return /^\/[a-zA-Z0-9/:_.-]{0,199}$/.test(path) ? path : "/";
|
|
15
15
|
}
|
|
16
|
-
function clickTarget(event, capture) {
|
|
16
|
+
function clickTarget(event, capture, defaultApp = null) {
|
|
17
17
|
const path = event.composedPath();
|
|
18
18
|
const elements = path.filter((item) => item instanceof Element);
|
|
19
19
|
if (elements.some((element) => element.matches("[data-ct-ignore], input, textarea, select, [contenteditable]:not([contenteditable='false'])")))
|
|
@@ -23,8 +23,10 @@ function clickTarget(event, capture) {
|
|
|
23
23
|
if (!target)
|
|
24
24
|
return null;
|
|
25
25
|
const label = target.getAttribute("data-ct");
|
|
26
|
+
const app = elements.find((element) => element.hasAttribute("data-ct-app"))?.getAttribute("data-ct-app");
|
|
26
27
|
const tag = target.tagName.toLowerCase();
|
|
27
28
|
return {
|
|
29
|
+
app: app === undefined ? defaultApp : app && /^[a-z0-9][a-z0-9-]{0,63}$/.test(app) ? app : null,
|
|
28
30
|
target: /^[a-z][a-z0-9-]{0,31}$/.test(tag) ? tag : "unknown",
|
|
29
31
|
label: label && /^[a-zA-Z0-9_.:-]{1,80}$/.test(label) ? label : null
|
|
30
32
|
};
|
|
@@ -80,7 +82,11 @@ function createTracker(options) {
|
|
|
80
82
|
const response = await fetch(endpoint, {
|
|
81
83
|
method: "POST",
|
|
82
84
|
headers: { "Content-Type": "text/plain;charset=UTF-8" },
|
|
83
|
-
body: JSON.stringify({
|
|
85
|
+
body: JSON.stringify({
|
|
86
|
+
site: options.site,
|
|
87
|
+
environment: options.environment,
|
|
88
|
+
events: batch
|
|
89
|
+
}),
|
|
84
90
|
credentials: "omit",
|
|
85
91
|
referrerPolicy: "no-referrer",
|
|
86
92
|
keepalive: true,
|
|
@@ -122,7 +128,7 @@ function createTracker(options) {
|
|
|
122
128
|
function click(event) {
|
|
123
129
|
if (!event.isTrusted || blocked())
|
|
124
130
|
return;
|
|
125
|
-
const target = clickTarget(event, options.capture ?? "interactive");
|
|
131
|
+
const target = clickTarget(event, options.capture ?? "interactive", options.app ?? null);
|
|
126
132
|
if (!target || queue.length >= capacity)
|
|
127
133
|
return;
|
|
128
134
|
try {
|
|
@@ -162,6 +168,10 @@ function createTracker(options) {
|
|
|
162
168
|
return false;
|
|
163
169
|
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(options.site))
|
|
164
170
|
throw new Error("Invalid ct site identifier");
|
|
171
|
+
if (options.app !== undefined && !/^[a-z0-9][a-z0-9-]{0,63}$/.test(options.app))
|
|
172
|
+
throw new Error("Invalid ct app identifier");
|
|
173
|
+
if (options.environment !== undefined && !["production", "staging"].includes(options.environment))
|
|
174
|
+
throw new Error("Invalid ct environment");
|
|
165
175
|
const url = new URL(endpoint);
|
|
166
176
|
if (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1"].includes(url.hostname)))
|
|
167
177
|
throw new Error("ct requires an HTTPS endpoint");
|