@a2f0/skyline 0.1.6 → 0.2.1
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 +7 -0
- package/lib/three-engine.d.ts +16 -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 +94 -36
- 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,21 +1,52 @@
|
|
|
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;
|
|
@@ -108,8 +139,30 @@ export function createBuildingStudy({ models, defaultView = "quarter", views = {
|
|
|
108
139
|
let frameDistance = null;
|
|
109
140
|
let frameOffset = [0, 0];
|
|
110
141
|
let frameAlign = "center";
|
|
142
|
+
const resizing = new ResizeObserver(() => resize());
|
|
143
|
+
let api;
|
|
144
|
+
// Leaving releases what a closed page or a removed frame used to: the next frame, the
|
|
145
|
+
// observer, the orbit controls' listeners, and the renderer with its WebGL context, its
|
|
146
|
+
// GPU geometry, materials and textures. Listeners go with the signal. It is set up before
|
|
147
|
+
// anything else, so a study that fails to start part way is released too.
|
|
148
|
+
signal.addEventListener("abort", () => {
|
|
149
|
+
cancelAnimationFrame(frameId);
|
|
150
|
+
frameId = 0;
|
|
151
|
+
turning = false;
|
|
152
|
+
resizing.disconnect();
|
|
153
|
+
controls.dispose();
|
|
154
|
+
for (const object of new Set([scene, ...original.models.map((model) => model.building), ...original.extras,
|
|
155
|
+
...Object.values(layouts ?? {}).flatMap((entry) => [...entry.models?.map((model) => model.building) ?? [], ...entry.extras ?? []])]))
|
|
156
|
+
disposeObject(object);
|
|
157
|
+
renderer.dispose();
|
|
158
|
+
renderer.forceContextLoss();
|
|
159
|
+
labelLayer.remove();
|
|
160
|
+
if (api && root === document && window.__buildingStudy === api)
|
|
161
|
+
delete window.__buildingStudy;
|
|
162
|
+
delete canvas.__buildingStudy;
|
|
163
|
+
}, { once: true });
|
|
111
164
|
function requestRender() {
|
|
112
|
-
if (!frameId && !document.hidden)
|
|
165
|
+
if (!frameId && !document.hidden && !signal.aborted)
|
|
113
166
|
frameId = requestAnimationFrame(render);
|
|
114
167
|
}
|
|
115
168
|
function render(time) {
|
|
@@ -153,7 +206,7 @@ export function createBuildingStudy({ models, defaultView = "quarter", views = {
|
|
|
153
206
|
activeView = name;
|
|
154
207
|
if (viewLabel)
|
|
155
208
|
viewLabel.textContent = views[name]?.label || "orbit view";
|
|
156
|
-
|
|
209
|
+
root.querySelectorAll("[data-view]").forEach((button) => {
|
|
157
210
|
button.setAttribute("aria-pressed", String(button.dataset["view"] === name));
|
|
158
211
|
});
|
|
159
212
|
}
|
|
@@ -358,16 +411,19 @@ export function createBuildingStudy({ models, defaultView = "quarter", views = {
|
|
|
358
411
|
if (selectedModel) {
|
|
359
412
|
const data = selectedModel.building.userData["geography"];
|
|
360
413
|
tooltip.textContent = data ? `${data.name} · ${data.height} m${data.tipHeight !== data.height ? ` / tip ${data.tipHeight} m` : ""}` : selectedModel.building.name;
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
const
|
|
414
|
+
// Beside the pointer, turned back from the frame's right and bottom edges, in the
|
|
415
|
+
// frame's own CSS pixels.
|
|
416
|
+
const width = tooltip.offsetWidth, height = tooltip.offsetHeight, box = frameBox(frame);
|
|
417
|
+
const pointerX = (event.clientX - box.left) / box.scaleX, pointerY = (event.clientY - box.top) / box.scaleY;
|
|
418
|
+
const x = pointerX + 18 + width > box.width - 6 ? pointerX - width - 18 : pointerX + 18;
|
|
419
|
+
const y = pointerY + 18 + height > box.height - 6 ? pointerY - height - 18 : pointerY + 18;
|
|
364
420
|
tooltip.style.left = `${Math.max(6, x)}px`;
|
|
365
421
|
tooltip.style.top = `${Math.max(6, y)}px`;
|
|
366
422
|
}
|
|
367
|
-
}, { passive: true });
|
|
368
|
-
canvas.addEventListener("pointerleave", clearHighlight);
|
|
369
|
-
canvas.addEventListener("blur", clearHighlight);
|
|
370
|
-
window.addEventListener("scroll", clearHighlight, { passive: true });
|
|
423
|
+
}, { passive: true, signal });
|
|
424
|
+
canvas.addEventListener("pointerleave", clearHighlight, listening);
|
|
425
|
+
canvas.addEventListener("blur", clearHighlight, listening);
|
|
426
|
+
window.addEventListener("scroll", clearHighlight, { passive: true, signal });
|
|
371
427
|
canvas.addEventListener("keydown", (event) => {
|
|
372
428
|
if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "+", "=", "-", "Home"].includes(event.key))
|
|
373
429
|
return;
|
|
@@ -404,16 +460,16 @@ export function createBuildingStudy({ models, defaultView = "quarter", views = {
|
|
|
404
460
|
controls.update();
|
|
405
461
|
markView(null);
|
|
406
462
|
clearHighlight();
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
|
|
463
|
+
}, listening);
|
|
464
|
+
root.querySelectorAll("[data-view]").forEach((button) => button.addEventListener("click", () => setView(button.dataset["view"]), listening));
|
|
465
|
+
root.querySelector("#reset").addEventListener("click", () => setView(layouts?.[layout]?.defaultView || defaultView), listening);
|
|
410
466
|
wireframeButton.addEventListener("click", () => {
|
|
411
467
|
wireframe = !wireframe;
|
|
412
468
|
models.forEach((model) => model.setWireframe(wireframe));
|
|
413
469
|
wireframeButton.setAttribute("aria-pressed", String(wireframe));
|
|
414
470
|
requestRender();
|
|
415
|
-
});
|
|
416
|
-
turntableButton.addEventListener("click", () => setTurning(!turning));
|
|
471
|
+
}, listening);
|
|
472
|
+
turntableButton.addEventListener("click", () => setTurning(!turning), listening);
|
|
417
473
|
function setLayout(name) {
|
|
418
474
|
if (name === layout || (name !== "original" && !layouts?.[name]))
|
|
419
475
|
return;
|
|
@@ -458,11 +514,11 @@ export function createBuildingStudy({ models, defaultView = "quarter", views = {
|
|
|
458
514
|
requestRender();
|
|
459
515
|
}
|
|
460
516
|
function markLayout(name) {
|
|
461
|
-
|
|
517
|
+
root.querySelectorAll("[data-layout]").forEach((button) => button.setAttribute("aria-pressed", String(button.dataset["layout"] === name)));
|
|
462
518
|
}
|
|
463
|
-
|
|
519
|
+
root.querySelectorAll("[data-layout]").forEach((button) => button.addEventListener("click", () => setLayout(button.dataset["layout"]), listening));
|
|
464
520
|
function updateCameraHint() {
|
|
465
|
-
|
|
521
|
+
root.querySelector("#camera-hint").innerHTML = reducedMotion.matches
|
|
466
522
|
? '<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
523
|
: controls.enablePan
|
|
468
524
|
? '<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 +534,7 @@ export function createBuildingStudy({ models, defaultView = "quarter", views = {
|
|
|
478
534
|
updateCameraHint();
|
|
479
535
|
clearHighlight();
|
|
480
536
|
}
|
|
481
|
-
reducedMotion.addEventListener("change", updateMotionPreference);
|
|
537
|
+
reducedMotion.addEventListener("change", updateMotionPreference, listening);
|
|
482
538
|
document.addEventListener("visibilitychange", () => {
|
|
483
539
|
lastTime = 0;
|
|
484
540
|
if (document.hidden) {
|
|
@@ -487,17 +543,16 @@ export function createBuildingStudy({ models, defaultView = "quarter", views = {
|
|
|
487
543
|
}
|
|
488
544
|
else
|
|
489
545
|
requestRender();
|
|
490
|
-
});
|
|
546
|
+
}, listening);
|
|
491
547
|
canvas.addEventListener("webglcontextlost", (event) => {
|
|
492
548
|
event.preventDefault();
|
|
493
549
|
setTurning(false);
|
|
494
|
-
const loading =
|
|
550
|
+
const loading = root.querySelector("#loading");
|
|
495
551
|
loading.textContent = "The graphics context was interrupted. Reload to restore the 3D preview.";
|
|
496
552
|
loading.hidden = false;
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
new ResizeObserver(resize).observe(viewport);
|
|
553
|
+
onUnavailable();
|
|
554
|
+
}, listening);
|
|
555
|
+
resizing.observe(viewport);
|
|
501
556
|
updateMotionPreference();
|
|
502
557
|
// Open on the default view with its own frame, target, and eye distance, if it sets any.
|
|
503
558
|
setView(defaultView);
|
|
@@ -512,8 +567,8 @@ export function createBuildingStudy({ models, defaultView = "quarter", views = {
|
|
|
512
567
|
if (turntable)
|
|
513
568
|
setTurning(true);
|
|
514
569
|
renderer.render(scene, camera);
|
|
515
|
-
|
|
516
|
-
|
|
570
|
+
root.querySelector("#loading").hidden = true;
|
|
571
|
+
api = {
|
|
517
572
|
ready: true,
|
|
518
573
|
get modelName() { return models[0].building.name; },
|
|
519
574
|
get modelNames() { return models.map((model) => model.building.name); },
|
|
@@ -600,7 +655,10 @@ export function createBuildingStudy({ models, defaultView = "quarter", views = {
|
|
|
600
655
|
return { min: min.clone().add(base.position).toArray(), max: max.clone().add(base.position).toArray() };
|
|
601
656
|
},
|
|
602
657
|
};
|
|
603
|
-
|
|
604
|
-
|
|
658
|
+
// A study's own page publishes it on its window; wherever it renders, its canvas holds it.
|
|
659
|
+
if (root === document)
|
|
660
|
+
window.__buildingStudy = api;
|
|
661
|
+
Object.defineProperty(canvas, "__buildingStudy", { value: api, configurable: true });
|
|
662
|
+
onReady();
|
|
605
663
|
return { setLayout, setView, requestRender, buildingAt };
|
|
606
664
|
}
|
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
|
+
}
|