@a2f0/skyline 0.2.20 → 0.2.23

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/docs/package.md CHANGED
@@ -10,6 +10,16 @@ To start an embed in sunny-day colour, pass `colour: true` to `mountSkyline` alo
10
10
  Omitting `colour` keeps the existing greyscale default. The setting belongs to that instance;
11
11
  it does not use storage or affect another embed. The standalone viewer accepts `?colour=1`.
12
12
 
13
+ Pass `theme: "light"` or `theme: "dark"` from the host's active theme to choose the
14
+ loading backdrop, animated outline and error palette. It applies synchronously,
15
+ before the viewer's code or stylesheets load. After the first frame, the scene
16
+ supplies its own sky; returning to greyscale restores the black sky and stars.
17
+ Without `theme`, `colour: true`
18
+ boots light and greyscale boots dark. The theme does not select scene materials or
19
+ time of day: for a light host, pass both `theme: "light"` and `colour: true`.
20
+ Destroy and remount when the host theme changes. The standalone viewer also accepts
21
+ `?theme=light&colour=1` (or `?theme=dark`); each instance keeps its own setting.
22
+
13
23
  ## Install from npm
14
24
 
15
25
  ```sh
@@ -1,5 +1,7 @@
1
1
  import type { BuildingModel } from "./building-kit.js";
2
2
  import type { Daylight } from "../study-types.js";
3
+ import { type ReflectionColours } from "./colour-reflections.js";
4
+ import { type NightLighting } from "./night-windows.js";
3
5
  export declare const materialNames: readonly ["neutral", "glass", "green glass", "blue-green glass", "bronze glass", "lit window", "dim window", "lamp", "limestone", "marble", "white granite", "granite", "dark granite", "slate", "white terracotta", "pink terracotta", "green tile", "brick", "common brick", "orange brick", "brown brick", "buff brick", "glazed brick", "concrete", "precast", "copper", "maroon", "blue enamel", "bronze", "dark metal", "aluminium", "stainless", "opaque spandrel", "crown glass", "crown lights", "screen", "band lights", "sign board", "sign letters", "cap lights"];
4
6
  export type MaterialName = (typeof materialNames)[number];
5
7
  export type Tones = Readonly<Record<number, MaterialName>>;
@@ -30,9 +32,14 @@ export interface SkylineColour {
30
32
  readonly enabled: boolean;
31
33
  /** Shows the colours, or restores the greys exactly. */
32
34
  set(enabled: boolean): void;
35
+ /** Updates reflection uniforms without rewriting geometry or recompiling shaders. */
36
+ setSky(colours: ReflectionColours | null): void;
37
+ /** Sets ordinary window activity and dims outlines for the time preview, or restores the preset. */
38
+ setNight(lighting: NightLighting | null): void;
33
39
  /**
34
40
  * Recolours vertices another writer has since rewritten in grey, such as window
35
- * illumination resetting a facade for a celebration.
41
+ * illumination resetting a facade for a celebration. Re-reads celebration state
42
+ * so ordinary night windows yield to the message while architectural lights remain.
36
43
  */
37
44
  refresh(): void;
38
45
  }
@@ -1,6 +1,7 @@
1
1
  import * as THREE from "../vendor/three-r186.js";
2
2
  import { daylightColours, measuredColours } from "./colour-palette.js";
3
- import { glassReflection } from "./colour-reflections.js";
3
+ import { glassReflection, daylightReflections } from "./colour-reflections.js";
4
+ import { nightWindowMask } from "./night-windows.js";
4
5
  // The colour trial (https://github.com/a2f0/skyline/issues/115): which named material each
5
6
  // surface of each building is, and a layer that shows the colours measured for those materials
6
7
  // (colour-palette.ts) over the greys the models build in, restoring the greys exactly when it is
@@ -358,9 +359,16 @@ export function createDaylight() {
358
359
  // another batch already took, since the builder lets batches share them.
359
360
  export function createSkylineColour(models) {
360
361
  const painted = [], flat = [], taken = new Set();
361
- const reflections = [];
362
+ const facades = [];
363
+ const outlines = [];
364
+ let night = null;
362
365
  const glassMaterials = new Set(["glass", "green glass", "blue-green glass", "bronze glass", "crown glass", "lit window", "dim window"]);
363
366
  for (const model of models) {
367
+ model.building.traverse((object) => {
368
+ const line = object;
369
+ if (line.isLineSegments && line.material instanceof THREE.LineBasicMaterial)
370
+ outlines.push({ material: line.material, opacity: line.material.opacity });
371
+ });
364
372
  const buildingId = String(model.building.userData["buildingId"]);
365
373
  if (!measuredColours[buildingId])
366
374
  continue;
@@ -381,7 +389,13 @@ export function createSkylineColour(models) {
381
389
  return;
382
390
  const [base, tones = {}] = batch;
383
391
  const material = mesh.material;
384
- const materialOf = (grey) => tones[toneOf(grey)] ?? base;
392
+ const names = new Map();
393
+ const materialOf = (grey) => {
394
+ let name = names.get(grey);
395
+ if (!name)
396
+ names.set(grey, name = tones[toneOf(grey)] ?? base);
397
+ return name;
398
+ };
385
399
  const attribute = mesh.geometry.getAttribute("color");
386
400
  if (material.vertexColors) {
387
401
  if (!(attribute?.array instanceof Float32Array) || taken.has(attribute))
@@ -413,8 +427,9 @@ export function createSkylineColour(models) {
413
427
  }
414
428
  mask[vertex] = reflects;
415
429
  }
416
- if (mask.some((value) => value > 0))
417
- reflections.push(glassReflection(material, geometry, mask));
430
+ const windows = nightWindowMask(geometry, buildingId, (vertex) => materialOf(colours ? colours.array[vertex * 3] : material.color.r));
431
+ if (mask.some((value) => value > 0) || windows.some((value) => value > 0))
432
+ facades.push({ light: glassReflection(material, geometry, mask, windows), model });
418
433
  const colourOf = (grey) => {
419
434
  const swatch = swatches.get(materialOf(grey));
420
435
  return swatch ? paint(grey, swatch) : [grey, grey, grey];
@@ -435,6 +450,12 @@ export function createSkylineColour(models) {
435
450
  }
436
451
  }
437
452
  let enabled = false;
453
+ function updateNight() {
454
+ for (const { light, model } of facades)
455
+ light.setNight(night, enabled, Boolean(model.illumination?.active));
456
+ for (const { material, opacity } of outlines)
457
+ material.opacity = opacity * (night?.outlineStrength ?? 1);
458
+ }
438
459
  return {
439
460
  get enabled() { return enabled; },
440
461
  set(next) {
@@ -459,10 +480,20 @@ export function createSkylineColour(models) {
459
480
  }
460
481
  for (const { material, grey, colour } of flat)
461
482
  material.color.copy(next ? colour : grey);
462
- for (const reflect of reflections)
463
- reflect(next);
483
+ for (const { light } of facades)
484
+ light(next);
485
+ updateNight();
486
+ },
487
+ setSky(colours) {
488
+ for (const { light } of facades)
489
+ light.setSky(colours ?? daylightReflections);
490
+ },
491
+ setNight(lighting) {
492
+ night = lighting;
493
+ updateNight();
464
494
  },
465
495
  refresh() {
496
+ updateNight();
466
497
  if (!enabled)
467
498
  return;
468
499
  for (const batch of painted) {
@@ -6,3 +6,35 @@ export declare const daylightColours: {
6
6
  readonly horizon: 12898785;
7
7
  readonly sun: 16774374;
8
8
  };
9
+ export declare const nightColours: {
10
+ readonly ambient: 10596296;
11
+ readonly fill: 9281469;
12
+ readonly window: 16764817;
13
+ readonly windowWhite: 16772558;
14
+ };
15
+ export declare const timeOfDayColours: readonly [{
16
+ readonly elevation: -18;
17
+ readonly sky: 132108;
18
+ readonly horizon: 527643;
19
+ readonly sun: 16756855;
20
+ }, {
21
+ readonly elevation: -8;
22
+ readonly sky: 1319749;
23
+ readonly horizon: 5984878;
24
+ readonly sun: 16756086;
25
+ }, {
26
+ readonly elevation: -2;
27
+ readonly sky: 4283273;
28
+ readonly horizon: 15244153;
29
+ readonly sun: 16757879;
30
+ }, {
31
+ readonly elevation: 6;
32
+ readonly sky: 8560581;
33
+ readonly horizon: 15778720;
34
+ readonly sun: 16766120;
35
+ }, {
36
+ readonly sky: 10402779;
37
+ readonly horizon: 12898785;
38
+ readonly sun: 16774374;
39
+ readonly elevation: 25;
40
+ }];
@@ -290,3 +290,14 @@ export const measuredColours = {
290
290
  // above the horizon, each the median of a clear box of the same panorama (scripts/sample-colours.ts,
291
291
  // the day study's sky), and the sun's warm white, chosen.
292
292
  export const daylightColours = { sky: 0x9ebbdb, horizon: 0xc4d1e1, sun: 0xfff4e6 };
293
+ // Art-directed night illumination: quiet cool facades and a mix of warm office lamps.
294
+ export const nightColours = { ambient: 0xa1afc8, fill: 0x8d9fbd, window: 0xffcf91, windowWhite: 0xffedce };
295
+ // Illustrated clear-sky time previews. Solar elevation chooses the stops; these are
296
+ // artistic colours, separate from the photograph-calibrated daytime palette above.
297
+ export const timeOfDayColours = [
298
+ { elevation: -18, sky: 0x02040c, horizon: 0x080d1b, sun: 0xffb077 },
299
+ { elevation: -8, sky: 0x142345, horizon: 0x5b526e, sun: 0xffad76 },
300
+ { elevation: -2, sky: 0x415b89, horizon: 0xe89b79, sun: 0xffb477 },
301
+ { elevation: 6, sky: 0x829fc5, horizon: 0xf0c3a0, sun: 0xffd4a8 },
302
+ { elevation: 25, ...daylightColours },
303
+ ];
@@ -1,2 +1,14 @@
1
1
  import * as THREE from "../vendor/three-r186.js";
2
- export declare function glassReflection(material: THREE.MeshToonMaterial, geometry: THREE.BufferGeometry, mask: Float32Array): (enabled: boolean) => void;
2
+ import type { NightLighting } from "./night-windows.js";
3
+ export interface ReflectionColours {
4
+ sky: number;
5
+ horizon: number;
6
+ ground: number;
7
+ }
8
+ export declare const daylightReflections: ReflectionColours;
9
+ export interface GlassReflection {
10
+ (enabled: boolean): void;
11
+ setSky(colours: ReflectionColours): void;
12
+ setNight(lighting: NightLighting | null, coloured: boolean, celebration: boolean): void;
13
+ }
14
+ export declare function glassReflection(material: THREE.MeshToonMaterial, geometry: THREE.BufferGeometry, mask: Float32Array, windows?: Float32Array): GlassReflection;
@@ -1,32 +1,51 @@
1
1
  import * as THREE from "../vendor/three-r186.js";
2
- import { daylightColours } from "./colour-palette.js";
2
+ import { daylightColours, nightColours } from "./colour-palette.js";
3
+ export const daylightReflections = { ...daylightColours, ground: 0x444444 };
3
4
  // A small sky reflection over the measured glass colour. This is an illustration of an open
4
5
  // sky, not a reflection of nearby buildings or a replacement for the toon renderer. The mask
5
6
  // separates glass from masonry and mullions even when the builder batches them in one mesh.
6
- // Install only in colour mode, and restore the original shader on exit. Keep the mask attached
7
- // once used so Three.js releases its GPU buffer when it disposes the geometry.
8
- export function glassReflection(material, geometry, mask) {
7
+ // The same shader adds night-window emission, independently of material colour. Restore the
8
+ // original shader outside colour/time previews. Keep attributes attached once used so Three.js
9
+ // releases their GPU buffers when it disposes the geometry.
10
+ export function glassReflection(material, geometry, mask, windows = new Float32Array(mask.length * 4)) {
9
11
  const attribute = new THREE.Float32BufferAttribute(mask, 1);
12
+ const windowAttribute = new THREE.Float32BufferAttribute(windows, 4);
10
13
  const originalCompile = material.onBeforeCompile, originalKey = material.customProgramCacheKey;
11
14
  const key = originalKey.call(material);
12
15
  const uniforms = {
13
16
  skylineSky: { value: new THREE.Color(daylightColours.sky) },
14
17
  skylineHorizon: { value: new THREE.Color(daylightColours.horizon) },
15
18
  skylineGround: { value: new THREE.Color(0x444444) },
19
+ skylineReflecting: { value: 0 },
20
+ skylineDarkness: { value: 0 },
21
+ skylineActivity: { value: 0 },
22
+ skylineWindowsSuppressed: { value: 0 },
23
+ skylineWindowWarm: { value: new THREE.Color(nightColours.window) },
24
+ skylineWindowWhite: { value: new THREE.Color(nightColours.windowWhite) },
16
25
  };
17
26
  const compile = (shader, renderer) => {
18
27
  originalCompile.call(material, shader, renderer);
19
28
  Object.assign(shader.uniforms, uniforms);
20
29
  shader.vertexShader = shader.vertexShader.replace("#include <common>", `#include <common>
21
30
  attribute float skylineGlass;
22
- varying float vSkylineGlass;`).replace("#include <begin_vertex>", `#include <begin_vertex>
23
- vSkylineGlass = skylineGlass;`);
31
+ attribute vec4 skylineWindow;
32
+ varying float vSkylineGlass;
33
+ varying vec4 vSkylineWindow;`).replace("#include <begin_vertex>", `#include <begin_vertex>
34
+ vSkylineGlass = skylineGlass;
35
+ vSkylineWindow = skylineWindow;`);
24
36
  shader.fragmentShader = shader.fragmentShader.replace("#include <common>", `#include <common>
25
37
  varying float vSkylineGlass;
38
+ varying vec4 vSkylineWindow;
26
39
  uniform vec3 skylineSky;
27
40
  uniform vec3 skylineHorizon;
28
- uniform vec3 skylineGround;`).replace("#include <opaque_fragment>", `
29
- if (vSkylineGlass > 0.0) {
41
+ uniform vec3 skylineGround;
42
+ uniform float skylineReflecting;
43
+ uniform float skylineDarkness;
44
+ uniform float skylineActivity;
45
+ uniform float skylineWindowsSuppressed;
46
+ uniform vec3 skylineWindowWarm;
47
+ uniform vec3 skylineWindowWhite;`).replace("#include <opaque_fragment>", `
48
+ if (vSkylineGlass > 0.0 && skylineReflecting > 0.0) {
30
49
  vec3 eye = vec3(0.0);
31
50
  eye.z = 1.0;
32
51
  if (!isOrthographic) eye = normalize(vViewPosition);
@@ -36,18 +55,58 @@ if (vSkylineGlass > 0.0) {
36
55
  float grazing = pow(1.0 - clamp(dot(normal, eye), 0.0, 1.0), 5.0);
37
56
  outgoingLight = mix(outgoingLight, environment, vSkylineGlass * (0.06 + 0.54 * grazing));
38
57
  }
58
+ if (vSkylineWindow.z > 0.0 && skylineDarkness > 0.0) {
59
+ float occupied = 1.0 - smoothstep(skylineActivity - 0.05, skylineActivity + 0.05, vSkylineWindow.x);
60
+ float dusk = smoothstep(0.12 + 0.35 * vSkylineWindow.x, 0.45 + 0.35 * vSkylineWindow.x, skylineDarkness);
61
+ float light = vSkylineWindow.z > 1.5 ? skylineDarkness : occupied * dusk * (1.0 - skylineWindowsSuppressed);
62
+ vec3 lamp = mix(skylineWindowWarm, skylineWindowWhite, vSkylineWindow.w);
63
+ outgoingLight += lamp * vSkylineWindow.y * light;
64
+ }
39
65
  #include <opaque_fragment>`);
40
66
  };
41
- return (enabled) => {
67
+ let reflecting = false, night = false, installed = false;
68
+ function install() {
69
+ const enabled = reflecting || night;
70
+ if (enabled === installed)
71
+ return;
72
+ installed = enabled;
42
73
  if (enabled) {
43
74
  geometry.setAttribute("skylineGlass", attribute);
75
+ geometry.setAttribute("skylineWindow", windowAttribute);
44
76
  material.onBeforeCompile = compile;
45
- material.customProgramCacheKey = () => `${key}:skyline-glass-v1`;
77
+ material.customProgramCacheKey = () => `${key}:skyline-facade-v2`;
46
78
  }
47
79
  else {
48
80
  material.onBeforeCompile = originalCompile;
49
81
  material.customProgramCacheKey = originalKey;
50
82
  }
51
83
  material.needsUpdate = true;
84
+ }
85
+ const set = (enabled) => {
86
+ reflecting = enabled;
87
+ uniforms.skylineReflecting.value = enabled ? 1 : 0;
88
+ install();
52
89
  };
90
+ return Object.assign(set, {
91
+ setSky(colours) {
92
+ uniforms.skylineSky.value.setHex(colours.sky);
93
+ uniforms.skylineHorizon.value.setHex(colours.horizon);
94
+ uniforms.skylineGround.value.setHex(colours.ground);
95
+ },
96
+ setNight(lighting, coloured, celebration) {
97
+ night = lighting !== null;
98
+ // A deliberately composed celebration takes precedence over ordinary office rooms.
99
+ uniforms.skylineDarkness.value = lighting?.darkness ?? 0;
100
+ uniforms.skylineWindowsSuppressed.value = celebration ? 1 : 0;
101
+ uniforms.skylineActivity.value = lighting?.activity ?? 0;
102
+ for (const [uniform, hex] of [[uniforms.skylineWindowWarm, nightColours.window], [uniforms.skylineWindowWhite, nightColours.windowWhite]]) {
103
+ uniform.value.setHex(hex);
104
+ if (!coloured) {
105
+ const { r, g, b } = uniform.value, grey = r * 0.2126 + g * 0.7152 + b * 0.0722;
106
+ uniform.value.setRGB(grey, grey, grey);
107
+ }
108
+ }
109
+ install();
110
+ },
111
+ });
53
112
  }
@@ -0,0 +1,13 @@
1
+ import type * as THREE from "../vendor/three-r186.js";
2
+ import type { MaterialName } from "./colour-materials.js";
3
+ export interface NightLighting {
4
+ /** Sunset/dawn transition, from daylight (0) to full darkness (1). */
5
+ darkness: number;
6
+ /** Illustrative occupancy, not a record of actual tenants or working hours. */
7
+ activity: number;
8
+ /** Unlit drawing outlines must dim with the surrounding architecture. */
9
+ outlineStrength: number;
10
+ }
11
+ export declare const smooth: (low: number, high: number, value: number) => number;
12
+ export declare function windowActivity(hour: number): number;
13
+ export declare function nightWindowMask(geometry: THREE.BufferGeometry, buildingId: string, materialOf: (vertex: number) => MaterialName): Float32Array;
@@ -0,0 +1,76 @@
1
+ export const smooth = (low, high, value) => {
2
+ const t = Math.max(0, Math.min(1, (value - low) / (high - low)));
3
+ return t * t * (3 - 2 * t);
4
+ };
5
+ // A continuous daily cycle: busy evenings, a few overnight rooms, then early arrivals.
6
+ // Midnight shares the same curve from either side, including when scrubbing backwards.
7
+ export function windowActivity(hour) {
8
+ const late = hour < 12 ? hour + 24 : hour;
9
+ return 0.76 - 0.52 * smooth(21, 26, late) + 0.3 * smooth(29, 32, late) + (hour < 12 ? 0.22 * smooth(8, 12, hour) : 0);
10
+ }
11
+ function hash(value) {
12
+ let seed = 2166136261;
13
+ for (const character of value)
14
+ seed = Math.imul(seed ^ character.charCodeAt(0), 16777619);
15
+ // Avalanche adjacent room keys: plain FNV leaves trailing bay digits correlated,
16
+ // making a whole east-facing floor turn on together instead of small room groups.
17
+ seed = Math.imul(seed ^ (seed >>> 16), 0x85ebca6b);
18
+ seed = Math.imul(seed ^ (seed >>> 13), 0xc2b2ae35);
19
+ seed ^= seed >>> 16;
20
+ return (seed >>> 0) / 4294967296;
21
+ }
22
+ const architectural = new Set(["lamp", "crown lights", "band lights", "sign letters", "cap lights"]);
23
+ // The building kit emits panes as paired triangles, interspersed with standalone
24
+ // triangles for other features. Find shared edges rather than assuming quad alignment.
25
+ // Reuse those exact surfaces;
26
+ // never turn a generic glass shell, spandrel, masonry face or roof into a light source.
27
+ // Adjacent bays share a floor/room seed, so offices come on in small groups. Identity
28
+ // depends on local geometry and building ID, not load order, camera or slider direction.
29
+ export function nightWindowMask(geometry, buildingId, materialOf) {
30
+ const positions = geometry.getAttribute("position"), normals = geometry.getAttribute("normal");
31
+ const mask = new Float32Array(positions.count * 4);
32
+ const samePoint = (a, b) => positions.getX(a) === positions.getX(b) && positions.getY(a) === positions.getY(b) && positions.getZ(a) === positions.getZ(b);
33
+ for (let first = 0; first < positions.count;) {
34
+ const name = materialOf(first), crown = architectural.has(name);
35
+ const window = name === "lit window" || name === "dim window";
36
+ if (!crown && (!window || Math.abs(normals.getY(first)) > 0.5)) {
37
+ first += 3;
38
+ continue;
39
+ }
40
+ let count = 3;
41
+ if (first + 6 <= positions.count && materialOf(first + 3) === name
42
+ && normals.getX(first) === normals.getX(first + 3) && normals.getY(first) === normals.getY(first + 3) && normals.getZ(first) === normals.getZ(first + 3)) {
43
+ let shared = 0;
44
+ for (let a = first; a < first + 3; a += 1) {
45
+ for (let b = first + 3; b < first + 6; b += 1)
46
+ if (samePoint(a, b)) {
47
+ shared += 1;
48
+ break;
49
+ }
50
+ }
51
+ if (shared === 2)
52
+ count = 6;
53
+ }
54
+ const end = first + count;
55
+ let x = 0, y = 0, z = 0;
56
+ for (let vertex = first; vertex < end; vertex += 1) {
57
+ x += positions.getX(vertex);
58
+ y += positions.getY(vertex);
59
+ z += positions.getZ(vertex);
60
+ }
61
+ const floor = Math.round(y / count / 3.4), across = Math.floor(x / count / 7), depth = Math.floor(z / count / 7);
62
+ const room = hash(`${buildingId}:${floor}:${across}:${depth}`);
63
+ const brightness = hash(`${buildingId}:${floor}:${across}:${depth}:brightness`);
64
+ for (let vertex = first; vertex < end; vertex += 1) {
65
+ if (materialOf(vertex) !== name)
66
+ continue;
67
+ const rank = name === "lit window" ? 0.05 + room * 0.8 : 0.3 + room * 0.75;
68
+ mask[vertex * 4] = rank;
69
+ mask[vertex * 4 + 1] = crown ? 0.3 : 0.25 + brightness * 0.45;
70
+ mask[vertex * 4 + 2] = crown ? 2 : 1;
71
+ mask[vertex * 4 + 3] = brightness;
72
+ }
73
+ first = end;
74
+ }
75
+ return mask;
76
+ }
@@ -18,6 +18,8 @@ export interface SkylineOptions {
18
18
  readonly controls?: "open" | "closed";
19
19
  /** Start in the opt-in sunny-day colour view. Greyscale by default. */
20
20
  readonly colour?: boolean;
21
+ /** Loading backdrop and trace. Defaults to light with colour: true, dark otherwise. */
22
+ readonly theme?: "light" | "dark";
21
23
  /**
22
24
  * Show the OpenStreetMap credit with the 3D scene's controls. Shown by default. OpenStreetMap's
23
25
  * licence requires that credit wherever its data appears, so hide it only when the host page
@@ -38,4 +40,4 @@ export interface SkylineOptions {
38
40
  * size and placement: give it an explicit height. Call `destroy` when the hosting component
39
41
  * leaves.
40
42
  */
41
- export declare function mountSkyline(container: HTMLElement, { assetsUrl, title, controls, colour, attribution, three }: SkylineOptions): SkylineInstance;
43
+ export declare function mountSkyline(container: HTMLElement, { assetsUrl, title, controls, colour, theme, attribution, three }: SkylineOptions): SkylineInstance;
@@ -8,10 +8,10 @@ import { mountViewer } from "./skyline-shell.js";
8
8
  * size and placement: give it an explicit height. Call `destroy` when the hosting component
9
9
  * leaves.
10
10
  */
11
- export function mountSkyline(container, { assetsUrl, title = "Interactive Chicago skyline", controls = "closed", colour = false, attribution = true, three }) {
11
+ export function mountSkyline(container, { assetsUrl, title = "Interactive Chicago skyline", controls = "closed", colour = false, theme, attribution = true, three }) {
12
12
  if (controls !== "open" && controls !== "closed")
13
13
  throw new TypeError('Skyline controls must be "open" or "closed".');
14
14
  if (typeof colour !== "boolean")
15
15
  throw new TypeError("Skyline colour must be a boolean.");
16
- return mountViewer(container, { assetsUrl, title, controls, colour, attribution, navigation: false, page: false, three });
16
+ return mountViewer(container, { assetsUrl, title, controls, colour, theme, attribution, navigation: false, page: false, three });
17
17
  }
@@ -13,6 +13,8 @@ export interface ViewerContext {
13
13
  readonly controls: "open" | "closed";
14
14
  /** Start the scene and its building details in sunny-day colour. */
15
15
  readonly colour?: boolean;
16
+ /** Loading backdrop and trace; defaults to light for colour, dark otherwise. */
17
+ readonly theme: "light" | "dark";
16
18
  /** Show the OpenStreetMap credit with the scene's controls. */
17
19
  readonly attribution: boolean;
18
20
  /**
@@ -56,7 +58,8 @@ export interface SkylineInstance {
56
58
  */
57
59
  destroy(): void;
58
60
  }
59
- export interface ViewerOptions extends Omit<ViewerContext, "assets" | "signal"> {
61
+ export interface ViewerOptions extends Omit<ViewerContext, "assets" | "signal" | "theme"> {
62
+ readonly theme?: "light" | "dark" | undefined;
60
63
  /** The assets' directory; relative URLs resolve against the container's document. */
61
64
  readonly assetsUrl: string | URL;
62
65
  /** The viewer's accessible name: `element` is a region with this label. */
@@ -33,7 +33,7 @@ function loaded(link) {
33
33
  // mirroring the viewer's layout, and the rest sizes it to fill the container.
34
34
  const HOST_STYLE = "all:initial;direction:ltr;display:block;position:relative;isolation:isolate;width:100%;height:100%;overflow:hidden";
35
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";
36
+ const FAILURE_STYLE = "box-sizing:border-box;height:100%;margin:0;padding:24px;background:inherit;color:inherit;font:12px/1.5 ui-monospace,monospace";
37
37
  /** Shows why the viewer couldn't start, in place of the viewer. */
38
38
  function showFailure(root, error) {
39
39
  const message = root.ownerDocument.createElement("p");
@@ -49,8 +49,16 @@ function showFailure(root, error) {
49
49
  export function mountViewer(container, { assetsUrl, title, ...options }) {
50
50
  const document = container.ownerDocument;
51
51
  const assets = assetsBase(document, assetsUrl);
52
+ const theme = options.theme === undefined ? (options.colour ? "light" : "dark") : options.theme;
53
+ if (theme !== "light" && theme !== "dark")
54
+ throw new TypeError('Skyline theme must be "light" or "dark".');
52
55
  const element = document.createElement("div");
53
56
  element.style.cssText = HOST_STYLE;
57
+ // Paint before any assets arrive, including when a stylesheet fails to load.
58
+ // Keep the boot palette in sync with viewer.css and index.html.
59
+ element.style.colorScheme = theme;
60
+ element.style.backgroundColor = theme === "light" ? "#f5f5f5" : "#000";
61
+ element.style.color = theme === "light" ? "#444" : "#aaa";
54
62
  element.setAttribute("role", "region");
55
63
  element.setAttribute("aria-label", title);
56
64
  element.setAttribute("aria-busy", "true");
@@ -70,7 +78,7 @@ export function mountViewer(container, { assetsUrl, title, ...options }) {
70
78
  Promise.resolve(options.three).catch(() => { });
71
79
  const controller = new AbortController();
72
80
  let destroyed = false, mounted = false;
73
- const context = { ...options, assets, signal: controller.signal };
81
+ const context = { ...options, theme, assets, signal: controller.signal };
74
82
  const ready = new Promise((resolve, reject) => {
75
83
  Promise.all([importViewer(new URL(VIEWER, assets).href), ...stylesheets.map(loaded)])
76
84
  .then(async ([viewer]) => {
@@ -49,6 +49,8 @@ export interface StudyLabel {
49
49
  }
50
50
  export interface Daylight {
51
51
  sky: THREE.Texture;
52
+ /** Unit vector toward the sun in world east/up/south coordinates. */
53
+ direction?: Vec3;
52
54
  sun: readonly [colour: number, intensity: number];
53
55
  ambient: readonly [colour: number, intensity: number];
54
56
  fill: readonly [colour: number, intensity: number];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a2f0/skyline",
3
- "version": "0.2.20",
3
+ "version": "0.2.23",
4
4
  "description": "Interactive Chicago skyline viewer and reusable Three.js building models",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -2,7 +2,8 @@ import * as THREE from "./vendor/three-r186.js";
2
2
  import { createBuildingStudy } from "./study-viewer.js";
3
3
  import { skylineAzimuth } from "./skyline-comparison.js";
4
4
  import { geographicBuildings } from "./models/skyline-geography-data.js";
5
- import { createGeographicBuilding, footprintMetrics } from "./models/skyline-geography.js";
5
+ import { createGeographicBuilding, footprintMetrics, geographicOrigin } from "./models/skyline-geography.js";
6
+ import { createTimeLighting } from "./time-of-day.js";
6
7
  import { createDaylight, createSkylineColour } from "./models/colour-materials.js";
7
8
  // One mapped building of the 3D skyline, alone on its own platform, as the building study
8
9
  // shows Crain: building-detail.html?building=<id>, and the panel the 3D skyline floats over
@@ -82,14 +83,30 @@ function showBuilding(record, root, frame, signal, initiallyColoured, onColourCh
82
83
  });
83
84
  const colour = createSkylineColour([model]), button = root.querySelector("#colour");
84
85
  let daylight;
85
- signal.addEventListener("abort", () => daylight?.sky.dispose(), { once: true });
86
+ let selectedTime = null;
87
+ let timeLight;
88
+ signal.addEventListener("abort", () => { daylight?.sky.dispose(); timeLight?.dispose(); }, { once: true });
86
89
  function setColour(enabled) {
87
90
  colour.set(enabled);
88
- if (enabled)
89
- daylight ??= createDaylight();
90
- viewer.setDaylight(enabled ? daylight : null);
91
+ if (selectedTime) {
92
+ // Each renderer owns its sky texture and disposal listeners. Sharing the parent's
93
+ // texture would retain closed detail renderers until the whole skyline is destroyed.
94
+ timeLight ??= createTimeLighting(geographicOrigin[1], geographicOrigin[0], false);
95
+ const lighting = timeLight.update(selectedTime, enabled);
96
+ colour.setSky(lighting.reflections);
97
+ colour.setNight(lighting.night);
98
+ viewer.setDaylight(lighting);
99
+ }
100
+ else {
101
+ if (enabled)
102
+ daylight ??= createDaylight();
103
+ colour.setSky(null);
104
+ colour.setNight(null);
105
+ viewer.setDaylight(enabled ? daylight : null);
106
+ }
91
107
  button.setAttribute("aria-pressed", String(enabled));
92
- button.title = enabled ? "Restore the building's greyscale view" : "Show the building in sunny-day colour";
108
+ button.title = selectedTime ? (enabled ? "Show this time in greyscale" : "Show this time in colour")
109
+ : enabled ? "Restore the building's greyscale view" : "Show the building in sunny-day colour";
93
110
  }
94
111
  button.addEventListener("click", () => {
95
112
  setColour(!colour.enabled);
@@ -97,7 +114,13 @@ function showBuilding(record, root, frame, signal, initiallyColoured, onColourCh
97
114
  }, { signal });
98
115
  if (initiallyColoured)
99
116
  setColour(true);
100
- return { setColour };
117
+ return {
118
+ setColour,
119
+ setTime(date, coloured) {
120
+ selectedTime = date;
121
+ setColour(coloured);
122
+ },
123
+ };
101
124
  }
102
125
  // Sources open beside the page, so a detail floated over the skyline stays where it is.
103
126
  function link(document, href, text) {
package/site/index.html CHANGED
@@ -25,7 +25,7 @@
25
25
  height: 100%;
26
26
  margin: 0;
27
27
  overflow: hidden;
28
- background: #000;
28
+ background: inherit;
29
29
  }
30
30
 
31
31
  .notice {
@@ -35,7 +35,20 @@
35
35
  color: #aaa;
36
36
  font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
37
37
  }
38
+ /* Keep the boot palette in sync with skyline-shell.ts and viewer.css. */
39
+ :root[data-theme="light"] { color-scheme: light; background: #f5f5f5; }
40
+ :root[data-theme="light"] .notice { color: #444; }
38
41
  </style>
42
+ <script>
43
+ // Apply the backdrop before first paint, while the viewer modules load.
44
+ {
45
+ const settings = new URLSearchParams(location.search);
46
+ const light = settings.get("theme") === "light" ||
47
+ (settings.get("theme") !== "dark" && settings.get("colour") === "1");
48
+ document.documentElement.dataset.theme = light ? "light" : "dark";
49
+ document.querySelector('meta[name="theme-color"]').content = light ? "#f5f5f5" : "#000000";
50
+ }
51
+ </script>
39
52
  </head>
40
53
  <body>
41
54
  <!-- The viewer renders into a shadow root inside this element, as in a host's page. -->