@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/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 +10 -0
- package/lib/three-engine.d.ts +18 -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 +97 -37
- 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/study-viewer.js
CHANGED
|
@@ -1,25 +1,58 @@
|
|
|
1
1
|
import * as THREE from "./vendor/three-r186.js";
|
|
2
|
+
// The box a pointer-placed overlay is positioned in: where it is on screen, its size in CSS
|
|
3
|
+
// pixels, and how much a host's transform scales it, so a pointer's client coordinates
|
|
4
|
+
// become a place inside it: (x - left) / scaleX.
|
|
5
|
+
export function frameBox(frame) {
|
|
6
|
+
if (!frame)
|
|
7
|
+
return { left: 0, top: 0, width: innerWidth, height: innerHeight, scaleX: 1, scaleY: 1 };
|
|
8
|
+
const box = frame.getBoundingClientRect();
|
|
9
|
+
return {
|
|
10
|
+
left: box.left, top: box.top, width: frame.clientWidth, height: frame.clientHeight,
|
|
11
|
+
scaleX: frame.offsetWidth ? box.width / frame.offsetWidth : 1, scaleY: frame.offsetHeight ? box.height / frame.offsetHeight : 1,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
// Releases the GPU resources an object tree holds: geometries, materials and their textures.
|
|
15
|
+
export function disposeObject(object) {
|
|
16
|
+
object.traverse((child) => {
|
|
17
|
+
// A light's shadow map is a render target of its own.
|
|
18
|
+
if (child.isDirectionalLight)
|
|
19
|
+
child.dispose();
|
|
20
|
+
const mesh = child;
|
|
21
|
+
mesh.geometry?.dispose();
|
|
22
|
+
const materials = mesh.material ? [mesh.material].flat() : [];
|
|
23
|
+
for (const material of materials) {
|
|
24
|
+
for (const value of Object.values(material))
|
|
25
|
+
if (value?.isTexture)
|
|
26
|
+
value.dispose();
|
|
27
|
+
material.dispose();
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
2
31
|
export function createBuildingStudy({ models, defaultView = "quarter", views = {
|
|
3
32
|
front: { azimuth: Math.PI / 4, polar: Math.PI / 2 - 0.035, label: "front view" },
|
|
4
33
|
quarter: { azimuth: Math.PI / 4 + 0.22, polar: Math.PI / 2 - 0.15, label: "three-quarter view" },
|
|
5
34
|
side: { azimuth: Math.PI * 0.75, polar: Math.PI / 2 - 0.12, label: "side view" },
|
|
6
|
-
}, fov = 32, near = 1, far = 2000, clippingMargin = null, minimumDistanceRatio = 0.48, maximumDistanceRatio = 2, minimumZoom = 0.5, maximumZoom = 4, enablePan = false, target = [0, 85, 0], minimumCameraHeight = null, fit = { height: 230, width: 98 }, platform = { width: 76, depth: 76 }, lightPosition = [-110, 240, 170], shadowCamera = { left: -140, right: 140, top: 160, bottom: -160, near: 1, far: 600 }, extras = [], layouts = null, initialLayout = "original", onLayoutChange = () => { }, labels = [], turntable = false, }) {
|
|
35
|
+
}, fov = 32, near = 1, far = 2000, clippingMargin = null, minimumDistanceRatio = 0.48, maximumDistanceRatio = 2, minimumZoom = 0.5, maximumZoom = 4, enablePan = false, target = [0, 85, 0], minimumCameraHeight = null, fit = { height: 230, width: 98 }, platform = { width: 76, depth: 76 }, lightPosition = [-110, 240, 170], shadowCamera = { left: -140, right: 140, top: 160, bottom: -160, near: 1, far: 600 }, extras = [], layouts = null, initialLayout = "original", onLayoutChange = () => { }, labels = [], turntable = false, root = document, frame = null, signal = new AbortController().signal, onReady = () => { }, onUnavailable = () => { }, }) {
|
|
7
36
|
const original = { models, extras, fit, target, platform, lightPosition, shadowCamera, clippingMargin };
|
|
8
37
|
let layout = "original";
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
const
|
|
38
|
+
const document = root instanceof Document ? root : root.ownerDocument;
|
|
39
|
+
const viewport = root.querySelector("#viewport");
|
|
40
|
+
const canvas = root.querySelector("#building");
|
|
41
|
+
const tooltip = root.querySelector("#tooltip");
|
|
12
42
|
// The studies name the active view in the viewport; the full-screen skyline leaves it to
|
|
13
43
|
// the pressed button.
|
|
14
|
-
const viewLabel =
|
|
15
|
-
const wireframeButton =
|
|
16
|
-
const turntableButton =
|
|
17
|
-
const motionStatus =
|
|
44
|
+
const viewLabel = root.querySelector("#view-label");
|
|
45
|
+
const wireframeButton = root.querySelector("#wireframe");
|
|
46
|
+
const turntableButton = root.querySelector("#turntable");
|
|
47
|
+
const motionStatus = root.querySelector("#motion-status");
|
|
18
48
|
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
|
|
49
|
+
const listening = { signal };
|
|
19
50
|
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
|
20
51
|
renderer.setPixelRatio(Math.min(devicePixelRatio || 1, 2));
|
|
21
52
|
renderer.shadowMap.enabled = true;
|
|
22
|
-
|
|
53
|
+
// r186 removed PCFSoftShadowMap and renders it as PCFShadowMap, with a console warning;
|
|
54
|
+
// PCFShadowMap draws the same shadows without the warning.
|
|
55
|
+
renderer.shadowMap.type = THREE.PCFShadowMap;
|
|
23
56
|
const scene = new THREE.Scene();
|
|
24
57
|
const labelLayer = document.createElement("div");
|
|
25
58
|
labelLayer.className = "study-annotations";
|
|
@@ -108,8 +141,30 @@ export function createBuildingStudy({ models, defaultView = "quarter", views = {
|
|
|
108
141
|
let frameDistance = null;
|
|
109
142
|
let frameOffset = [0, 0];
|
|
110
143
|
let frameAlign = "center";
|
|
144
|
+
const resizing = new ResizeObserver(() => resize());
|
|
145
|
+
let api;
|
|
146
|
+
// Leaving releases what a closed page or a removed frame used to: the next frame, the
|
|
147
|
+
// observer, the orbit controls' listeners, and the renderer with its WebGL context, its
|
|
148
|
+
// GPU geometry, materials and textures. Listeners go with the signal. It is set up before
|
|
149
|
+
// anything else, so a study that fails to start part way is released too.
|
|
150
|
+
signal.addEventListener("abort", () => {
|
|
151
|
+
cancelAnimationFrame(frameId);
|
|
152
|
+
frameId = 0;
|
|
153
|
+
turning = false;
|
|
154
|
+
resizing.disconnect();
|
|
155
|
+
controls.dispose();
|
|
156
|
+
for (const object of new Set([scene, ...original.models.map((model) => model.building), ...original.extras,
|
|
157
|
+
...Object.values(layouts ?? {}).flatMap((entry) => [...entry.models?.map((model) => model.building) ?? [], ...entry.extras ?? []])]))
|
|
158
|
+
disposeObject(object);
|
|
159
|
+
renderer.dispose();
|
|
160
|
+
renderer.forceContextLoss();
|
|
161
|
+
labelLayer.remove();
|
|
162
|
+
if (api && root === document && window.__buildingStudy === api)
|
|
163
|
+
delete window.__buildingStudy;
|
|
164
|
+
delete canvas.__buildingStudy;
|
|
165
|
+
}, { once: true });
|
|
111
166
|
function requestRender() {
|
|
112
|
-
if (!frameId && !document.hidden)
|
|
167
|
+
if (!frameId && !document.hidden && !signal.aborted)
|
|
113
168
|
frameId = requestAnimationFrame(render);
|
|
114
169
|
}
|
|
115
170
|
function render(time) {
|
|
@@ -153,7 +208,7 @@ export function createBuildingStudy({ models, defaultView = "quarter", views = {
|
|
|
153
208
|
activeView = name;
|
|
154
209
|
if (viewLabel)
|
|
155
210
|
viewLabel.textContent = views[name]?.label || "orbit view";
|
|
156
|
-
|
|
211
|
+
root.querySelectorAll("[data-view]").forEach((button) => {
|
|
157
212
|
button.setAttribute("aria-pressed", String(button.dataset["view"] === name));
|
|
158
213
|
});
|
|
159
214
|
}
|
|
@@ -358,16 +413,19 @@ export function createBuildingStudy({ models, defaultView = "quarter", views = {
|
|
|
358
413
|
if (selectedModel) {
|
|
359
414
|
const data = selectedModel.building.userData["geography"];
|
|
360
415
|
tooltip.textContent = data ? `${data.name} · ${data.height} m${data.tipHeight !== data.height ? ` / tip ${data.tipHeight} m` : ""}` : selectedModel.building.name;
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
const
|
|
416
|
+
// Beside the pointer, turned back from the frame's right and bottom edges, in the
|
|
417
|
+
// frame's own CSS pixels.
|
|
418
|
+
const width = tooltip.offsetWidth, height = tooltip.offsetHeight, box = frameBox(frame);
|
|
419
|
+
const pointerX = (event.clientX - box.left) / box.scaleX, pointerY = (event.clientY - box.top) / box.scaleY;
|
|
420
|
+
const x = pointerX + 18 + width > box.width - 6 ? pointerX - width - 18 : pointerX + 18;
|
|
421
|
+
const y = pointerY + 18 + height > box.height - 6 ? pointerY - height - 18 : pointerY + 18;
|
|
364
422
|
tooltip.style.left = `${Math.max(6, x)}px`;
|
|
365
423
|
tooltip.style.top = `${Math.max(6, y)}px`;
|
|
366
424
|
}
|
|
367
|
-
}, { passive: true });
|
|
368
|
-
canvas.addEventListener("pointerleave", clearHighlight);
|
|
369
|
-
canvas.addEventListener("blur", clearHighlight);
|
|
370
|
-
window.addEventListener("scroll", clearHighlight, { passive: true });
|
|
425
|
+
}, { passive: true, signal });
|
|
426
|
+
canvas.addEventListener("pointerleave", clearHighlight, listening);
|
|
427
|
+
canvas.addEventListener("blur", clearHighlight, listening);
|
|
428
|
+
window.addEventListener("scroll", clearHighlight, { passive: true, signal });
|
|
371
429
|
canvas.addEventListener("keydown", (event) => {
|
|
372
430
|
if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "+", "=", "-", "Home"].includes(event.key))
|
|
373
431
|
return;
|
|
@@ -404,16 +462,16 @@ export function createBuildingStudy({ models, defaultView = "quarter", views = {
|
|
|
404
462
|
controls.update();
|
|
405
463
|
markView(null);
|
|
406
464
|
clearHighlight();
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
|
|
465
|
+
}, listening);
|
|
466
|
+
root.querySelectorAll("[data-view]").forEach((button) => button.addEventListener("click", () => setView(button.dataset["view"]), listening));
|
|
467
|
+
root.querySelector("#reset").addEventListener("click", () => setView(layouts?.[layout]?.defaultView || defaultView), listening);
|
|
410
468
|
wireframeButton.addEventListener("click", () => {
|
|
411
469
|
wireframe = !wireframe;
|
|
412
470
|
models.forEach((model) => model.setWireframe(wireframe));
|
|
413
471
|
wireframeButton.setAttribute("aria-pressed", String(wireframe));
|
|
414
472
|
requestRender();
|
|
415
|
-
});
|
|
416
|
-
turntableButton.addEventListener("click", () => setTurning(!turning));
|
|
473
|
+
}, listening);
|
|
474
|
+
turntableButton.addEventListener("click", () => setTurning(!turning), listening);
|
|
417
475
|
function setLayout(name) {
|
|
418
476
|
if (name === layout || (name !== "original" && !layouts?.[name]))
|
|
419
477
|
return;
|
|
@@ -458,11 +516,11 @@ export function createBuildingStudy({ models, defaultView = "quarter", views = {
|
|
|
458
516
|
requestRender();
|
|
459
517
|
}
|
|
460
518
|
function markLayout(name) {
|
|
461
|
-
|
|
519
|
+
root.querySelectorAll("[data-layout]").forEach((button) => button.setAttribute("aria-pressed", String(button.dataset["layout"] === name)));
|
|
462
520
|
}
|
|
463
|
-
|
|
521
|
+
root.querySelectorAll("[data-layout]").forEach((button) => button.addEventListener("click", () => setLayout(button.dataset["layout"]), listening));
|
|
464
522
|
function updateCameraHint() {
|
|
465
|
-
|
|
523
|
+
root.querySelector("#camera-hint").innerHTML = reducedMotion.matches
|
|
466
524
|
? '<span class="wide-hint">Use the view buttons or arrow keys to inspect<br />+ / − zoom · Home resets</span><span class="narrow-hint">Use the view buttons to inspect</span>'
|
|
467
525
|
: controls.enablePan
|
|
468
526
|
? '<span class="wide-hint">Drag to orbit · shift-drag to pan · scroll to zoom<br />Arrow keys rotate · + / − zoom · Home resets</span><span class="narrow-hint">Drag to orbit · two fingers pan / zoom</span>'
|
|
@@ -478,7 +536,7 @@ export function createBuildingStudy({ models, defaultView = "quarter", views = {
|
|
|
478
536
|
updateCameraHint();
|
|
479
537
|
clearHighlight();
|
|
480
538
|
}
|
|
481
|
-
reducedMotion.addEventListener("change", updateMotionPreference);
|
|
539
|
+
reducedMotion.addEventListener("change", updateMotionPreference, listening);
|
|
482
540
|
document.addEventListener("visibilitychange", () => {
|
|
483
541
|
lastTime = 0;
|
|
484
542
|
if (document.hidden) {
|
|
@@ -487,17 +545,16 @@ export function createBuildingStudy({ models, defaultView = "quarter", views = {
|
|
|
487
545
|
}
|
|
488
546
|
else
|
|
489
547
|
requestRender();
|
|
490
|
-
});
|
|
548
|
+
}, listening);
|
|
491
549
|
canvas.addEventListener("webglcontextlost", (event) => {
|
|
492
550
|
event.preventDefault();
|
|
493
551
|
setTurning(false);
|
|
494
|
-
const loading =
|
|
552
|
+
const loading = root.querySelector("#loading");
|
|
495
553
|
loading.textContent = "The graphics context was interrupted. Reload to restore the 3D preview.";
|
|
496
554
|
loading.hidden = false;
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
new ResizeObserver(resize).observe(viewport);
|
|
555
|
+
onUnavailable();
|
|
556
|
+
}, listening);
|
|
557
|
+
resizing.observe(viewport);
|
|
501
558
|
updateMotionPreference();
|
|
502
559
|
// Open on the default view with its own frame, target, and eye distance, if it sets any.
|
|
503
560
|
setView(defaultView);
|
|
@@ -512,8 +569,8 @@ export function createBuildingStudy({ models, defaultView = "quarter", views = {
|
|
|
512
569
|
if (turntable)
|
|
513
570
|
setTurning(true);
|
|
514
571
|
renderer.render(scene, camera);
|
|
515
|
-
|
|
516
|
-
|
|
572
|
+
root.querySelector("#loading").hidden = true;
|
|
573
|
+
api = {
|
|
517
574
|
ready: true,
|
|
518
575
|
get modelName() { return models[0].building.name; },
|
|
519
576
|
get modelNames() { return models.map((model) => model.building.name); },
|
|
@@ -600,7 +657,10 @@ export function createBuildingStudy({ models, defaultView = "quarter", views = {
|
|
|
600
657
|
return { min: min.clone().add(base.position).toArray(), max: max.clone().add(base.position).toArray() };
|
|
601
658
|
},
|
|
602
659
|
};
|
|
603
|
-
|
|
604
|
-
|
|
660
|
+
// A study's own page publishes it on its window; wherever it renders, its canvas holds it.
|
|
661
|
+
if (root === document)
|
|
662
|
+
window.__buildingStudy = api;
|
|
663
|
+
Object.defineProperty(canvas, "__buildingStudy", { value: api, configurable: true });
|
|
664
|
+
onReady();
|
|
605
665
|
return { setLayout, setView, requestRender, buildingAt };
|
|
606
666
|
}
|
package/site/study.css
CHANGED
|
@@ -56,28 +56,44 @@ button:disabled { cursor: default; opacity: 0.35; }
|
|
|
56
56
|
.study-annotations [data-placement="below"] { transform: translate(-50%, 0); margin-top: 12px; }
|
|
57
57
|
.study-annotations [data-placement="right"] { transform: translate(0, -50%); margin-top: 0; margin-left: 14px; }
|
|
58
58
|
.visually-hidden { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
|
59
|
-
/* A building's detail fills its
|
|
60
|
-
leave it, beside its notes, or above them in a narrow or
|
|
61
|
-
scrolls to them. Every height is set by the
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
.
|
|
66
|
-
.
|
|
67
|
-
.
|
|
59
|
+
/* A building's detail fills its box, the page or the skyline's panel: the model takes the
|
|
60
|
+
height the header and toolbar leave it, beside its notes, or above them in a narrow or
|
|
61
|
+
short box, which scrolls to them. Every height is set by the box, never by the canvas it
|
|
62
|
+
holds. The box (.detail-root) is a container, so the detail lays itself out by its own
|
|
63
|
+
size wherever it renders, and contains its layout, so its fixed tooltip is placed in it. Floated over the 3D skyline, the panel
|
|
64
|
+
closes it, so the header leaves the panel's close button its corner and drops the page's
|
|
65
|
+
own links. */
|
|
66
|
+
body:has(> .detail-root) { height: 100vh; height: 100dvh; }
|
|
67
|
+
.detail-root { position: relative; height: 100%; container: detail / size; contain: layout; color-scheme: dark; background: #080808; color: #ddd; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
68
|
+
/* Its scrolling stays in the detail, rather than carrying on into a host page beneath. */
|
|
69
|
+
.detail-frame { height: 100%; overflow-y: auto; overscroll-behavior: contain; }
|
|
70
|
+
.building-detail { display: flex; flex-direction: column; height: 100%; }
|
|
71
|
+
.building-detail header { padding: 22px 5cqw 16px; }
|
|
72
|
+
.building-detail h1 { font-size: clamp(24px, 3cqw, 38px); }
|
|
73
|
+
.building-detail main { flex: 1 1 0; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr) minmax(200px, 270px); grid-template-rows: minmax(0, 1fr); gap: 0 28px; width: 100%; margin: 0 auto; padding: 0 5cqw 24px; }
|
|
68
74
|
.detail-model { display: flex; flex-direction: column; min-height: 0; }
|
|
69
75
|
.building-detail .viewport { flex: 1 1 0; height: auto; min-height: 280px; border-top: 1px solid #292929; border-bottom: 1px solid #292929; }
|
|
70
76
|
.building-detail .notes { display: block; min-height: 0; overflow-y: auto; border-top: 1px solid #292929; padding-top: 4px; }
|
|
71
77
|
.framed .building-detail header { padding-right: 60px; }
|
|
72
78
|
.framed .building-detail .study-links { display: none; }
|
|
73
|
-
@
|
|
74
|
-
.building-detail { height: auto; min-height:
|
|
79
|
+
@container detail (max-width: 799px) or (max-height: 499px) {
|
|
80
|
+
.building-detail { height: auto; min-height: 100%; }
|
|
75
81
|
.building-detail main, .detail-model { display: block; }
|
|
76
|
-
.building-detail .viewport { height: clamp(300px,
|
|
82
|
+
.building-detail .viewport { height: clamp(300px, 62cqh, 560px); }
|
|
77
83
|
.building-detail .notes { overflow: visible; border-top: 0; padding-top: 0; }
|
|
78
84
|
}
|
|
85
|
+
@container detail (max-width: 600px) {
|
|
86
|
+
.building-detail header { flex-wrap: wrap; gap: 18px; }
|
|
87
|
+
.building-detail h1 { max-width: 300px; }
|
|
88
|
+
.building-detail .view-label { top: 34px; left: 10px; right: auto; font-size: 9px; }
|
|
89
|
+
.building-detail .hint { font-size: 9px; }
|
|
90
|
+
.building-detail .wide-hint { display: none; }
|
|
91
|
+
.building-detail .narrow-hint { display: inline; }
|
|
92
|
+
.building-detail button { padding: 12px 11px; }
|
|
93
|
+
.building-detail .study-links { align-items: start; flex-direction: row; flex-wrap: wrap; gap: 18px; }
|
|
94
|
+
}
|
|
79
95
|
/* A building's context menu: detail and any lighting choices, scrollable on short screens. */
|
|
80
|
-
.context-menu { position: fixed; z-index: 4; box-sizing: border-box; min-width: 160px; max-width: min(280px, calc(
|
|
96
|
+
.context-menu { position: fixed; z-index: 4; box-sizing: border-box; min-width: 160px; max-width: min(280px, calc(100cqw - 8px)); max-height: calc(100cqh - 8px); overflow-y: auto; overscroll-behavior: contain; padding: 4px; border: 1px solid #393939; border-radius: 3px; background: #080808f2; box-shadow: 0 10px 30px #000000cc; font-size: 11px; }
|
|
81
97
|
.context-menu-title { margin: 0; padding: 5px 8px 6px; color: #858585; font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; }
|
|
82
98
|
.context-menu :is([role="menuitem"], [role="menuitemradio"]) { display: block; box-sizing: border-box; width: 100%; min-height: 32px; margin: 0; padding: 7px 8px; border: 0; border-radius: 2px; background: none; color: #ddd; font: inherit; text-align: left; text-decoration: none; white-space: normal; }
|
|
83
99
|
.context-menu :is([role="menuitem"], [role="menuitemradio"]):is(:hover, :focus) { background: #202020; color: #fff; }
|
|
@@ -86,19 +102,24 @@ button:disabled { cursor: default; opacity: 0.35; }
|
|
|
86
102
|
.context-menu [role="menuitemradio"][aria-checked="true"]::before { content: "●"; }
|
|
87
103
|
.context-menu :focus-visible { outline-offset: -1px; }
|
|
88
104
|
/* The detail its item floats over the skyline: centred in the scene above the control bar,
|
|
89
|
-
below anything that lies over the
|
|
90
|
-
|
|
105
|
+
below anything that lies over the scene's top, as the site's toolbar does, which sets
|
|
106
|
+
--viewer-controls. The menu stays above it, so every building's menu can replace it. The
|
|
107
|
+
detail renders in a shadow root of its own, inside .detail-host. */
|
|
91
108
|
.detail-panel { position: absolute; z-index: 3; inset: calc(var(--viewer-controls, 0px) + 12px) 12px 12px; width: min(1040px, calc(100% - 24px)); height: min(760px, calc(100% - var(--viewer-controls, 0px) - 24px)); max-width: none; max-height: none; margin: auto; padding: 0; overflow: hidden; border: 1px solid #393939; border-radius: 3px; background: #080808; color: #ddd; box-shadow: 0 24px 80px #000000e6; }
|
|
92
|
-
.detail-panel[
|
|
109
|
+
.detail-panel:not([hidden]) { animation: detail-in 0.18s cubic-bezier(0.2, 0.7, 0.2, 1); }
|
|
93
110
|
@keyframes detail-in { from { opacity: 0; scale: 0.98; } }
|
|
94
|
-
.detail-panel
|
|
95
|
-
.detail-panel .detail-close { position: absolute; top: 12px; right: 12px; display: grid; place-items: center; width: 32px; height: 32px; padding: 0; font-size: 18px; line-height: 1; }
|
|
111
|
+
.detail-panel .detail-host { display: block; width: 100%; height: 100%; }
|
|
112
|
+
.detail-panel .detail-close { position: absolute; z-index: 1; top: 12px; right: 12px; display: grid; place-items: center; width: 32px; height: 32px; padding: 0; font-size: 18px; line-height: 1; }
|
|
96
113
|
.detail-panel .detail-close:focus-visible { outline-offset: 2px; }
|
|
97
|
-
/* The skyline viewer's full-screen 3D mode fills its
|
|
98
|
-
the bottom, and stays transparent, so the
|
|
99
|
-
skyline stands on the bar, as the drawing stands
|
|
100
|
-
|
|
101
|
-
|
|
114
|
+
/* The skyline viewer's full-screen 3D mode fills its box, its own page or the viewer's
|
|
115
|
+
shadow root, above a control bar docked to the bottom, and stays transparent, so the
|
|
116
|
+
viewer's stars show above the towers. The skyline stands on the bar, as the drawing stands
|
|
117
|
+
on the viewer's bottom edge. The box is a container, so the scene lays itself out by its
|
|
118
|
+
own size wherever it renders, and its layout containment makes it the box its fixed menus
|
|
119
|
+
and tooltip are placed in, wherever the host puts the viewer. */
|
|
120
|
+
:root:has(body.skyline-3d) { background: transparent; }
|
|
121
|
+
.skyline-3d { display: flex; flex-direction: column; height: 100%; overflow: hidden; container: skyline / size; contain: layout; --viewer-controls: 0px; color-scheme: dark; color: #ddd; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
122
|
+
body.skyline-3d { height: 100vh; height: 100dvh; }
|
|
102
123
|
.skyline-3d main.viewport { flex: 1 1 auto; min-height: 0; height: auto; max-width: none; margin: 0; padding: 0; background: none; }
|
|
103
124
|
.skyline-3d canvas { height: 100%; }
|
|
104
125
|
.skyline-3d .study-annotations { inset: 0; }
|
|
@@ -106,7 +127,7 @@ button:disabled { cursor: default; opacity: 0.35; }
|
|
|
106
127
|
appears before the model graph loads; errors replace it with the normal loading text.
|
|
107
128
|
Its baseline stands on the toolbar. Scaled strokes and glow keep the outline legible. */
|
|
108
129
|
.skyline-3d .loading:has(.skyline-trace) { inset: 0; margin: 0; display: grid; place-items: end center; padding: 32px 24px 0; pointer-events: none; }
|
|
109
|
-
.skyline-trace { display: block; width: min(240px, calc(100% / 3)); max-height:
|
|
130
|
+
.skyline-trace { display: block; width: min(240px, calc(100% / 3)); max-height: 14cqh; overflow: visible; fill: none; stroke: #ddd; stroke-width: 4.2; stroke-linejoin: round; stroke-linecap: round; }
|
|
110
131
|
.skyline-trace-rail { opacity: 0.08; }
|
|
111
132
|
.skyline-trace-line { stroke-dasharray: 1; stroke-dashoffset: 1; animation: skyline-trace 4s linear infinite; }
|
|
112
133
|
.skyline-trace-tip { stroke: #fff; stroke-width: 6.6; stroke-dasharray: 0.008 0.992; stroke-dashoffset: 0.008; filter: drop-shadow(0 0 9px #ffffff99); animation: skyline-trace-tip 4s linear infinite; }
|
|
@@ -160,30 +181,37 @@ button:disabled { cursor: default; opacity: 0.35; }
|
|
|
160
181
|
.menu-toggle[aria-expanded="true"] svg { rotate: 30deg; }
|
|
161
182
|
/* At desktop widths the badges share the display row, so opening the controls
|
|
162
183
|
keeps the canvas height unchanged. Smaller screens retain 36px touch targets. */
|
|
163
|
-
@
|
|
184
|
+
@container skyline (min-width: 1440px) {
|
|
164
185
|
.celebration-controls { flex-basis: auto; flex-wrap: nowrap; }
|
|
165
186
|
.celebration-caption { display: none; }
|
|
166
187
|
.control-bar .celebration-controls button { width: 28px; height: 28px; padding: 2px; }
|
|
167
188
|
.celebration-controls svg { width: 20px; height: 20px; }
|
|
168
189
|
}
|
|
169
|
-
@
|
|
190
|
+
@container skyline (max-width: 1439px) {
|
|
170
191
|
.control-bar { grid-template-columns: auto auto; grid-template-areas: "menu menu" "views display"; justify-content: space-between; }
|
|
171
192
|
.control-bar .menu-toggle { justify-self: center; }
|
|
172
193
|
}
|
|
173
|
-
@
|
|
194
|
+
@container skyline (max-width: 1023px) {
|
|
174
195
|
.control-bar { grid-template-columns: minmax(0, 1fr); grid-template-areas: "menu" "views" "display"; }
|
|
175
196
|
.control-bar .button-group { justify-self: center; justify-content: center; --fold: inset(0 50%); }
|
|
176
197
|
.celebration-controls { justify-content: center; }
|
|
177
198
|
}
|
|
178
199
|
@media (prefers-reduced-motion: reduce) {
|
|
179
200
|
.menu-toggle svg { transition: none; }
|
|
180
|
-
.detail-panel[
|
|
201
|
+
.detail-panel:not([hidden]) { animation: none; }
|
|
181
202
|
.skyline-trace-line { animation: none; stroke-dasharray: none; stroke-dashoffset: 0; opacity: 0.65; }
|
|
182
203
|
.skyline-trace-tip { display: none; animation: none; }
|
|
183
204
|
}
|
|
184
|
-
@
|
|
205
|
+
@container skyline (max-width: 600px) {
|
|
185
206
|
.skyline-trace { stroke-width: 8.4; }
|
|
186
207
|
.skyline-trace-tip { stroke-width: 12; }
|
|
208
|
+
.skyline-3d .hint { font-size: 9px; }
|
|
209
|
+
.skyline-3d .wide-hint { display: none; }
|
|
210
|
+
.skyline-3d .narrow-hint { display: inline; }
|
|
211
|
+
.control-bar button { font-size: 10px; }
|
|
212
|
+
}
|
|
213
|
+
/* The studies' pages lay themselves out by the window. */
|
|
214
|
+
@media (max-width: 600px) {
|
|
187
215
|
.layout-controls { align-items: start; flex-direction: column; gap: 12px; }
|
|
188
216
|
header { padding: 24px 5vw; flex-wrap: wrap; gap: 18px; }
|
|
189
217
|
h1 { max-width: 300px; }
|
|
@@ -200,5 +228,4 @@ button:disabled { cursor: default; opacity: 0.35; }
|
|
|
200
228
|
.study-links { align-items: start; flex-direction: row; flex-wrap: wrap; gap: 18px; }
|
|
201
229
|
.skyline-study .reference { height: 360px; }
|
|
202
230
|
.skyline-study .viewport { height: 480px; }
|
|
203
|
-
.control-bar button { font-size: 10px; }
|
|
204
231
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
let provided;
|
|
2
|
+
/**
|
|
3
|
+
* Offers a host's engine to the scene. The page's first scene takes the engine once, when its
|
|
4
|
+
* code first loads: an engine offered later, or by a second instance, is not used.
|
|
5
|
+
*/
|
|
6
|
+
export function provideThree(three) {
|
|
7
|
+
provided ??= Promise.resolve(three);
|
|
8
|
+
}
|
|
9
|
+
/** The host's engine, if one was offered. */
|
|
10
|
+
export function providedThree() {
|
|
11
|
+
return provided;
|
|
12
|
+
}
|