@a2f0/skyline 0.1.7 → 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.
@@ -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 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;
@@ -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
- document.querySelectorAll("[data-view]").forEach((button) => {
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
- 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;
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
- document.querySelectorAll("[data-view]").forEach((button) => button.addEventListener("click", () => setView(button.dataset["view"])));
409
- document.querySelector("#reset").addEventListener("click", () => setView(layouts?.[layout]?.defaultView || defaultView));
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
- document.querySelectorAll("[data-layout]").forEach((button) => button.setAttribute("aria-pressed", String(button.dataset["layout"] === name)));
517
+ root.querySelectorAll("[data-layout]").forEach((button) => button.setAttribute("aria-pressed", String(button.dataset["layout"] === name)));
462
518
  }
463
- document.querySelectorAll("[data-layout]").forEach((button) => button.addEventListener("click", () => setLayout(button.dataset["layout"])));
519
+ root.querySelectorAll("[data-layout]").forEach((button) => button.addEventListener("click", () => setLayout(button.dataset["layout"]), listening));
464
520
  function updateCameraHint() {
465
- document.querySelector("#camera-hint").innerHTML = reducedMotion.matches
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 = document.querySelector("#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
- if (window.parent !== window)
498
- window.parent.postMessage({ type: "skyline:unavailable" }, location.origin);
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
- document.querySelector("#loading").hidden = true;
516
- window.__buildingStudy = {
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
- if (window.parent !== window)
604
- window.parent.postMessage({ type: "skyline:ready" }, location.origin);
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 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
+ }