@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.
@@ -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 viewport = document.querySelector("#viewport");
10
- const canvas = document.querySelector("#building");
11
- const tooltip = document.querySelector("#tooltip");
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 = document.querySelector("#view-label");
15
- const wireframeButton = document.querySelector("#wireframe");
16
- const turntableButton = document.querySelector("#turntable");
17
- const motionStatus = document.querySelector("#motion-status");
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
- renderer.shadowMap.type = THREE.PCFSoftShadowMap;
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
- document.querySelectorAll("[data-view]").forEach((button) => {
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
- const tooltipBounds = tooltip.getBoundingClientRect();
362
- const x = event.clientX + 18 + tooltipBounds.width > innerWidth - 6 ? event.clientX - tooltipBounds.width - 18 : event.clientX + 18;
363
- const y = event.clientY + 18 + tooltipBounds.height > innerHeight - 6 ? event.clientY - tooltipBounds.height - 18 : event.clientY + 18;
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
- document.querySelectorAll("[data-view]").forEach((button) => button.addEventListener("click", () => setView(button.dataset["view"])));
409
- document.querySelector("#reset").addEventListener("click", () => setView(layouts?.[layout]?.defaultView || defaultView));
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
- document.querySelectorAll("[data-layout]").forEach((button) => button.setAttribute("aria-pressed", String(button.dataset["layout"] === name)));
519
+ root.querySelectorAll("[data-layout]").forEach((button) => button.setAttribute("aria-pressed", String(button.dataset["layout"] === name)));
462
520
  }
463
- document.querySelectorAll("[data-layout]").forEach((button) => button.addEventListener("click", () => setLayout(button.dataset["layout"])));
521
+ root.querySelectorAll("[data-layout]").forEach((button) => button.addEventListener("click", () => setLayout(button.dataset["layout"]), listening));
464
522
  function updateCameraHint() {
465
- document.querySelector("#camera-hint").innerHTML = reducedMotion.matches
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 = document.querySelector("#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
- if (window.parent !== window)
498
- window.parent.postMessage({ type: "skyline:unavailable" }, location.origin);
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
- document.querySelector("#loading").hidden = true;
516
- window.__buildingStudy = {
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
- if (window.parent !== window)
604
- window.parent.postMessage({ type: "skyline:ready" }, location.origin);
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 window: the model takes the height the header and toolbar
60
- leave it, beside its notes, or above them in a narrow or short window, where the page
61
- scrolls to them. Every height is set by the window, never by the canvas it holds.
62
- Floated over the skyline viewer, the viewer's panel closes it, so the header leaves the
63
- panel's close button its corner and drops the page's own links. */
64
- .building-detail { display: flex; flex-direction: column; height: 100vh; height: 100dvh; }
65
- .building-detail header { padding: 22px 5vw 16px; }
66
- .building-detail h1 { font-size: clamp(24px, 3vw, 38px); }
67
- .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; }
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
- @media (max-width: 799px), (max-height: 499px) {
74
- .building-detail { height: auto; min-height: 100vh; min-height: 100dvh; }
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, 62vh, 560px); }
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(100vw - 8px)); max-height: calc(100dvh - 8px); overflow-y: auto; padding: 4px; border: 1px solid #393939; border-radius: 3px; background: #080808f2; box-shadow: 0 10px 30px #000000cc; font-size: 11px; }
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 frame's top, as the skyline viewer's controls do, which
90
- set --viewer-controls. The menu stays above it, so every building's menu can replace it. */
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[open] { animation: detail-in 0.18s cubic-bezier(0.2, 0.7, 0.2, 1); }
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 iframe { display: block; width: 100%; height: 100%; border: 0; }
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 frame above a control bar docked to
98
- the bottom, and stays transparent, so the viewer's stars show above the towers. The
99
- skyline stands on the bar, as the drawing stands on the viewer's bottom edge. */
100
- :root:has(.skyline-3d) { background: transparent; }
101
- .skyline-3d { display: flex; flex-direction: column; height: 100vh; height: 100dvh; overflow: hidden; }
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: 14vh; overflow: visible; fill: none; stroke: #ddd; stroke-width: 4.2; stroke-linejoin: round; stroke-linecap: round; }
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
- @media (min-width: 1440px) {
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
- @media (max-width: 1439px) {
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
- @media (max-width: 1023px) {
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[open] { animation: none; }
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
- @media (max-width: 600px) {
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
+ }