@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 CHANGED
@@ -22,7 +22,7 @@ WebGL and the 3D pages need it.
22
22
 
23
23
  | Page | Shows | Docs |
24
24
  | --- | --- | --- |
25
- | `index.html` | The viewer: opens on the 3D skyline, with the enhanced drawing, the original drawing, and a WebGL prototype one button away | [The viewer](docs/viewer.md) |
25
+ | `index.html` | The viewer, mounted as the package mounts it: opens on the 3D skyline, with the enhanced drawing, the original drawing, and a WebGL prototype one button away | [The viewer](docs/viewer.md) |
26
26
  | `skyline-3d.html` | The full-screen 3D skyline on its own: camera views, streets, footprints, celebratory lights, and a building menu | [Full-screen 3D skyline](docs/viewer.md#full-screen-3d-skyline) |
27
27
  | `building-detail.html?building=<id>` | One mapped building alone on its platform | [Building menu and detail](docs/viewer.md#building-menu-and-detail) |
28
28
  | `skyline-study.html` | The 3D scene over the drawing, in the geographic layout or the original drawing's fit | [3D studies](docs/studies.md#skyline-study) |
@@ -32,8 +32,8 @@ WebGL and the 3D pages need it.
32
32
  ## Package
33
33
 
34
34
  The repository also builds [`@a2f0/skyline`](https://www.npmjs.com/package/@a2f0/skyline), an ESM
35
- package with the complete embeddable viewer, reusable Three.js scenes and building models, and
36
- TypeScript declarations. Every merge to `main` publishes a new version to npm; run
35
+ package that mounts the complete viewer into a host page's own document, in a shadow root, with
36
+ reusable Three.js scenes and building models, and TypeScript declarations. Every merge to `main` publishes a new version to npm; run
37
37
  `bun run build:package` or `bun pm pack` to build it locally. [The package guide](docs/package.md)
38
38
  covers installing, asset copying, mounting, imports, and releases.
39
39
 
package/docs/package.md CHANGED
@@ -20,7 +20,8 @@ provenance linking it to the commit and workflow run that built it.
20
20
 
21
21
  From this repository:
22
22
 
23
- Use the pinned Bun version, Node.js 22+ (with npm), and Google Chrome for checks.
23
+ Use the Bun and Node.js LTS versions pinned in `mise.toml`, npm (included with Node),
24
+ and Google Chrome for checks.
24
25
 
25
26
  ```sh
26
27
  bun install --ignore-scripts
@@ -81,15 +82,16 @@ await copySkylineAssets('./public/skyline');
81
82
 
82
83
  Serve that directory over HTTP at `/skyline/`, preserving all its relative paths.
83
84
  The helper copies files into the supplied directory; it does not clear unrelated
84
- files already there. Use a dedicated directory for these assets. All HTML, CSS,
85
- SVGs, compiled browser modules, and the viewer's vendored Three.js travel together.
85
+ files already there. Use a dedicated directory for these assets, and clear it before
86
+ copying an upgrade, so files a new version dropped do not linger. All the viewer's
87
+ code, CSS, markup, SVGs and models, and its vendored Three.js, travel together. Copy
88
+ them again whenever you upgrade the package, so the loader you bundle and the viewer
89
+ you serve match.
86
90
 
87
- In `~/github/devopsrockstars`, the existing `packages/frontend/tooling/build.ts`
88
- copies `packages/frontend/static` into its build. Its development server also
89
- serves `/static/` directly. A later integration can call the helper with
90
- `path.join(frontendRoot, 'static', 'skyline')` in both build and development startup,
91
- then use `/static/skyline/` as the browser URL. Keep the generated directory out of
92
- that application's Git history.
91
+ In `~/github/devopsrockstars`, `packages/frontend/tooling/build.ts` calls the helper
92
+ when the `skyline3d` flag is on and serves the copy at `/static/skyline/`, the browser
93
+ URL the viewer mounts with. Keep the generated directory out of that application's
94
+ Git history.
93
95
 
94
96
  ## Mount the complete viewer
95
97
 
@@ -100,17 +102,41 @@ const skyline = mountSkyline(container, {
100
102
  assetsUrl: '/skyline/',
101
103
  title: 'Interactive Chicago skyline',
102
104
  });
105
+ await skyline.ready; // optional: the 3D skyline has drawn its first frame
103
106
 
104
107
  // When the page or component leaves:
105
108
  skyline.destroy();
106
109
  ```
107
110
 
108
- The container determines the viewer's size and placement; give it an explicit
109
- height. The viewer fills it through an iframe, which isolates its styles and
110
- controls from the application's header and CSS. Embedding hides the standalone
111
- toolbar and study links so they do not overlap the host's navigation. Set
112
- `navigation: true` to show them. It retains stars, the traced
113
- loading silhouette, star controls, original comparison, and building details.
111
+ The viewer renders into the host's own document, inside a shadow root that keeps its
112
+ styles and the host's apart. `mountSkyline` returns at once with an instance:
113
+
114
+ - `element` is the `HTMLElement` it appended to `container`: a region named by the
115
+ `title` option (default "Interactive Chicago skyline"), with the viewer in its open
116
+ shadow root. It fills the container, which sets the viewer's size and placement:
117
+ give the container an explicit height. The element is `aria-busy` until `ready`
118
+ settles.
119
+ - `ready` resolves once the 3D skyline has drawn its first frame: use it where an
120
+ iframe's `load` event was used. It rejects when the viewer can't start: when its
121
+ assets fail to load, the element says why in the viewer's place; when the 3D scene
122
+ can't start, as without WebGL 2, the scene says why and the original drawing stays
123
+ one press away. The error is also logged, so a host that ignores `ready` sees no
124
+ unhandled rejection. A destroyed instance's `ready` never settles.
125
+ - `destroy()` removes the element and releases everything the viewer holds: its WebGL
126
+ renderers and contexts (disposed, then lost on purpose, so remounts never exhaust
127
+ the browser's contexts), animation frames, resize observers, media-query, pointer
128
+ and keyboard listeners, the building detail, and loads still in flight. Calling it
129
+ again does nothing, and destroying while the viewer still loads is safe. Unlike a
130
+ frame, the viewer doesn't stop when its element merely leaves the page: always call
131
+ `destroy`.
132
+
133
+ The viewer shows what the embedded viewer always has: the twinkling stars and the
134
+ traced loading silhouette, the 3D skyline with its control bar, the OpenStreetMap
135
+ credit, a small **show original** button until the first 3D frame (and again if the
136
+ graphics context is lost), so the original drawing stays reachable even if WebGL or
137
+ the scene's code cannot load, building details in a panel over the skyline, and
138
+ reduced motion. `F`, pressed inside the viewer, shows its element fullscreen.
139
+
114
140
  The scene's control bar starts folded behind its star; set `controls: 'open'` to
115
141
  start it open, with no fold, from its first frame. Visitors can still fold it.
116
142
  Open, the bar shows the camera hint and the OpenStreetMap credit for the streets
@@ -118,14 +144,34 @@ and footprints. Set `attribution: false` to leave the credit out. OpenStreetMap'
118
144
  [licence](https://www.openstreetmap.org/copyright) requires crediting its contributors
119
145
  wherever its data is shown, so do this only when the host page credits
120
146
  © OpenStreetMap contributors itself.
121
- A small independent **show original** button remains available until the first
122
- 3D frame succeeds, and reappears if the graphics context is lost. The SVG stays
123
- reachable even if WebGL, the scene document, or its bootstrap script cannot load.
124
- Unmounting removes the entire document, its event handlers, and its WebGL context.
147
+
125
148
  Imports themselves do not access `window` or `document`, so server rendering and
126
149
  lazy loading are safe. Asset URLs may be relative or absolute HTTP(S) directories.
127
150
 
128
- For the existing React route in `devopsrockstars`, an effect owns this lifecycle:
151
+ ### Living in the host's page
152
+
153
+ - The host's styles stay out: the element sets `all: initial` and `direction: ltr` on
154
+ itself, so nothing the host's rules set is inherited into the viewer, and the
155
+ viewer sets its own type, colours and custom properties. Its styles stay in its
156
+ shadow root. It lays itself out by its own size, through container queries, not the
157
+ window's: a small window in a desktop gets the narrow layout.
158
+ - Presses and keys bubble out to the host, as any other content's do: a press inside
159
+ the viewer reaches the host's `pointerdown` and `mousedown` listeners, so a host's
160
+ window can raise itself and its menus close. Its keys act only where they are
161
+ pressed: `F`, `Escape` and its arrow, zoom and menu keys work while focus is inside
162
+ that viewer, and arrive with `defaultPrevented` set, so two viewers, or a viewer and
163
+ the host, never take each other's keys.
164
+ - The viewer never scrolls the host page: it moves focus with `preventScroll`, and
165
+ its building detail is a non-modal panel (`role="dialog"`, not `aria-modal`), so the
166
+ rest of the page stays usable.
167
+ - It works wherever the host puts it: in the host's own shadow root, open or closed, and
168
+ under a host's CSS transform, where its menus and tooltip still open at the pointer.
169
+ - Each instance is independent: two viewers can run side by side on one page.
170
+
171
+ ### React
172
+
173
+ An effect owns the lifecycle. Mounting is synchronous, so React Strict Mode's mount,
174
+ destroy and mount again in development works:
129
175
 
130
176
  ```tsx
131
177
  import React, { useEffect, useRef } from 'react';
@@ -136,6 +182,8 @@ export default function Skyline() {
136
182
  useEffect(() => {
137
183
  const skyline = mountSkyline(container.current!, {
138
184
  assetsUrl: '/static/skyline/',
185
+ // The page already runs Three.js: share it (see below).
186
+ three: import('@a2f0/skyline/three'),
139
187
  });
140
188
  return () => skyline.destroy();
141
189
  }, []);
@@ -144,15 +192,95 @@ export default function Skyline() {
144
192
  }
145
193
  ```
146
194
 
147
- Keep the host's header, footer, sizing, and stacking in that application. Importing
148
- `@a2f0/skyline` loads only the small mounting helper; scene geometry loads inside
149
- the viewer when it boots. The cleanup also supports React Strict Mode's remounts.
195
+ Keep the host's header, footer, sizing, and stacking in that application.
196
+
197
+ ### Share the page's Three.js
198
+
199
+ The viewer's 3D scene renders with Three.js r186. By default it loads the copy in the
200
+ assets, `vendor/three-r186.bundle.js` (about 140 KB compressed), only when a scene
201
+ first needs it, so a host needs no `three` dependency. A page that already runs
202
+ Three.js would then run two engines, and Three.js warns "Multiple instances of
203
+ Three.js being imported". Such a page can share its own instead, through the `three`
204
+ option:
205
+
206
+ ```ts
207
+ mountSkyline(container, { assetsUrl: '/static/skyline/', three: import('@a2f0/skyline/three') });
208
+ ```
209
+
210
+ `@a2f0/skyline/three` re-exports from the host's `three` (the optional peer,
211
+ `~0.186.0`) exactly the classes the viewer uses, and `OrbitControls`, so a bundler keeps
212
+ no more of the engine than the viewer needs; the viewer then never requests its own
213
+ copy. Pass the module, or a promise of it as above, which keeps it out of the host's
214
+ first bundle when the viewer mounts later. The page's first scene takes the engine
215
+ when its code first loads, and later instances on the page share it; if the promise
216
+ rejects, the viewer warns and loads its own copy. A host without `three` simply leaves
217
+ the option out.
218
+
219
+ ### How the viewer's code loads
220
+
221
+ The package's module (`lib/`) is a small loader that a host bundles (under 4 KB). When
222
+ it mounts, it imports the viewer's code from the assets (`skyline-viewer.js`) with a
223
+ dynamic `import()` that carries `webpackIgnore`, `turbopackIgnore` and `@vite-ignore`
224
+ comments, so webpack (including Next.js with `--webpack`), Turbopack and Vite leave it
225
+ to the browser; Rollup, esbuild and Bun leave an import of a variable alone anyway. A
226
+ bundler that tried to resolve it would fail to build: tell it to ignore that import.
227
+ The viewer then loads its stylesheets, the scene's markup from `skyline-3d.html`, its
228
+ stars from `stars.svg`, and the scene's modules and models from the same directory.
229
+ Each module loads once per page, however many viewers mount.
230
+
231
+ ### Content security policy and other origins
232
+
233
+ The viewer runs in the host's page, under the host's content security policy. A host
234
+ with one must allow the assets' origin (usually `'self'`) in `script-src`,
235
+ `style-src` and `connect-src` (the viewer fetches its markup and stars), and inline
236
+ styles in `style-src`, which the viewer sets on its elements. Serve the assets from
237
+ the host's origin when you can; on another origin, every file must allow the page's
238
+ origin through CORS. The viewer's code runs with the host page's privileges wherever
239
+ it is served from: serve it only from an origin you trust.
240
+
241
+ ### For tests
242
+
243
+ `element.shadowRoot` holds the viewer: its 3D canvas is `#building` and the scene's
244
+ loading indicator `#loading`, hidden at the first frame. The region drops `aria-busy`
245
+ when `ready` settles. WebDriver and Playwright reach into open shadow roots; code run
246
+ in the page reads `element.shadowRoot.querySelector(...)`.
247
+
248
+ ### From 0.1 (iframe) to 0.2
249
+
250
+ Before 0.2, the viewer ran in an iframe. Hosts upgrading:
251
+
252
+ - `element` is the viewer's region element, not an `HTMLIFrameElement`: wait for
253
+ `ready` instead of the frame's `load` event, and reach the viewer through
254
+ `element.shadowRoot` instead of `contentDocument`. There are no nested frames: the
255
+ 3D scene and its building detail render in the same shadow root (the detail in a
256
+ shadow root of its own, inside the scene's panel), and they no longer post
257
+ `skyline:` messages to each other.
258
+ - `navigation` is gone. It showed the site's own toolbar, which only the site's
259
+ `index.html` shows now. The viewer's options are `assetsUrl`, `title`, `controls`,
260
+ `attribution` and `three`.
261
+ - Presses inside the viewer reach the host as any other press does, so a workaround
262
+ that raised a window when a frame took focus is no longer needed.
263
+ - The viewer no longer has its own window: `window.__buildingStudy`, which tests read
264
+ inside the scene's frame, is not set on the host's window.
265
+ - Copy the assets again: the viewer's code is new files (`skyline-viewer.js`,
266
+ `viewer.css` and others).
267
+
268
+ ### Limitations
269
+
270
+ - Removing the element without calling `destroy()` leaves the viewer running.
271
+ - If the viewer's or scene's code fails to load, as on a dropped connection, the
272
+ browser keeps that failure for the page: later mounts on the same page fail the same
273
+ way until it reloads. A frame used to retry with a new document.
274
+ - The site's own `index.html` shows two prototypes from its toolbar, the hover-scripted
275
+ enhanced drawing and the WebGL prototype, as pages of their own in frames; a host's
276
+ viewer never shows them.
150
277
 
151
278
  ## Import a scene or a building
152
279
 
153
280
  For an application that owns its own renderer, install `three` from the r186
154
281
  series (`~0.186.0`). `devopsrockstars` already uses a compatible version. The
155
- complete iframe viewer does not need this peer; scene/model imports do.
282
+ mounted viewer needs this peer only to share the host's engine; scene/model imports
283
+ always do.
156
284
 
157
285
  ```ts
158
286
  import { Group } from 'three';
@@ -205,7 +333,14 @@ overlap, and when merges land together only the newest pending run starts, so in
205
333
  versions can be skipped on npm.
206
334
 
207
335
  The workflow first runs the full `bun run check`, browser suites included, in the runner's
208
- Google Chrome. Only when that passes does it build and pack the tarball; both jobs run without
336
+ Google Chrome with the pinned Node.js LTS. The dependency audit is part of that
337
+ check: it needs the registry network, and any advisory in the locked dependency
338
+ graph fails the check and blocks publishing until addressed.
339
+ If that changes after merge, report the failed release, fix the dependency or
340
+ registry failure, then re-dispatch the workflow to release the merged version.
341
+ It also runs the
342
+ tarball and viewer package suite on Node.js 22 to verify the supported minimum.
343
+ Only when those pass does it build and pack the tarball; both jobs run without
209
344
  publish rights, and the publish job receives only that tarball and runs
210
345
  `npm publish --ignore-scripts`. Dispatching the workflow on another branch
211
346
  (`gh workflow run npm-publish.yml --ref <branch>`) runs only the tests, in a queue of its own,
@@ -224,6 +359,9 @@ Then, in the package's settings on npmjs.com, add a GitHub Actions trusted publi
224
359
  repository `a2f0/skyline`, workflow `npm-publish.yml`, and environment `npm`, and re-run
225
360
  the workflow; it reports the version as already published.
226
361
 
362
+ The Sharp override in the tarball is repository development tooling policy; npm
363
+ and Bun consumers use their own root overrides, so it does not pin their dependencies.
364
+
227
365
  The package is ESM only, with no CommonJS entrypoint. Building requires this repository's
228
366
  Bun/TypeScript tools; consuming the published package requires neither Bun nor build
229
367
  scripts. Metadata uses `UNLICENSED` and preserves the third-party notices in
@@ -1,31 +1,39 @@
1
+ import { type SkylineInstance } from "./skyline-shell.js";
2
+ import type { SkylineThree } from "./three-engine.js";
1
3
  export type { BuildingModel, Vec2, Vec3 } from "./models/building-kit.js";
2
4
  export type { FitBox, PlatformOptions, ShadowCameraOptions, StudyView, StudyLayout, StudyLabel } from "./study-types.js";
3
5
  export type { GeographicSkyline } from "./skyline-scene.js";
6
+ export type { SkylineInstance } from "./skyline-shell.js";
7
+ export type { SkylineThree } from "./three-engine.js";
4
8
  export interface SkylineOptions {
5
- /** Directory serving the package's viewer assets, e.g. "/static/skyline/". */
6
- assetsUrl: string | URL;
7
- /** Accessible title for the embedded viewer. */
8
- title?: string;
9
- /** Show the standalone toolbar and study links. Hidden by default when embedded. */
10
- navigation?: boolean;
9
+ /**
10
+ * The directory serving the package's viewer assets, as copied by `copySkylineAssets`
11
+ * from `@a2f0/skyline/build`; for example "/static/skyline/". Relative URLs resolve
12
+ * against the page. The viewer's code, styles, drawings and models load from it.
13
+ */
14
+ readonly assetsUrl: string | URL;
15
+ /** The viewer's accessible name: `element` is a region with this label. */
16
+ readonly title?: string;
11
17
  /** Start the 3D scene's control bar open, or folded behind its star. Closed by default. */
12
- controls?: "open" | "closed";
18
+ readonly controls?: "open" | "closed";
13
19
  /**
14
20
  * Show the OpenStreetMap credit with the 3D scene's controls. Shown by default. OpenStreetMap's
15
21
  * licence requires that credit wherever its data appears, so hide it only when the host page
16
22
  * credits OpenStreetMap contributors itself.
17
23
  */
18
- attribution?: boolean;
19
- }
20
- export interface SkylineInstance {
21
- /** The viewer's frame; its styles are isolated from the host application. */
22
- readonly element: HTMLIFrameElement;
23
- /** Removes the viewer, releasing its document, event handlers and WebGL context. */
24
- destroy(): void;
24
+ readonly attribution?: boolean;
25
+ /**
26
+ * The host's own Three.js, for a page that already runs it: pass `@a2f0/skyline/three`, or a
27
+ * dynamic import of it, and the scene renders with the host's engine instead of downloading
28
+ * the copy in the assets. The page's first scene takes the engine; leave it out to use the
29
+ * assets' copy, which needs no `three` dependency.
30
+ */
31
+ readonly three?: SkylineThree | PromiseLike<SkylineThree>;
25
32
  }
26
33
  /**
27
- * Mounts the complete interactive viewer inside a host-owned container. Importing
28
- * this module never touches the DOM; call it after mounting a browser component.
29
- * The container controls placement and size. Destroy it when that component leaves.
34
+ * Mounts the complete interactive viewer filling `container`, inside a shadow root. It
35
+ * returns at once; `ready` settles when the 3D skyline shows. The container sets the viewer's
36
+ * size and placement: give it an explicit height. Call `destroy` when the hosting component
37
+ * leaves.
30
38
  */
31
- export declare function mountSkyline(container: HTMLElement, { assetsUrl, title, navigation, controls, attribution }: SkylineOptions): SkylineInstance;
39
+ export declare function mountSkyline(container: HTMLElement, { assetsUrl, title, controls, attribution, three }: SkylineOptions): SkylineInstance;
@@ -1,31 +1,15 @@
1
+ // The browser entrypoint of the @a2f0/skyline package: mounts the viewer in a host-owned
2
+ // container, inside a shadow root in the host's own document. Importing it never touches the
3
+ // DOM, so server rendering and lazy loading are safe. docs/package.md describes the package.
4
+ import { mountViewer } from "./skyline-shell.js";
1
5
  /**
2
- * Mounts the complete interactive viewer inside a host-owned container. Importing
3
- * this module never touches the DOM; call it after mounting a browser component.
4
- * The container controls placement and size. Destroy it when that component leaves.
6
+ * Mounts the complete interactive viewer filling `container`, inside a shadow root. It
7
+ * returns at once; `ready` settles when the 3D skyline shows. The container sets the viewer's
8
+ * size and placement: give it an explicit height. Call `destroy` when the hosting component
9
+ * leaves.
5
10
  */
6
- export function mountSkyline(container, { assetsUrl, title = "Interactive Chicago skyline", navigation = false, controls = "closed", attribution = true }) {
11
+ export function mountSkyline(container, { assetsUrl, title = "Interactive Chicago skyline", controls = "closed", attribution = true, three }) {
7
12
  if (controls !== "open" && controls !== "closed")
8
13
  throw new TypeError('Skyline controls must be "open" or "closed".');
9
- const document = container.ownerDocument;
10
- const base = new URL(assetsUrl, document.baseURI);
11
- if (!/^(https?:)$/.test(base.protocol))
12
- throw new TypeError("Skyline assets must be served over HTTP or HTTPS.");
13
- if (base.search || base.hash)
14
- throw new TypeError("Skyline assetsUrl must be a directory URL without a query or fragment.");
15
- if (!base.pathname.endsWith("/"))
16
- base.pathname += "/";
17
- const element = document.createElement("iframe");
18
- element.title = title;
19
- const viewerUrl = new URL("index.html", base);
20
- if (!navigation)
21
- viewerUrl.searchParams.set("embed", "1");
22
- if (controls === "open")
23
- viewerUrl.searchParams.set("controls", "open");
24
- if (!attribution)
25
- viewerUrl.searchParams.set("attribution", "hidden");
26
- element.src = viewerUrl.href;
27
- element.allowFullscreen = true;
28
- element.style.cssText = "display:block;width:100%;height:100%;border:0";
29
- container.append(element);
30
- return { element, destroy() { element.remove(); } };
14
+ return mountViewer(container, { assetsUrl, title, controls, attribution, navigation: false, page: false, three });
31
15
  }
@@ -0,0 +1,69 @@
1
+ import type { SkylineThree } from "./three-engine.js";
2
+ /** What the viewer module is told when it mounts. */
3
+ export interface ViewerContext {
4
+ /** The slash-terminated directory the assets are served from. */
5
+ readonly assets: URL;
6
+ /**
7
+ * Aborts when the instance is destroyed, or the viewer fails to start. The viewer then
8
+ * releases everything it holds: its WebGL contexts, animation frames, observers, listeners
9
+ * outside its root and pending loads. The shell removes the DOM.
10
+ */
11
+ readonly signal: AbortSignal;
12
+ /** Start the 3D scene's control bar open, rather than folded behind its star. */
13
+ readonly controls: "open" | "closed";
14
+ /** Show the OpenStreetMap credit with the scene's controls. */
15
+ readonly attribution: boolean;
16
+ /**
17
+ * The site's own toolbar, along the viewer's top: fullscreen, the drawings, the WebGL
18
+ * prototype, the studies and the stars' debug motion. Only the site's index.html shows it.
19
+ */
20
+ readonly navigation: boolean;
21
+ /** The viewer is its page's whole content, so the page's keys are its keys. */
22
+ readonly page: boolean;
23
+ /** The host's Three.js, for the scene to share (three-engine.ts). */
24
+ readonly three?: SkylineThree | PromiseLike<SkylineThree> | undefined;
25
+ }
26
+ /** What the viewer module exports. */
27
+ export interface ViewerModule {
28
+ /**
29
+ * Renders the viewer into `root`, whose stylesheets have loaded, and settles once the 3D
30
+ * skyline has drawn its first frame: a rejection is a scene that can't start, and the
31
+ * viewer then says why in place and keeps the drawing one press away.
32
+ */
33
+ mount(root: ShadowRoot, context: ViewerContext): Promise<void>;
34
+ }
35
+ /** A mounted viewer. */
36
+ export interface SkylineInstance {
37
+ /**
38
+ * The element holding the viewer, appended to the container. The viewer renders into its
39
+ * open shadow root, which keeps the viewer's styles and the host's apart. It is a region
40
+ * named by the `title` option, filling the container, and is `aria-busy` until `ready`
41
+ * settles.
42
+ */
43
+ readonly element: HTMLElement;
44
+ /**
45
+ * Resolves once the 3D skyline has drawn its first frame; replaces an iframe's `load`
46
+ * event. Rejects when it can't start, as when its assets fail to load or WebGL is
47
+ * unavailable: the viewer then says why, in place, and still offers the original drawing
48
+ * if its own code loaded. Stays pending if the instance is destroyed first.
49
+ */
50
+ readonly ready: Promise<void>;
51
+ /**
52
+ * Removes the viewer and releases its WebGL contexts, animation frames, observers,
53
+ * listeners, timers and pending loads. Calling it again does nothing.
54
+ */
55
+ destroy(): void;
56
+ }
57
+ export interface ViewerOptions extends Omit<ViewerContext, "assets" | "signal"> {
58
+ /** The assets' directory; relative URLs resolve against the container's document. */
59
+ readonly assetsUrl: string | URL;
60
+ /** The viewer's accessible name: `element` is a region with this label. */
61
+ readonly title: string;
62
+ }
63
+ /** The validated, slash-terminated directory the assets are served from. */
64
+ export declare function assetsBase(document: Document, assetsUrl: string | URL): URL;
65
+ /**
66
+ * Mounts the viewer in a new element appended to `container`, and returns at once: the
67
+ * element is in place, and `ready` settles once the 3D skyline shows or can't start.
68
+ */
69
+ export declare function mountViewer(container: HTMLElement, { assetsUrl, title, ...options }: ViewerOptions): SkylineInstance;
@@ -0,0 +1,115 @@
1
+ // The viewer's code and styles, relative to the assets.
2
+ const VIEWER = "skyline-viewer.js";
3
+ const STYLESHEETS = ["study.css", "viewer.css"];
4
+ /** The validated, slash-terminated directory the assets are served from. */
5
+ export function assetsBase(document, assetsUrl) {
6
+ const base = new URL(assetsUrl, document.baseURI);
7
+ if (base.protocol !== "http:" && base.protocol !== "https:")
8
+ throw new TypeError("Skyline assets must be served over HTTP or HTTPS.");
9
+ if (base.search || base.hash)
10
+ throw new TypeError("Skyline assetsUrl must be a directory URL without a query or fragment.");
11
+ if (!base.pathname.endsWith("/"))
12
+ base.pathname += "/";
13
+ return base;
14
+ }
15
+ /**
16
+ * Imports the viewer module from the assets. Hosts bundle this file, but the module is
17
+ * served with the assets, so the comments ask webpack, Turbopack and Vite to leave the
18
+ * import to the browser; Rollup, esbuild and Bun leave an import of a variable alone.
19
+ */
20
+ function importViewer(url) {
21
+ return import(/* webpackIgnore: true */ /* @vite-ignore */ /* turbopackIgnore: true */ url);
22
+ }
23
+ /** Resolves once `link` has loaded its stylesheet. */
24
+ function loaded(link) {
25
+ return new Promise((resolve, reject) => {
26
+ link.addEventListener("load", () => resolve(), { once: true });
27
+ link.addEventListener("error", () => reject(new Error(`Couldn't load ${link.href}.`)), { once: true });
28
+ });
29
+ }
30
+ // Set on the element itself, where it outranks the host page's rules: `all: initial` keeps
31
+ // the host's rules for its elements (a `div` or `*` rule) off it, so nothing they set is
32
+ // inherited into the viewer, `direction` (which `all` leaves) keeps a right-to-left page from
33
+ // mirroring the viewer's layout, and the rest sizes it to fill the container.
34
+ const HOST_STYLE = "all:initial;direction:ltr;display:block;position:relative;isolation:isolate;width:100%;height:100%;overflow:hidden";
35
+ // The failure message's own look, since the viewer's stylesheets may be what failed.
36
+ const FAILURE_STYLE = "box-sizing:border-box;height:100%;margin:0;padding:24px;background:#000;color:#aaa;font:12px/1.5 ui-monospace,monospace";
37
+ /** Shows why the viewer couldn't start, in place of the viewer. */
38
+ function showFailure(root, error) {
39
+ const message = root.ownerDocument.createElement("p");
40
+ message.style.cssText = FAILURE_STYLE;
41
+ message.setAttribute("role", "alert");
42
+ message.textContent = `The Chicago skyline couldn't start: ${error instanceof Error ? error.message : String(error)}`;
43
+ root.replaceChildren(...[...root.querySelectorAll("link")].filter((link) => link.sheet), message);
44
+ }
45
+ /**
46
+ * Mounts the viewer in a new element appended to `container`, and returns at once: the
47
+ * element is in place, and `ready` settles once the 3D skyline shows or can't start.
48
+ */
49
+ export function mountViewer(container, { assetsUrl, title, ...options }) {
50
+ const document = container.ownerDocument;
51
+ const assets = assetsBase(document, assetsUrl);
52
+ const element = document.createElement("div");
53
+ element.style.cssText = HOST_STYLE;
54
+ element.setAttribute("role", "region");
55
+ element.setAttribute("aria-label", title);
56
+ element.setAttribute("aria-busy", "true");
57
+ const root = element.attachShadow({ mode: "open" });
58
+ const stylesheets = STYLESHEETS.map((name) => {
59
+ const link = document.createElement("link");
60
+ link.rel = "stylesheet";
61
+ link.href = new URL(name, assets).href;
62
+ return link;
63
+ });
64
+ root.append(...stylesheets);
65
+ container.append(element);
66
+ // A host's engine that fails to load is the viewer's to report, once its scene asks for it
67
+ // (three-engine.ts); until then, or if the instance never gets that far, its rejection is
68
+ // handled here rather than left unhandled.
69
+ if (options.three)
70
+ Promise.resolve(options.three).catch(() => { });
71
+ const controller = new AbortController();
72
+ let destroyed = false, mounted = false;
73
+ const context = { ...options, assets, signal: controller.signal };
74
+ const ready = new Promise((resolve, reject) => {
75
+ Promise.all([importViewer(new URL(VIEWER, assets).href), ...stylesheets.map(loaded)])
76
+ .then(async ([viewer]) => {
77
+ if (destroyed)
78
+ return;
79
+ mounted = true;
80
+ await viewer.mount(root, context);
81
+ })
82
+ .then(() => {
83
+ if (destroyed)
84
+ return;
85
+ element.removeAttribute("aria-busy");
86
+ resolve();
87
+ }, (error) => {
88
+ if (destroyed)
89
+ return;
90
+ element.removeAttribute("aria-busy");
91
+ // A viewer that mounted says why its scene can't start, and keeps its drawing;
92
+ // one that never loaded is replaced by the reason.
93
+ if (!mounted) {
94
+ controller.abort(error);
95
+ showFailure(root, error);
96
+ }
97
+ console.error("The Chicago skyline couldn't start:", error);
98
+ reject(error);
99
+ });
100
+ });
101
+ // The element and the console report a failure, so a host that ignores `ready` sees no
102
+ // unhandled rejection.
103
+ ready.catch(() => { });
104
+ return {
105
+ element,
106
+ ready,
107
+ destroy() {
108
+ if (destroyed)
109
+ return;
110
+ destroyed = true;
111
+ controller.abort(new DOMException("The Chicago skyline was destroyed.", "AbortError"));
112
+ element.remove();
113
+ },
114
+ };
115
+ }
@@ -0,0 +1,2 @@
1
+ export { AmbientLight, BoxGeometry, BufferGeometry, Color, DataTexture, DirectionalLight, EdgesGeometry, Float32BufferAttribute, Group, LineBasicMaterial, LineSegments, Mesh, MeshBasicMaterial, MeshToonMaterial, NearestFilter, PCFSoftShadowMap, OrthographicCamera, PerspectiveCamera, Raycaster, RedFormat, Scene, Vector2, Vector3, WebGLRenderer, } from "three";
2
+ export { OrbitControls } from "three/addons/controls/OrbitControls.js";
@@ -0,0 +1,7 @@
1
+ // `@a2f0/skyline/three`: the part of the host's own Three.js the viewer uses, for
2
+ // `mountSkyline`'s `three` option. Importing it needs the `three` peer (~0.186.0). Pass the
3
+ // module, or a dynamic import of it, so the viewer shares the host's engine:
4
+ //
5
+ // mountSkyline(container, { assetsUrl, three: import("@a2f0/skyline/three") });
6
+ export { AmbientLight, BoxGeometry, BufferGeometry, Color, DataTexture, DirectionalLight, EdgesGeometry, Float32BufferAttribute, Group, LineBasicMaterial, LineSegments, Mesh, MeshBasicMaterial, MeshToonMaterial, NearestFilter, PCFSoftShadowMap, OrthographicCamera, PerspectiveCamera, Raycaster, RedFormat, Scene, Vector2, Vector3, WebGLRenderer, } from "three";
7
+ export { OrbitControls } from "three/addons/controls/OrbitControls.js";
@@ -0,0 +1,16 @@
1
+ import type * as THREE from "three";
2
+ import type { OrbitControls } from "three/addons/controls/OrbitControls.js";
3
+ /**
4
+ * The part of Three.js the viewer uses: `@a2f0/skyline/three` exports exactly this from the
5
+ * host's `three`, so a host's bundler keeps no more of its engine than the viewer needs.
6
+ */
7
+ export type SkylineThree = Pick<typeof THREE, "AmbientLight" | "BoxGeometry" | "BufferGeometry" | "Color" | "DataTexture" | "DirectionalLight" | "EdgesGeometry" | "Float32BufferAttribute" | "Group" | "LineBasicMaterial" | "LineSegments" | "Mesh" | "MeshBasicMaterial" | "MeshToonMaterial" | "NearestFilter" | "PCFSoftShadowMap" | "OrthographicCamera" | "PerspectiveCamera" | "Raycaster" | "RedFormat" | "Scene" | "Vector2" | "Vector3" | "WebGLRenderer"> & {
8
+ readonly OrbitControls: typeof OrbitControls;
9
+ };
10
+ /**
11
+ * Offers a host's engine to the scene. The page's first scene takes the engine once, when its
12
+ * code first loads: an engine offered later, or by a second instance, is not used.
13
+ */
14
+ export declare function provideThree(three: SkylineThree | PromiseLike<SkylineThree>): void;
15
+ /** The host's engine, if one was offered. */
16
+ export declare function providedThree(): Promise<SkylineThree> | undefined;
@@ -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
+ }