@unson/brainbase-mcp 0.15.5 → 0.15.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unson/brainbase-mcp",
3
- "version": "0.15.5",
3
+ "version": "0.15.7",
4
4
  "description": "Local-first Brainbase judgment OS that lets supported AI agents reuse a user's approved goals, priorities, decisions, and judgment criteria.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -358,5 +358,5 @@
358
358
  "engines": {
359
359
  "node": ">=20"
360
360
  },
361
- "gitHead": "9c24218a7ff1058cdf997adf92048186cd0b5e23"
361
+ "gitHead": "9d594a6e3267d90308404d606a087cbd6191baec"
362
362
  }
@@ -93,3 +93,43 @@ export function cityMeasures(business, { isFinished, judgments = [], now = Date.
93
93
  lit: recent > 0,
94
94
  };
95
95
  }
96
+
97
+ /**
98
+ * The sky for an hour of the day (scenery only; it represents no data). `mode` 'auto' follows the hour;
99
+ * 'day', 'dusk' and 'night' fix it. At night lit windows and street lamps glow the most, so the cities
100
+ * that moved lately read best then.
101
+ */
102
+ export function skyAt(hour, mode = 'auto') {
103
+ const phase = mode !== 'auto' ? mode
104
+ : hour >= 5 && hour < 7 ? 'dawn'
105
+ : hour >= 7 && hour < 17 ? 'day'
106
+ : hour >= 17 && hour < 19 ? 'dusk'
107
+ : 'night';
108
+ const skies = {
109
+ dawn: { top: '#c9d6ef', middle: '#f3d9c4', bottom: '#f6ead8', fog: 0xf1e2d2, sun: 0xffd2a8, sunIntensity: 1.6, hemi: 0.95, exposure: 1.0, glow: 1.1, lamps: true, stars: false },
110
+ day: { top: '#bfdcf0', middle: '#e3f0f2', bottom: '#f4f1e6', fog: 0xe9f1ee, sun: 0xfff1dc, sunIntensity: 2.7, hemi: 1.25, exposure: 1.05, glow: 0.6, lamps: false, stars: false },
111
+ dusk: { top: '#5d6f9e', middle: '#e7a77e', bottom: '#f3cfa4', fog: 0xe6bf9c, sun: 0xffb27a, sunIntensity: 1.3, hemi: 0.75, exposure: 0.95, glow: 1.5, lamps: true, stars: false },
112
+ night: { top: '#0d1730', middle: '#1c2b4c', bottom: '#33456a', fog: 0x2a3a5a, sun: 0x9fb4ff, sunIntensity: 0.35, hemi: 0.35, exposure: 0.9, glow: 2.4, lamps: true, stars: true },
113
+ };
114
+ return { phase, ...skies[phase] };
115
+ }
116
+
117
+ /**
118
+ * What moved since the viewer last opened the world: cities whose newest decision or a placed judgment
119
+ * is newer than `lastVisit`, and plaza buildings whose newest judgment is. Null on a first visit.
120
+ */
121
+ export function changesSince(lastVisit, businesses, judgmentsByBusiness, rows) {
122
+ const since = Date.parse(lastVisit ?? '');
123
+ if (!Number.isFinite(since)) return null;
124
+ const newer = (at) => {
125
+ const time = Date.parse(at ?? '');
126
+ return Number.isFinite(time) && time > since;
127
+ };
128
+ const cities = [];
129
+ for (const business of businesses ?? []) {
130
+ const judgments = judgmentsByBusiness?.get?.(business.code) ?? [];
131
+ if (newer(business.activity?.latest_decision_at) || judgments.some((entry) => newer(entry?.item?.proof?.recorded_at))) cities.push(business.code);
132
+ }
133
+ const plaza = (rows ?? []).filter((row) => newer(row.latest_recorded_at)).map((row) => row.key);
134
+ return { since: new Date(since).toISOString(), cities, plaza };
135
+ }
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import { THREE, MapControls } from './world-vendor.js';
18
- import { cityMeasures, districtLots, groupJudgmentPlaces, UNPLACED_REASON_TEXT } from './world-placement.js';
18
+ import { changesSince, cityMeasures, districtLots, groupJudgmentPlaces, skyAt, UNPLACED_REASON_TEXT } from './world-placement.js';
19
19
  import {
20
20
  makeWorkspaceElement as el,
21
21
  workspacePageHeader,
@@ -182,18 +182,154 @@ function canvasTexture(doc, width, height, draw) {
182
182
  return texture;
183
183
  }
184
184
 
185
- function windowTexture(doc, lit, glowOnly = false) {
186
- const texture = canvasTexture(doc, 64, 64, (ctx, w, h) => {
187
- ctx.fillStyle = glowOnly ? '#000000' : '#ffffff';
188
- ctx.fillRect(0, 0, w, h);
189
- ctx.fillStyle = glowOnly ? '#ffffff' : lit ? '#ffe2a0' : '#c6d3de';
190
- for (let y = 8; y < h; y += 16) {
191
- for (let x = 6; x < w; x += 14) ctx.fillRect(x, y, 8, 8);
185
+ /** Smooth value noise in [0, 1]; used only to shade scenery (ground, sea, mountains) without repeating textures. */
186
+ function valueNoise(x, z, seed = 'n') {
187
+ const xi = Math.floor(x);
188
+ const zi = Math.floor(z);
189
+ const fx = x - xi;
190
+ const fz = z - zi;
191
+ const sx = fx * fx * (3 - 2 * fx);
192
+ const sz = fz * fz * (3 - 2 * fz);
193
+ const at = (i, j) => hashUnit(`${seed}${i},${j}`);
194
+ const near = at(xi, zi) + (at(xi + 1, zi) - at(xi, zi)) * sx;
195
+ const far = at(xi, zi + 1) + (at(xi + 1, zi + 1) - at(xi, zi + 1)) * sx;
196
+ return near + (far - near) * sz;
197
+ }
198
+
199
+ /** Colours a geometry per vertex from its position (x, y, z before any rotation). */
200
+ function paintVertices(geometry, colorAt) {
201
+ const position = geometry.attributes.position;
202
+ const colors = new Float32Array(position.count * 3);
203
+ const color = new THREE.Color();
204
+ for (let i = 0; i < position.count; i += 1) {
205
+ colorAt(color, position.getX(i), position.getY(i), position.getZ(i));
206
+ colors[i * 3] = color.r;
207
+ colors[i * 3 + 1] = color.g;
208
+ colors[i * 3 + 2] = color.b;
209
+ }
210
+ geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
211
+ return geometry;
212
+ }
213
+
214
+ /*
215
+ * Facades: one tile of a wall, drawn for a style and a wall colour. Windows are on the walls only (roofs
216
+ * get a plain material). Which panes are lit is fixed per pane, so the glow map matches the colour map;
217
+ * whether a building has lit windows at all still means "moved in the last 30 days".
218
+ */
219
+ const FACADE_UNITS = Object.freeze({ glass: [2.0, 2.3], office: [3.0, 2.0], brick: [1.3, 1.35], civic: [0.85, 1.5], research: [0.9, 1.2], house: [0, 0] });
220
+
221
+ /** Tile size in pixels: glass and office tiles hold several floors so lit panes do not repeat as a pattern. */
222
+ const FACADE_SIZES = Object.freeze({ glass: 256, office: 256 });
223
+
224
+ function drawFacade(ctx, w, h, style, wall, lit, glowOnly) {
225
+ const wallColor = `#${new THREE.Color(wall).getHexString()}`;
226
+ const dark = `#${new THREE.Color(wall).multiplyScalar(0.72).getHexString()}`;
227
+ const light = `#${new THREE.Color(wall).lerp(new THREE.Color(0xffffff), 0.35).getHexString()}`;
228
+ ctx.fillStyle = glowOnly ? '#000000' : wallColor;
229
+ ctx.fillRect(0, 0, w, h);
230
+ const pane = (x, y, pw, ph, index) => {
231
+ const on = lit && hashUnit(`${style}pane${index}x`) < 0.55;
232
+ if (glowOnly) {
233
+ ctx.fillStyle = on ? '#ffffff' : '#000000';
234
+ ctx.fillRect(x, y, pw, ph);
235
+ return;
192
236
  }
193
- });
194
- texture.wrapS = THREE.RepeatWrapping;
195
- texture.wrapT = THREE.RepeatWrapping;
196
- return texture;
237
+ if (on) {
238
+ ctx.fillStyle = '#ffd98c';
239
+ ctx.fillRect(x, y, pw, ph);
240
+ ctx.fillStyle = 'rgba(255,244,214,0.55)';
241
+ ctx.fillRect(x, y, pw, ph * 0.3);
242
+ } else {
243
+ const glass = ctx.createLinearGradient(x, y, x + pw, y + ph);
244
+ glass.addColorStop(0, '#9fb3c4');
245
+ glass.addColorStop(0.5, '#56687a');
246
+ glass.addColorStop(1, '#3f4f60');
247
+ ctx.fillStyle = glass;
248
+ ctx.fillRect(x, y, pw, ph);
249
+ }
250
+ };
251
+ if (style === 'glass') {
252
+ // A curtain wall: four floors of four panes, thin mullions, a floor band under each floor.
253
+ const cw = w / 4;
254
+ const rh = h / 4;
255
+ for (let r = 0; r < 4; r += 1) {
256
+ if (!glowOnly) {
257
+ ctx.fillStyle = dark;
258
+ ctx.fillRect(0, r * rh + rh - 7, w, 7);
259
+ }
260
+ for (let c = 0; c < 4; c += 1) pane(c * cw + 4, r * rh + 5, cw - 8, rh - 15, r * 4 + c);
261
+ }
262
+ if (!glowOnly) {
263
+ ctx.fillStyle = light;
264
+ for (let c = 0; c <= 4; c += 1) ctx.fillRect(c * cw - 2, 0, 4, h);
265
+ }
266
+ } else if (style === 'office') {
267
+ // Ribbon windows: two floors, each a long band split by mullions over a concrete spandrel.
268
+ const rh = h / 2;
269
+ for (let r = 0; r < 2; r += 1) {
270
+ if (!glowOnly) {
271
+ ctx.fillStyle = light;
272
+ ctx.fillRect(0, r * rh, w, 22);
273
+ ctx.fillStyle = dark;
274
+ ctx.fillRect(0, r * rh + rh - 10, w, 6);
275
+ }
276
+ for (let c = 0; c < 8; c += 1) pane(4 + c * (w / 8), r * rh + 34, w / 8 - 6, rh - 54, r * 8 + c);
277
+ }
278
+ } else if (style === 'brick') {
279
+ if (!glowOnly) {
280
+ ctx.strokeStyle = 'rgba(0,0,0,0.07)';
281
+ ctx.lineWidth = 2;
282
+ for (let y = 16; y < h; y += 16) {
283
+ ctx.beginPath();
284
+ ctx.moveTo(0, y);
285
+ ctx.lineTo(w, y);
286
+ ctx.stroke();
287
+ }
288
+ }
289
+ for (let c = 0; c < 2; c += 1) {
290
+ const x = 18 + c * 58;
291
+ if (!glowOnly) {
292
+ ctx.fillStyle = '#f5f1e8';
293
+ ctx.fillRect(x - 5, 22, 44, 82);
294
+ }
295
+ pane(x, 26, 34, 74, c);
296
+ if (!glowOnly) {
297
+ ctx.fillStyle = '#f5f1e8';
298
+ ctx.fillRect(x + 15, 26, 4, 74);
299
+ ctx.fillRect(x, 58, 34, 4);
300
+ }
301
+ }
302
+ } else if (style === 'civic') {
303
+ // Pilasters and tall windows.
304
+ if (!glowOnly) {
305
+ ctx.fillStyle = light;
306
+ ctx.fillRect(0, 0, 18, h);
307
+ ctx.fillRect(w - 18, 0, 18, h);
308
+ }
309
+ pane(30, 14, 68, 96, 0);
310
+ if (!glowOnly) {
311
+ ctx.fillStyle = light;
312
+ ctx.fillRect(62, 14, 4, 96);
313
+ }
314
+ } else if (style === 'research') {
315
+ for (let c = 0; c < 2; c += 1) pane(20 + c * 60, 18, 28, 90, c);
316
+ } else if (style === 'house') {
317
+ // A plastered wall with two framed windows (doors are separate meshes).
318
+ for (let c = 0; c < 2; c += 1) {
319
+ const x = 22 + c * 52;
320
+ if (!glowOnly) {
321
+ ctx.fillStyle = '#ffffff';
322
+ ctx.fillRect(x - 4, 36, 40, 50);
323
+ }
324
+ pane(x, 40, 32, 42, c);
325
+ if (!glowOnly) {
326
+ ctx.fillStyle = '#ffffff';
327
+ ctx.fillRect(x + 14, 40, 4, 42);
328
+ ctx.fillStyle = dark;
329
+ ctx.fillRect(x - 6, 86, 44, 5);
330
+ }
331
+ }
332
+ }
197
333
  }
198
334
 
199
335
  function roundedPlate(width, depth, height, radius) {
@@ -242,15 +378,29 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
242
378
  stage.prepend(renderer.domElement);
243
379
 
244
380
  const scene = new THREE.Scene();
245
- scene.background = canvasTexture(doc, 4, 256, (ctx, w, h) => {
246
- const gradient = ctx.createLinearGradient(0, 0, 0, h);
247
- gradient.addColorStop(0, '#dfeee6');
248
- gradient.addColorStop(0.55, '#eef4ef');
249
- gradient.addColorStop(1, '#f6f1e6');
250
- ctx.fillStyle = gradient;
251
- ctx.fillRect(0, 0, w, h);
252
- });
253
- scene.fog = new THREE.Fog(0xeef3ec, 170, 340);
381
+ // Scenery (sky, sea, land, mountains, trees, lamps) represents no data: it only sets the place.
382
+ let skyMode = 'auto';
383
+ let sky = skyAt(new Date().getHours(), skyMode);
384
+ function skyTexture(current) {
385
+ return canvasTexture(doc, 256, 256, (ctx, w, h) => {
386
+ const gradient = ctx.createLinearGradient(0, 0, 0, h);
387
+ gradient.addColorStop(0, current.top);
388
+ gradient.addColorStop(0.55, current.middle);
389
+ gradient.addColorStop(1, current.bottom);
390
+ ctx.fillStyle = gradient;
391
+ ctx.fillRect(0, 0, w, h);
392
+ if (current.stars) {
393
+ ctx.fillStyle = 'rgba(255,255,255,0.85)';
394
+ for (let i = 0; i < 90; i += 1) {
395
+ const sx = hashUnit(`star${i}x`) * w;
396
+ const sy = hashUnit(`star${i}y`) * h * 0.55;
397
+ ctx.fillRect(sx, sy, hashUnit(`star${i}s`) > 0.85 ? 1.6 : 0.9, hashUnit(`star${i}s`) > 0.85 ? 1.6 : 0.9);
398
+ }
399
+ }
400
+ });
401
+ }
402
+ scene.background = skyTexture(sky);
403
+ scene.fog = new THREE.Fog(sky.fog, 170, 340);
254
404
 
255
405
  const camera = new THREE.OrthographicCamera(-50, 50, 50, -50, 0.1, 1000);
256
406
  const home = { position: new THREE.Vector3(90, 95, 90), target: new THREE.Vector3(0, 0, 0), zoom: 1 };
@@ -267,26 +417,27 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
267
417
  controls.minPolarAngle = Math.PI / 6;
268
418
  controls.target.copy(home.target);
269
419
 
270
- scene.add(new THREE.HemisphereLight(0xf4f8ff, 0xd8ccb2, 1.25));
271
- const sun = new THREE.DirectionalLight(0xfff1dc, 2.7);
420
+ const hemisphere = new THREE.HemisphereLight(0xf4f8ff, 0xd8ccb2, sky.hemi);
421
+ scene.add(hemisphere);
422
+ const sun = new THREE.DirectionalLight(sky.sun, sky.sunIntensity);
272
423
  sun.position.set(70, 110, 40);
273
424
  sun.castShadow = true;
274
425
  sun.shadow.mapSize.set(2048, 2048);
275
426
  sun.shadow.radius = 4;
276
427
  sun.shadow.bias = -0.0004;
428
+ sun.shadow.normalBias = 0.03;
277
429
  Object.assign(sun.shadow.camera, { left: -150, right: 150, top: 150, bottom: -150, near: 1, far: 420 });
278
430
  scene.add(sun);
279
431
  const fill = new THREE.DirectionalLight(0xdfe9ff, 0.6);
280
432
  fill.position.set(-80, 60, -60);
281
433
  scene.add(fill);
282
434
 
283
- const ground = new THREE.Mesh(new THREE.CircleGeometry(230, 72), new THREE.MeshStandardMaterial({ color: 0xe4ebe1, roughness: 1 }));
284
- ground.rotation.x = -Math.PI / 2;
285
- ground.receiveShadow = true;
286
- ground.userData = { kind: 'ground' };
287
- scene.add(ground);
435
+ // Lit windows and street lamps follow the sky (brightest at night); their meaning does not change.
436
+ const litMaterials = new Set();
437
+ const lampMaterials = [];
438
+ const animated = { water: null, jets: [], sparkles: [], clouds: [], foam: null };
439
+ const maxAnisotropy = renderer.capabilities.getMaxAnisotropy?.() ?? 1;
288
440
 
289
- const textures = { lit: windowTexture(doc, true), unlit: windowTexture(doc, false), glow: windowTexture(doc, true, true) };
290
441
  const pickables = [];
291
442
  const labels = [];
292
443
  const beacons = [];
@@ -313,51 +464,381 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
313
464
  return new THREE.MeshStandardMaterial({ color, roughness: 0.8, metalness: 0.03, ...options });
314
465
  }
315
466
 
316
- function texturedBlock(w, h, d, color, lit, options = {}) {
317
- const map = (lit ? textures.lit : textures.unlit).clone();
467
+ const facadeCache = new Map();
468
+ function facadeTiles(style, wall, lit) {
469
+ const key = `${style}|${new THREE.Color(wall).getHexString()}|${lit}`;
470
+ if (!facadeCache.has(key)) {
471
+ const make = (glowOnly) => {
472
+ const size = FACADE_SIZES[style] ?? 128;
473
+ const texture = canvasTexture(doc, size, size, (ctx, w, h) => drawFacade(ctx, w, h, style, wall, lit, glowOnly));
474
+ texture.wrapS = THREE.RepeatWrapping;
475
+ texture.wrapT = THREE.RepeatWrapping;
476
+ texture.anisotropy = maxAnisotropy;
477
+ return texture;
478
+ };
479
+ facadeCache.set(key, { map: make(false), glow: lit ? make(true) : null });
480
+ }
481
+ return facadeCache.get(key);
482
+ }
483
+
484
+ /** A wall material for a face `span` wide and `h` tall: windows on the wall, glowing if the building is lit. */
485
+ function facadeMaterial(style, wall, lit, span, h, options = {}) {
486
+ const tiles = facadeTiles(style, wall, lit);
487
+ const [unitW, unitH] = FACADE_UNITS[style] ?? [1, 1];
488
+ const repeat = unitW === 0 ? [1, Math.max(1, Math.round(h / 1.1))] : [Math.max(1, Math.round(span / unitW)), Math.max(1, Math.round(h / unitH))];
489
+ const map = tiles.map.clone();
318
490
  map.needsUpdate = true;
319
- map.repeat.set(Math.max(1, Math.round(w / 1.4)), Math.max(1, Math.round(h / 1.2)));
320
- const material = standard(color, { map, ...options });
321
- if (lit) {
322
- const glow = textures.glow.clone();
491
+ map.repeat.set(...repeat);
492
+ const glass = style === 'glass';
493
+ const material = standard(0xffffff, { map, roughness: glass ? 0.32 : 0.85, metalness: glass ? 0.18 : 0.02, ...options });
494
+ if (tiles.glow) {
495
+ const glow = tiles.glow.clone();
323
496
  glow.needsUpdate = true;
324
497
  glow.repeat.copy(map.repeat);
325
498
  material.emissive = new THREE.Color(0xffc867);
326
499
  material.emissiveMap = glow;
327
- material.emissiveIntensity = 0.6;
500
+ material.emissiveIntensity = sky.glow;
501
+ litMaterials.add(material);
328
502
  }
329
- return mesh(new THREE.BoxGeometry(w, h, d), material, { y: h / 2 });
503
+ return material;
504
+ }
505
+
506
+ /** A block with facades on its four walls and a plain roof; stands on y = 0 of its parent. */
507
+ function block(w, h, d, { style = 'office', wall = 0xe9e4da, lit = false, roof = 0xc9c5bb, ...options } = {}) {
508
+ const alongX = facadeMaterial(style, wall, lit, d, h, options);
509
+ const alongZ = facadeMaterial(style, wall, lit, w, h, options);
510
+ const top = standard(roof, { roughness: 0.95, ...options });
511
+ return mesh(new THREE.BoxGeometry(w, h, d), [alongX, alongX, top, top, alongZ, alongZ], { y: h / 2 });
330
512
  }
331
513
 
332
514
  function register(group, data) {
333
515
  group.userData = data;
334
516
  pickables.push(group);
335
- const materials = [];
517
+ const materials = new Set();
336
518
  group.traverse((child) => {
337
- if (child.isMesh && child.material?.emissive) materials.push(child.material);
519
+ if (!child.isMesh) return;
520
+ for (const material of Array.isArray(child.material) ? child.material : [child.material]) {
521
+ if (material?.emissive) materials.add(material);
522
+ }
338
523
  });
339
- highlightable.set(group, materials.map((material) => ({ material, base: material.emissive.clone(), intensity: material.emissiveIntensity })));
524
+ highlightable.set(group, [...materials].map((material) => ({ material, base: material.emissive.clone(), intensity: material.emissiveIntensity })));
340
525
  }
341
526
 
527
+ const roadMaterials = {
528
+ asphalt: standard(0x8e938f, { roughness: 0.95 }),
529
+ walk: standard(0xe4dfd2, { roughness: 1 }),
530
+ };
531
+ const dashTexture = canvasTexture(doc, 8, 64, (ctx, w, h) => {
532
+ ctx.clearRect(0, 0, w, h);
533
+ ctx.fillStyle = '#ffffff';
534
+ ctx.fillRect(1, 0, w - 2, h * 0.55);
535
+ });
536
+ dashTexture.wrapT = THREE.RepeatWrapping;
537
+ dashTexture.anisotropy = maxAnisotropy;
342
538
  function road(from, to, width = 1.9) {
343
539
  const length = from.distanceTo(to);
344
540
  const angle = -Math.atan2(to.z - from.z, to.x - from.x) + Math.PI / 2;
345
- const surface = mesh(new THREE.PlaneGeometry(width, length), standard(0xd3d9d1, { roughness: 1 }), { x: (from.x + to.x) / 2, y: 0.03, z: (from.z + to.z) / 2, shadow: false });
346
- surface.rotation.set(-Math.PI / 2, 0, angle);
347
- scene.add(surface);
348
- const line = mesh(new THREE.PlaneGeometry(0.12, length), new THREE.MeshBasicMaterial({ color: 0xf8faf7 }), { x: (from.x + to.x) / 2, y: 0.05, z: (from.z + to.z) / 2, shadow: false });
349
- line.rotation.set(-Math.PI / 2, 0, angle);
350
- scene.add(line);
541
+ const at = { x: (from.x + to.x) / 2, z: (from.z + to.z) / 2 };
542
+ const strip = (stripWidth, material, y, offset = 0) => {
543
+ const surface = mesh(new THREE.PlaneGeometry(stripWidth, length), material, { x: at.x, y, z: at.z, shadow: false });
544
+ surface.rotation.set(-Math.PI / 2, 0, angle);
545
+ if (offset !== 0) surface.translateX(offset);
546
+ scene.add(surface);
547
+ };
548
+ // Sidewalks on both sides, asphalt between, a dashed centre line.
549
+ strip(width + 0.7, roadMaterials.walk, 0.025);
550
+ strip(width, roadMaterials.asphalt, 0.04);
551
+ const dashes = dashTexture.clone();
552
+ dashes.needsUpdate = true;
553
+ dashes.repeat.set(1, Math.max(1, Math.round(length / 1.6)));
554
+ strip(0.1, new THREE.MeshBasicMaterial({ map: dashes, transparent: true, opacity: 0.85, color: 0xf6f3e6 }), 0.055);
351
555
  }
352
556
 
353
- function tree(x, z, scale = 1) {
354
- const group = new THREE.Group();
355
- group.add(mesh(new THREE.CylinderGeometry(0.12 * scale, 0.16 * scale, 0.7 * scale, 6), standard(0x8a6d4e), { y: 0.35 * scale }));
356
- group.add(mesh(new THREE.ConeGeometry(0.7 * scale, 1.6 * scale, 7), standard(0x6f9a72, { roughness: 0.95 }), { y: 1.4 * scale }));
357
- group.position.set(x, 0, z);
358
- scene.add(group);
557
+ // Trees are many; they are drawn as instanced meshes (trunks, conifers, broadleaves) once the world is built.
558
+ const treePlacements = [];
559
+ function tree(x, z, scale = 1, y = 0) {
560
+ treePlacements.push([x, z, scale, y]);
561
+ }
562
+ function plantTrees() {
563
+ if (treePlacements.length === 0) return;
564
+ const conifers = [];
565
+ const broadleaves = [];
566
+ for (const placement of treePlacements) (hashUnit(`${placement[0]},${placement[1]}k`) < 0.42 ? conifers : broadleaves).push(placement);
567
+ const foliage = (color) => standard(color, { roughness: 0.9, flatShading: true });
568
+ const trunks = new THREE.InstancedMesh(new THREE.CylinderGeometry(0.1, 0.15, 0.8, 6), standard(0x7d6249, { roughness: 1 }), treePlacements.length);
569
+ const lower = new THREE.InstancedMesh(new THREE.ConeGeometry(0.78, 1.3, 7), foliage(0xffffff), Math.max(conifers.length, 1));
570
+ const upper = new THREE.InstancedMesh(new THREE.ConeGeometry(0.55, 1.05, 7), foliage(0xffffff), Math.max(conifers.length, 1));
571
+ const crowns = new THREE.InstancedMesh(new THREE.IcosahedronGeometry(0.72, 0), foliage(0xffffff), Math.max(broadleaves.length, 1));
572
+ const matrix = new THREE.Matrix4();
573
+ const rotation = new THREE.Quaternion();
574
+ const tint = new THREE.Color();
575
+ const up = new THREE.Vector3(0, 1, 0);
576
+ const place = (instanced, index, x, y, z, sx, sy, sz, spin) => {
577
+ rotation.setFromAxisAngle(up, spin);
578
+ matrix.compose(new THREE.Vector3(x, y, z), rotation, new THREE.Vector3(sx, sy, sz));
579
+ instanced.setMatrixAt(index, matrix);
580
+ };
581
+ treePlacements.forEach(([x, z, scale, y], index) => place(trunks, index, x, y + 0.4 * scale, z, scale, scale, scale, 0));
582
+ conifers.forEach(([x, z, scale, y], index) => {
583
+ const spin = hashUnit(`${x}${z}s`) * Math.PI;
584
+ const tall = 0.9 + hashUnit(`${z}${x}t`) * 0.35;
585
+ place(lower, index, x, y + 1.15 * scale * tall, z, scale, scale * tall, scale, spin);
586
+ place(upper, index, x, y + 1.85 * scale * tall, z, scale, scale * tall, scale, spin);
587
+ const shade = tint.setHSL(0.37 + (hashUnit(`${x},${z}`) - 0.5) * 0.04, 0.34, 0.3 + hashUnit(`${z},${x}`) * 0.08);
588
+ lower.setColorAt(index, shade);
589
+ upper.setColorAt(index, shade.offsetHSL(0, 0, 0.04));
590
+ });
591
+ broadleaves.forEach(([x, z, scale, y], index) => {
592
+ const squash = 0.85 + hashUnit(`${x}${z}q`) * 0.25;
593
+ place(crowns, index, x, y + 1.25 * scale, z, scale * squash, scale, scale * (1.9 - squash), hashUnit(`${x}${z}s`) * Math.PI);
594
+ crowns.setColorAt(index, tint.setHSL(0.24 + hashUnit(`${x},${z}h`) * 0.08, 0.38, 0.4 + hashUnit(`${z},${x}l`) * 0.12));
595
+ });
596
+ lower.count = conifers.length;
597
+ upper.count = conifers.length;
598
+ crowns.count = broadleaves.length;
599
+ for (const instanced of [trunks, lower, upper, crowns]) {
600
+ instanced.castShadow = true;
601
+ instanced.receiveShadow = true;
602
+ scene.add(instanced);
603
+ }
604
+ treePlacements.length = 0;
359
605
  }
360
606
 
607
+ // --- scenery (no data) -----------------------------------------------------
608
+ function pavingTexture() {
609
+ const texture = canvasTexture(doc, 256, 256, (ctx, w, h) => {
610
+ ctx.fillStyle = '#f1ece1';
611
+ ctx.fillRect(0, 0, w, h);
612
+ ctx.strokeStyle = 'rgba(176,164,140,0.5)';
613
+ ctx.lineWidth = 1.5;
614
+ for (let r = 16; r < w / 2; r += 18) {
615
+ ctx.beginPath();
616
+ ctx.arc(w / 2, h / 2, r, 0, Math.PI * 2);
617
+ ctx.stroke();
618
+ }
619
+ for (let i = 0; i < 24; i += 1) {
620
+ const a = (i / 24) * Math.PI * 2;
621
+ ctx.beginPath();
622
+ ctx.moveTo(w / 2 + Math.cos(a) * 16, h / 2 + Math.sin(a) * 16);
623
+ ctx.lineTo(w / 2 + Math.cos(a) * w / 2, h / 2 + Math.sin(a) * h / 2);
624
+ ctx.stroke();
625
+ }
626
+ });
627
+ texture.anisotropy = maxAnisotropy;
628
+ return texture;
629
+ }
630
+
631
+ const grass = { low: new THREE.Color(0x9fbf8c), high: new THREE.Color(0xd3e4bd), warm: new THREE.Color(0xd9d6a6), sand: new THREE.Color(0xeadfc2) };
632
+ /** Land, beach, sea and a far shore of mountains around the cities; scenery only. */
633
+ function buildTerrain(extent) {
634
+ const landRadius = extent + 22;
635
+ // Land: gentle, non-repeating colour variation (a repeating texture shimmered into stripes).
636
+ const land = mesh(paintVertices(new THREE.RingGeometry(0.01, landRadius, 160, 48), (color, x, y) => {
637
+ const broad = valueNoise(x / 16, y / 16, 'land');
638
+ const fine = valueNoise(x / 4.5, y / 4.5, 'tuft');
639
+ color.copy(grass.low).lerp(grass.high, broad * 0.7 + fine * 0.3);
640
+ if (valueNoise(x / 22, y / 22, 'dry') > 0.68) color.lerp(grass.warm, 0.35);
641
+ const shore = Math.max(0, (Math.hypot(x, y) - (landRadius - 3)) / 3);
642
+ color.lerp(grass.sand, shore * 0.8);
643
+ }), standard(0xffffff, { vertexColors: true, roughness: 1 }), { shadow: false });
644
+ land.rotation.x = -Math.PI / 2;
645
+ land.receiveShadow = true;
646
+ scene.add(land);
647
+ // Beach sloping into the water.
648
+ const beachGeometry = new THREE.RingGeometry(landRadius, landRadius + 5, 160, 3);
649
+ const beachPositions = beachGeometry.attributes.position;
650
+ for (let i = 0; i < beachPositions.count; i += 1) {
651
+ const t = (Math.hypot(beachPositions.getX(i), beachPositions.getY(i)) - landRadius) / 5;
652
+ beachPositions.setZ(i, -t * 0.42);
653
+ }
654
+ beachGeometry.computeVertexNormals();
655
+ const beach = mesh(paintVertices(beachGeometry, (color, x, y) => color.set(0xeadfc2).lerp(new THREE.Color(0xd7c8a3), (Math.hypot(x, y) - landRadius) / 5)), standard(0xffffff, { vertexColors: true, roughness: 1 }), { shadow: false });
656
+ beach.rotation.x = -Math.PI / 2;
657
+ scene.add(beach);
658
+ // Sea: shallow turquoise near the shore, deeper blue further out; a soft line of foam on the beach.
659
+ const sea = mesh(paintVertices(new THREE.RingGeometry(landRadius + 2.5, 760, 160, 24), (color, x, y) => {
660
+ const t = Math.min(1, Math.max(0, (Math.hypot(x, y) - landRadius - 2.5) / 140));
661
+ color.set(0x9ad3d3).lerp(new THREE.Color(0x3f7fa6), Math.sqrt(t));
662
+ color.lerp(new THREE.Color(0xb7dfe3), (valueNoise(x / 9, y / 9, 'swell') - 0.5) * 0.18);
663
+ }), standard(0xffffff, { vertexColors: true, roughness: 0.3, metalness: 0.08 }), { y: -0.36, shadow: false });
664
+ sea.rotation.x = -Math.PI / 2;
665
+ sea.receiveShadow = false;
666
+ scene.add(sea);
667
+ const foam = mesh(new THREE.RingGeometry(landRadius + 4.0, landRadius + 4.7, 160, 1), new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.5, depthWrite: false }), { y: -0.35, shadow: false });
668
+ foam.rotation.x = -Math.PI / 2;
669
+ scene.add(foam);
670
+ animated.foam = foam;
671
+ // A far shore: low, faceted mountains across the water, faded by the fog.
672
+ const rock = new THREE.Color(0xa7a497);
673
+ const snow = new THREE.Color(0xf3f5f5);
674
+ for (let i = 0; i < 22; i += 1) {
675
+ const angle = Math.PI * 0.85 + (i / 21) * Math.PI * 0.95 + (hashUnit(`m${i}a`) - 0.5) * 0.08;
676
+ const distance = landRadius + 115 + hashUnit(`m${i}d`) * 55;
677
+ const peak = 10 + hashUnit(`m${i}h`) * 18;
678
+ const radius = 11 + hashUnit(`m${i}r`) * 10;
679
+ const geometry = new THREE.IcosahedronGeometry(1, 2);
680
+ const positions = geometry.attributes.position;
681
+ for (let v = 0; v < positions.count; v += 1) {
682
+ const px = positions.getX(v);
683
+ const py = positions.getY(v);
684
+ const pz = positions.getZ(v);
685
+ const bump = 0.82 + valueNoise(px * 2.2 + i, pz * 2.2, 'ridge') * 0.36;
686
+ positions.setXYZ(v, px * radius * bump, Math.max(0, py) * peak * bump, pz * radius * bump);
687
+ }
688
+ geometry.computeVertexNormals();
689
+ paintVertices(geometry, (color, _x, y) => {
690
+ const t = y / peak;
691
+ color.set(0x86a383).lerp(rock, Math.min(1, t * 1.6));
692
+ if (peak > 20 && t > 0.72) color.lerp(snow, Math.min(1, (t - 0.72) * 5));
693
+ });
694
+ const mountain = mesh(geometry, new THREE.MeshLambertMaterial({ vertexColors: true, flatShading: true }), { x: Math.cos(angle) * distance, y: -0.5, z: Math.sin(angle) * distance, shadow: false });
695
+ mountain.rotation.y = hashUnit(`m${i}y`) * Math.PI;
696
+ mountain.receiveShadow = false;
697
+ scene.add(mountain);
698
+ }
699
+ // Clouds drifting over the far water, behind the island, so they never cover a city.
700
+ const cloudMaterial = new THREE.MeshLambertMaterial({ color: 0xffffff, transparent: true, opacity: 0.9, flatShading: true });
701
+ animated.cloudMaterial = cloudMaterial;
702
+ for (let i = 0; i < 7; i += 1) {
703
+ const cloud = new THREE.Group();
704
+ const puffs = 3 + Math.floor(hashUnit(`c${i}n`) * 3);
705
+ for (let p = 0; p < puffs; p += 1) {
706
+ const r = 2.6 + hashUnit(`c${i}p${p}`) * 2.4;
707
+ const puff = mesh(new THREE.IcosahedronGeometry(r, 1), cloudMaterial, { x: (p - puffs / 2) * 2.8, y: hashUnit(`c${i}y${p}`) * 1.2, z: (hashUnit(`c${i}z${p}`) - 0.5) * 2.5, shadow: false });
708
+ puff.scale.y = 0.62;
709
+ puff.receiveShadow = false;
710
+ cloud.add(puff);
711
+ }
712
+ cloud.userData = { angle: Math.PI * (0.95 + (i / 6) * 0.6) + (hashUnit(`c${i}a`) - 0.5) * 0.1, distance: landRadius + 34 + hashUnit(`c${i}d`) * 60, height: 24 + hashUnit(`c${i}h`) * 10, speed: 0.00002 + hashUnit(`c${i}s`) * 0.00002, phase: hashUnit(`c${i}f`) * Math.PI * 2 };
713
+ scene.add(cloud);
714
+ animated.clouds.push(cloud);
715
+ }
716
+ placeClouds(0);
717
+ // Keep the sun's shadow map on the land so shadows stay crisp.
718
+ const reach = landRadius + 6;
719
+ Object.assign(sun.shadow.camera, { left: -reach, right: reach, top: reach, bottom: -reach });
720
+ sun.shadow.camera.updateProjectionMatrix();
721
+ return landRadius;
722
+ }
723
+
724
+ function placeClouds(time) {
725
+ for (const cloud of animated.clouds) {
726
+ const { angle, distance, height, speed, phase } = cloud.userData;
727
+ const a = angle + Math.sin(time * speed + phase) * 0.08;
728
+ cloud.position.set(Math.cos(a) * distance, height, Math.sin(a) * distance);
729
+ cloud.rotation.y = -a;
730
+ }
731
+ }
732
+
733
+ const lampPlacements = [];
734
+ function streetLamp(x, z, y = 0) {
735
+ lampPlacements.push([x, z, y]);
736
+ }
737
+ function placeLamps() {
738
+ if (lampPlacements.length === 0) return;
739
+ const poles = new THREE.InstancedMesh(new THREE.CylinderGeometry(0.05, 0.07, 1.6, 6), standard(0x3f4844, { roughness: 0.6, metalness: 0.4 }), lampPlacements.length);
740
+ const arms = new THREE.InstancedMesh(new THREE.BoxGeometry(0.42, 0.05, 0.06), standard(0x3f4844, { roughness: 0.6, metalness: 0.4 }), lampPlacements.length);
741
+ const bulbMaterial = standard(0xfff3d6, { emissive: new THREE.Color(0xffd27a), emissiveIntensity: sky.lamps ? 1.8 : 0 });
742
+ lampMaterials.push(bulbMaterial);
743
+ const bulbs = new THREE.InstancedMesh(new THREE.SphereGeometry(0.14, 10, 8), bulbMaterial, lampPlacements.length);
744
+ // A warm pool of light on the ground under each lamp, shown only when the lamps are on.
745
+ const falloff = canvasTexture(doc, 64, 64, (ctx, w, h) => {
746
+ const gradient = ctx.createRadialGradient(w / 2, h / 2, 0, w / 2, h / 2, w / 2);
747
+ gradient.addColorStop(0, '#ffffff');
748
+ gradient.addColorStop(0.45, '#6b6b6b');
749
+ gradient.addColorStop(1, '#000000');
750
+ ctx.fillStyle = gradient;
751
+ ctx.fillRect(0, 0, w, h);
752
+ });
753
+ const poolMaterial = new THREE.MeshBasicMaterial({ color: 0xffc46a, map: falloff, transparent: true, opacity: 0, blending: THREE.AdditiveBlending, depthWrite: false });
754
+ animated.poolMaterial = poolMaterial;
755
+ const pools = new THREE.InstancedMesh(new THREE.PlaneGeometry(3.4, 3.4).rotateX(-Math.PI / 2), poolMaterial, lampPlacements.length);
756
+ const matrix = new THREE.Matrix4();
757
+ lampPlacements.forEach(([x, z, y], index) => {
758
+ poles.setMatrixAt(index, matrix.makeTranslation(x, y + 0.8, z));
759
+ arms.setMatrixAt(index, matrix.makeTranslation(x + 0.18, y + 1.58, z));
760
+ bulbs.setMatrixAt(index, matrix.makeTranslation(x + 0.36, y + 1.5, z));
761
+ pools.setMatrixAt(index, matrix.makeTranslation(x + 0.36, y + 0.07, z));
762
+ });
763
+ poles.castShadow = true;
764
+ pools.renderOrder = 1;
765
+ scene.add(poles, arms, bulbs, pools);
766
+ applyLampPools();
767
+ lampPlacements.length = 0;
768
+ }
769
+ function applyLampPools() {
770
+ if (animated.poolMaterial) animated.poolMaterial.opacity = sky.lamps ? Math.min(0.55, 0.15 + sky.glow * 0.16) : 0;
771
+ }
772
+
773
+ /** Trees and lamps along a road, kept off the plaza and the city plates. */
774
+ function avenue(from, to, keepOut) {
775
+ const length = from.distanceTo(to);
776
+ const dir = to.clone().sub(from).normalize();
777
+ const side = new THREE.Vector3(-dir.z, 0, dir.x);
778
+ for (let d = 3; d < length - 2; d += 4.2) {
779
+ const point = from.clone().addScaledVector(dir, d);
780
+ if (keepOut(point)) continue;
781
+ const index = Math.round(d / 4.2);
782
+ for (const sign of [-1, 1]) {
783
+ const at = point.clone().addScaledVector(side, sign * 1.9);
784
+ if (index % 3 === 0 && sign === 1) streetLamp(at.x, at.z);
785
+ else tree(at.x, at.z, 0.55 + hashUnit(`${from.x}${d}${sign}`) * 0.2);
786
+ }
787
+ }
788
+ }
789
+
790
+ /** Groves and fields between the cities; scenery only (no houses, which could read as engagements). */
791
+ function countryside(landRadius, cities, roads) {
792
+ const blocked = (x, z, margin) => {
793
+ if (Math.hypot(x, z) < PLAZA_RADIUS + 8) return true;
794
+ if (Math.hypot(x, z) > landRadius - 4) return true;
795
+ if (cities.some((city) => Math.hypot(x - city.x, z - city.z) < city.size * 0.8 + margin)) return true;
796
+ return roads.some(([a, b]) => {
797
+ const ab = b.clone().sub(a);
798
+ const t = Math.max(0, Math.min(1, new THREE.Vector3(x, 0, z).sub(a).dot(ab) / ab.lengthSq()));
799
+ return a.clone().addScaledVector(ab, t).distanceTo(new THREE.Vector3(x, 0, z)) < margin;
800
+ });
801
+ };
802
+ let groves = 0;
803
+ for (let i = 0; i < 90 && groves < 26; i += 1) {
804
+ const angle = hashUnit(`grove${i}a`) * Math.PI * 2;
805
+ const radius = PLAZA_RADIUS + 10 + hashUnit(`grove${i}r`) * (landRadius - PLAZA_RADIUS - 14);
806
+ const x = Math.cos(angle) * radius;
807
+ const z = Math.sin(angle) * radius;
808
+ if (blocked(x, z, 4)) continue;
809
+ groves += 1;
810
+ if (hashUnit(`grove${i}k`) > 0.7) {
811
+ // A field: a flat patch in a crop colour.
812
+ const field = mesh(new THREE.PlaneGeometry(6 + hashUnit(`f${i}w`) * 5, 4 + hashUnit(`f${i}h`) * 4), new THREE.MeshLambertMaterial({ color: hashUnit(`f${i}c`) > 0.5 ? 0xe7dca0 : 0xbfd69b }), { x, y: 0.02, z, shadow: false });
813
+ field.rotation.set(-Math.PI / 2, 0, hashUnit(`f${i}r`) * Math.PI);
814
+ scene.add(field);
815
+ continue;
816
+ }
817
+ const count = 4 + Math.floor(hashUnit(`grove${i}n`) * 6);
818
+ for (let t = 0; t < count; t += 1) {
819
+ const a = hashUnit(`grove${i}t${t}a`) * Math.PI * 2;
820
+ const r = hashUnit(`grove${i}t${t}r`) * 3.2;
821
+ tree(x + Math.cos(a) * r, z + Math.sin(a) * r, 0.6 + hashUnit(`grove${i}t${t}s`) * 0.5);
822
+ }
823
+ }
824
+ }
825
+
826
+ function applySky() {
827
+ sky = skyAt(new Date().getHours(), skyMode);
828
+ scene.background?.dispose?.();
829
+ scene.background = skyTexture(sky);
830
+ scene.fog.color.setHex(sky.fog);
831
+ sun.color.setHex(sky.sun);
832
+ sun.intensity = sky.sunIntensity;
833
+ hemisphere.intensity = sky.hemi;
834
+ renderer.toneMappingExposure = sky.exposure;
835
+ for (const material of litMaterials) if (!highlightedMaterials.has(material)) material.emissiveIntensity = sky.glow;
836
+ for (const material of lampMaterials) material.emissiveIntensity = sky.lamps ? 1.8 : 0;
837
+ applyLampPools();
838
+ animated.cloudMaterial?.color.set(sky.phase === 'night' ? 0x5a6782 : sky.phase === 'dusk' ? 0xf4d6c6 : 0xffffff);
839
+ }
840
+ const highlightedMaterials = new Set();
841
+
361
842
  // --- plaza ---------------------------------------------------------------
362
843
  function buildPlaza(rows, rowItems, waitingOf, now) {
363
844
  const plaza = new THREE.Group();
@@ -365,6 +846,20 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
365
846
  plaza.add(mesh(new THREE.CylinderGeometry(PLAZA_RADIUS, PLAZA_RADIUS + 0.4, 0.7, 64), standard(0xf3efe6, { roughness: 0.9 }), { y: 0.35 }));
366
847
  plaza.add(mesh(new THREE.TorusGeometry(PLAZA_RADIUS - 0.3, 0.08, 6, 64), standard(0xd8cfbd), { y: 0.72 }));
367
848
  plaza.children[2].rotation.x = Math.PI / 2;
849
+ const paving = mesh(new THREE.CircleGeometry(PLAZA_RADIUS - 0.05, 64), standard(0xffffff, { map: pavingTexture(), roughness: 0.95 }), { y: 0.705, shadow: false });
850
+ paving.rotation.x = -Math.PI / 2;
851
+ plaza.add(paving);
852
+ if (rows.length !== 1) {
853
+ // A fountain in the middle of the plaza (scenery; the kinds of judgment stand around it).
854
+ plaza.add(mesh(new THREE.CylinderGeometry(1.5, 1.7, 0.45, 32), standard(0xd9d1c1), { y: 0.92 }));
855
+ const pool = mesh(new THREE.CircleGeometry(1.3, 32), standard(0x9fd0de, { roughness: 0.2, metalness: 0.1 }), { y: 1.15, shadow: false });
856
+ pool.rotation.x = -Math.PI / 2;
857
+ plaza.add(pool);
858
+ plaza.add(mesh(new THREE.CylinderGeometry(0.18, 0.24, 0.9, 12), standard(0xd9d1c1), { y: 1.5 }));
859
+ const jet = mesh(new THREE.CylinderGeometry(0.05, 0.16, 1.2, 10), new THREE.MeshBasicMaterial({ color: 0xe8f6fb, transparent: true, opacity: 0.7 }), { y: 2.5, shadow: false });
860
+ plaza.add(jet);
861
+ animated.jets.push(jet);
862
+ }
368
863
  scene.add(plaza);
369
864
  register(plaza, { kind: 'plaza' });
370
865
  addLabel('広場(判断)', new THREE.Vector3(0, 0.8, PLAZA_RADIUS + 2.8), 'is-plaza', { priority: LABEL_PRIORITY.plaza });
@@ -383,16 +878,19 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
383
878
  const group = new THREE.Group();
384
879
  group.position.set(x, 0.7, z);
385
880
  const color = STATE_COLORS[row.state] ?? 0x929b95;
386
- group.add(mesh(new THREE.BoxGeometry(2.6, 0.25, 2.6), standard(0xe6ded0), { y: 0.12 }));
387
- const body = texturedBlock(2.1, height, 2.1, color, recent);
881
+ group.add(mesh(new THREE.BoxGeometry(2.7, 0.25, 2.7), standard(0xe6ded0), { y: 0.12 }));
882
+ const wall = new THREE.Color(color);
883
+ const body = block(2.1, height, 2.1, { style: 'civic', wall, lit: recent, roof: 0xd9d3c6 });
388
884
  body.position.y += 0.25;
389
885
  group.add(body);
390
- const cap = texturedBlock(1.4, 0.7, 1.4, color, recent);
391
- cap.position.y += 0.25 + height;
886
+ group.add(mesh(new THREE.BoxGeometry(2.3, 0.14, 2.3), standard(0xf3efe6), { y: 0.25 + height + 0.07 }));
887
+ const cap = block(1.4, 0.56, 1.4, { style: 'civic', wall, lit: recent, roof: 0xd9d3c6 });
888
+ cap.position.y += 0.25 + height + 0.14;
392
889
  group.add(cap);
393
890
  scene.add(group);
394
891
  register(group, { kind: 'judgment', row, items: rowItems.get(row.key) ?? [] });
395
892
  const top = 0.7 + 0.25 + height + 0.7;
893
+ markers.plaza.set(row.key, new THREE.Vector3(x, top, z));
396
894
  const waiting = waitingOf(row);
397
895
  if (waiting > 0) {
398
896
  const beam = mesh(new THREE.CylinderGeometry(0.22, 0.22, 16, 12), new THREE.MeshBasicMaterial({ color: 0xd92335, transparent: true, opacity: 0.55 }), { x, y: top + 8, z, shadow: false });
@@ -415,6 +913,15 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
415
913
  }
416
914
 
417
915
  // --- cities ----------------------------------------------------------------
916
+ // The form says the business's kind; the height and the lit windows say its last 30 days. Everything
917
+ // else on a building (podium, cornice, roof plant, portico) is detail and means nothing on its own.
918
+ function rooftopPlant(group, y, span, options) {
919
+ const metal = standard(0xb7bcbc, { roughness: 0.6, metalness: 0.3, ...options });
920
+ group.add(mesh(new THREE.BoxGeometry(span * 0.32, 0.32, span * 0.22), metal, { x: -span * 0.18, y: y + 0.16, z: span * 0.12 }));
921
+ group.add(mesh(new THREE.BoxGeometry(span * 0.18, 0.24, span * 0.18), metal, { x: span * 0.22, y: y + 0.12, z: -span * 0.2 }));
922
+ group.add(mesh(new THREE.CylinderGeometry(0.22, 0.22, 0.5, 12), standard(0xd8d4ca, options), { x: span * 0.24, y: y + 0.25, z: span * 0.2 }));
923
+ }
924
+
418
925
  function landmark(form, color, height, status, active = true) {
419
926
  const group = new THREE.Group();
420
927
  const phase = statusPhase(status);
@@ -423,22 +930,60 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
423
930
  const lit = !concept && active;
424
931
  const tone = phase === 'maintenance' ? muted(color, 0.55) : new THREE.Color(color);
425
932
  const options = concept ? { transparent: true, opacity: 0.38 } : {};
933
+ const stone = phase === 'maintenance' ? muted(0xe7e2d6, 0.3) : new THREE.Color(0xe7e2d6);
934
+ const at = (object, y) => {
935
+ object.position.y += y;
936
+ group.add(object);
937
+ return object;
938
+ };
426
939
  if (form === 'tower') {
427
- const base = texturedBlock(3, height, 3, tone, lit, { roughness: 0.4, ...options });
428
- const mid = texturedBlock(2.2, height * 0.45, 2.2, tone, lit, { roughness: 0.4, ...options });
429
- mid.position.y += height;
430
- group.add(base, mid, mesh(new THREE.CylinderGeometry(0.05, 0.05, 1.6, 6), standard(0x46524a), { y: height * 1.45 + 0.8 }));
940
+ // A glass tower on a stone podium, set back once, with a crown and a mast (total height × 1.45).
941
+ const podium = Math.min(1.1, height * 0.2);
942
+ at(block(3.8, podium, 3.8, { style: 'office', wall: stone, lit, roof: 0xcfcac0, ...options }), 0);
943
+ at(block(3, height - podium, 3, { style: 'glass', wall: tone, lit, roof: 0xd5d8d6, roughness: 0.3, ...options }), podium);
944
+ at(block(2.2, height * 0.45, 2.2, { style: 'glass', wall: tone, lit, roof: 0xd5d8d6, roughness: 0.3, ...options }), height);
945
+ group.add(mesh(new THREE.BoxGeometry(2.45, 0.16, 2.45), standard(0xf1f1ee, options), { y: height * 1.45 + 0.08 }));
946
+ group.add(mesh(new THREE.BoxGeometry(3.15, 0.12, 3.15), standard(0xf1f1ee, options), { y: height + 0.06 }));
947
+ rooftopPlant(group, height + 0.12, 3, options);
948
+ group.add(mesh(new THREE.CylinderGeometry(0.04, 0.07, 1.6, 6), standard(0x5a6460, { metalness: 0.5, roughness: 0.4 }), { y: height * 1.45 + 0.96 }));
949
+ group.add(mesh(new THREE.SphereGeometry(0.09, 8, 6), standard(0xffffff, { emissive: new THREE.Color(0xffffff), emissiveIntensity: 0.4 }), { y: height * 1.45 + 1.8 }));
431
950
  } else if (form === 'hall') {
432
- const hall = texturedBlock(4.4, height * 0.55, 3, tone, lit, options);
433
- const roof = mesh(gableRoof(4.4, 3, 1.4), standard(muted(color, 0.15).multiplyScalar(0.85), options), { y: height * 0.55 });
951
+ // A brick hall with a pitched roof in the kind's colour and a columned entrance.
952
+ const bodyHeight = height * 0.55;
953
+ const wall = new THREE.Color(0xf0e6d6).lerp(tone, 0.28);
954
+ at(block(4.4, bodyHeight, 3, { style: 'brick', wall, lit, roof: 0x9b8f80, ...options }), 0);
955
+ group.add(mesh(new THREE.BoxGeometry(4.6, 0.14, 3.2), standard(0xf5f1e8, options), { y: bodyHeight + 0.07 }));
956
+ const roof = mesh(gableRoof(3.2, 4.6, 1.45), standard(tone.clone().multiplyScalar(0.82), { roughness: 0.75, ...options }), { y: bodyHeight + 0.14 });
434
957
  roof.rotation.y = Math.PI / 2;
435
- group.add(hall, roof);
958
+ group.add(roof);
959
+ group.add(mesh(new THREE.BoxGeometry(0.42, 0.9, 0.42), standard(0x9c7a62, options), { x: 1.2, y: bodyHeight + 0.9, z: -0.6 }));
960
+ // Portico on the side that faces the default camera.
961
+ const columnHeight = Math.max(0.9, bodyHeight * 0.72);
962
+ group.add(mesh(new THREE.BoxGeometry(3, 0.16, 1.1), standard(0xebe5d9, options), { y: 0.08, z: 2.05 }));
963
+ for (let c = 0; c < 4; c += 1) group.add(mesh(new THREE.CylinderGeometry(0.11, 0.13, columnHeight, 10), standard(0xf7f4ee, options), { x: -1.2 + c * 0.8, y: 0.16 + columnHeight / 2, z: 2.25 }));
964
+ const pediment = mesh(gableRoof(3.1, 1.1, 0.55), standard(0xf7f4ee, options), { y: 0.16 + columnHeight, z: 2.05 });
965
+ group.add(pediment);
966
+ group.add(mesh(new THREE.BoxGeometry(0.7, Math.min(1.1, columnHeight * 0.8), 0.06), standard(0x6e5442, options), { y: 0.16 + Math.min(1.1, columnHeight * 0.8) / 2, z: 1.52 }));
436
967
  } else if (form === 'dome') {
437
- group.add(mesh(new THREE.CylinderGeometry(1.9, 2.1, height * 0.5, 24), standard(tone, options), { y: height * 0.25 }));
438
- group.add(mesh(new THREE.SphereGeometry(1.9, 24, 12, 0, Math.PI * 2, 0, Math.PI / 2), standard(0xf3f6f8, { roughness: 0.3, ...options }), { y: height * 0.5 }));
968
+ // A research hall: a windowed drum under a faceted dome with a lantern, and a low wing.
969
+ const drumHeight = height * 0.5;
970
+ const drum = mesh(new THREE.CylinderGeometry(1.9, 2.05, drumHeight, 32, 1, true), facadeMaterial('research', tone, lit, 2 * Math.PI * 1.9, drumHeight, options), { y: drumHeight / 2 });
971
+ group.add(drum);
972
+ group.add(mesh(new THREE.TorusGeometry(2.0, 0.12, 6, 40).rotateX(Math.PI / 2), standard(0xf3f1ea, options), { y: drumHeight }));
973
+ group.add(mesh(new THREE.SphereGeometry(1.95, 18, 9, 0, Math.PI * 2, 0, Math.PI / 2), standard(0xeef2f4, { roughness: 0.28, metalness: 0.25, flatShading: true, ...options }), { y: drumHeight }));
974
+ group.add(mesh(new THREE.CylinderGeometry(0.32, 0.36, 0.5, 12), standard(0xf3f1ea, options), { y: drumHeight + 1.95 + 0.2 }));
975
+ group.add(mesh(new THREE.ConeGeometry(0.42, 0.45, 12), standard(tone.clone().multiplyScalar(0.85), options), { y: drumHeight + 1.95 + 0.67 }));
976
+ const wing = at(block(2.4, Math.max(0.9, drumHeight * 0.45), 1.5, { style: 'office', wall: stone, lit, roof: 0xcfcac0, ...options }), 0);
977
+ wing.position.set(-1.9, wing.position.y, 1.2);
439
978
  } else {
440
- group.add(texturedBlock(3.6, height * 0.75, 2.8, tone, lit, options));
441
- group.add(mesh(new THREE.BoxGeometry(1.2, 0.6, 1), standard(0xb8c2bb), { y: height * 0.75 + 0.3 }));
979
+ // An office: a main block and a lower wing, with a parapet and roof plant.
980
+ const mainHeight = height * 0.75;
981
+ const wall = new THREE.Color(0xeeeae2).lerp(tone, 0.35);
982
+ at(block(3.6, mainHeight, 2.8, { style: 'office', wall, lit, roof: 0xc4c0b6, ...options }), 0);
983
+ group.add(mesh(new THREE.BoxGeometry(3.72, 0.16, 2.92), standard(0xf3f1ec, options), { y: mainHeight + 0.08 }));
984
+ rooftopPlant(group, mainHeight + 0.16, 2.8, options);
985
+ const wing = at(block(1.9, Math.max(0.8, mainHeight * 0.5), 2.1, { style: 'office', wall: stone, lit, roof: 0xc4c0b6, ...options }), 0);
986
+ wing.position.set(1.95, wing.position.y, 1.0);
442
987
  }
443
988
  if (concept) {
444
989
  // Scaffold outline: a business that is still a concept.
@@ -449,22 +994,36 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
449
994
  return group;
450
995
  }
451
996
 
997
+ const HOUSE_WALLS = [0xf4efe4, 0xefe6d6, 0xf2ece6, 0xe9e4d6];
452
998
  function house(engagement, color) {
453
999
  const group = new THREE.Group();
454
1000
  const status = engagement.status;
455
1001
  const phase = statusPhase(status);
456
1002
  if (phase === 'finished') {
457
- // A finished engagement leaves its lot: a low slab, no roof.
458
- group.add(mesh(new THREE.BoxGeometry(1.8, 0.18, 1.8), standard(0xc9cfc8), { y: 0.09 }));
1003
+ // A finished engagement becomes a small memorial park: a lawn edged by hedges, a stone, a bench.
1004
+ group.add(mesh(new THREE.BoxGeometry(1.9, 0.14, 1.9), standard(0xa6c796, { roughness: 1 }), { y: 0.07 }));
1005
+ const hedge = standard(0x6f9a68, { roughness: 1 });
1006
+ for (const [hx, hz, hw, hd] of [[0, -0.88, 1.9, 0.16], [-0.88, 0, 0.16, 1.9], [0.88, 0.35, 0.16, 1.2]]) group.add(mesh(new THREE.BoxGeometry(hw, 0.26, hd), hedge, { x: hx, y: 0.24, z: hz }));
1007
+ group.add(mesh(new THREE.BoxGeometry(0.5, 0.12, 0.5), standard(0xcfc8b8), { y: 0.2 }));
1008
+ group.add(mesh(new THREE.BoxGeometry(0.24, 0.85, 0.24), standard(0xdcd5c6), { y: 0.68 }));
1009
+ group.add(mesh(new THREE.ConeGeometry(0.18, 0.24, 4).rotateY(Math.PI / 4), standard(0xdcd5c6), { y: 1.22 }));
1010
+ group.add(mesh(new THREE.BoxGeometry(0.7, 0.08, 0.24), standard(0x9a7b5c), { x: 0.2, y: 0.32, z: 0.62 }));
1011
+ group.add(mesh(new THREE.IcosahedronGeometry(0.26, 0), standard(0x7fa877, { roughness: 1, flatShading: true }), { x: -0.5, y: 0.34, z: 0.45 }));
459
1012
  group.userData.finished = true;
460
1013
  return group;
461
1014
  }
1015
+ // One house per engagement; the roof takes the kind's colour, lit windows mean the engagement is active.
462
1016
  const h = 0.9 + hashUnit(engagement.id) * 1.1;
463
- const tone = phase === 'maintenance' ? muted(color, 0.6) : new THREE.Color(0xf2efe8);
464
- group.add(texturedBlock(1.6, h, 1.4, tone, phase === 'active'));
465
- const roof = mesh(gableRoof(1.6, 1.4, 0.8), standard(phase === 'maintenance' ? muted(color, 0.6) : color), { y: h });
466
- roof.rotation.y = hashUnit(`${engagement.id}r`) > 0.5 ? Math.PI / 2 : 0;
1017
+ const wall = phase === 'maintenance' ? muted(color, 0.6) : new THREE.Color(HOUSE_WALLS[Math.floor(hashUnit(`${engagement.id}w`) * HOUSE_WALLS.length)]);
1018
+ const turned = hashUnit(`${engagement.id}r`) > 0.5;
1019
+ group.add(block(1.6, h, 1.4, { style: 'house', wall, lit: phase === 'active', roof: 0xb9b2a5 }));
1020
+ const roofColor = phase === 'maintenance' ? muted(color, 0.6) : new THREE.Color(color).multiplyScalar(0.9);
1021
+ const roof = mesh(gableRoof(turned ? 1.4 : 1.6, turned ? 1.6 : 1.4, 0.8).scale(1.08, 1, 1.08), standard(roofColor, { roughness: 0.7 }), { y: h });
1022
+ roof.rotation.y = turned ? Math.PI / 2 : 0;
467
1023
  group.add(roof);
1024
+ group.add(mesh(new THREE.BoxGeometry(0.22, 0.55, 0.22), standard(0x9c7a62), { x: 0.42, y: h + 0.45, z: -0.25 }));
1025
+ group.add(mesh(new THREE.BoxGeometry(0.34, 0.58, 0.05), standard(0x7a5a43), { y: 0.29, z: 0.72 }));
1026
+ group.add(mesh(new THREE.BoxGeometry(0.52, 0.05, 0.22), standard(0xe2ddd2), { y: 0.62, z: 0.8 }));
468
1027
  return group;
469
1028
  }
470
1029
 
@@ -483,8 +1042,13 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
483
1042
  return group;
484
1043
  }
485
1044
 
1045
+ const markers = { cities: new Map(), plaza: new Map() };
486
1046
  function buildCities({ cities, sectors, extent }, judgmentsByBusiness = new Map()) {
487
1047
  fitExtent(extent);
1048
+ const landRadius = buildTerrain(extent);
1049
+ const roads = [];
1050
+ const keepOut = (point) => Math.hypot(point.x, point.z) < PLAZA_RADIUS + 5
1051
+ || cities.some((city) => Math.hypot(point.x - city.x, point.z - city.z) < city.size * 0.62 + 1.2);
488
1052
  for (let i = 0; i < 12; i += 1) {
489
1053
  const angle = (i / 12) * Math.PI * 2;
490
1054
  road(new THREE.Vector3(Math.cos(angle) * (PLAZA_RADIUS + 4), 0, Math.sin(angle) * (PLAZA_RADIUS + 4)), new THREE.Vector3(Math.cos(angle + Math.PI / 6) * (PLAZA_RADIUS + 4), 0, Math.sin(angle + Math.PI / 6) * (PLAZA_RADIUS + 4)), 1.5);
@@ -495,16 +1059,22 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
495
1059
  const color = kindColor(kind);
496
1060
  const start = new THREE.Vector3(x, 0, z).setLength(PLAZA_RADIUS + 4);
497
1061
  road(start, new THREE.Vector3(x, 0, z));
1062
+ roads.push([start, new THREE.Vector3(x, 0, z)]);
1063
+ avenue(start, new THREE.Vector3(x, 0, z), keepOut);
498
1064
  const group = new THREE.Group();
499
1065
  group.position.set(x, 0, z);
500
- const plateColor = new THREE.Color(0xf7f8f4).lerp(new THREE.Color(color), 0.1);
1066
+ // A city block: a curb step, a paved block in a hint of the kind's colour, lamps on its corners.
1067
+ group.add(mesh(roundedPlate(size + 0.9, size + 0.9, 0.18, 2.0), standard(0xcfc9bb, { roughness: 1 })));
1068
+ const plateColor = new THREE.Color(0xf3f1ea).lerp(new THREE.Color(color), 0.08);
501
1069
  group.add(mesh(roundedPlate(size, size, 0.45, 1.6), standard(plateColor, { roughness: 0.95 })));
1070
+ for (const [cx, cz] of [[1, 1], [1, -1], [-1, 1], [-1, -1]]) streetLamp(x + cx * (size / 2 - 0.9), z + cz * (size / 2 - 0.9), 0.45);
502
1071
  const measures = cityMeasures(business, { isFinished, judgments: judgmentsByBusiness.get(business.code) ?? [] });
503
1072
  const towerHeight = measures.towerHeight;
504
1073
  group.add(landmark(entry.form, color, towerHeight, business.status, measures.lit));
505
1074
  scene.add(group);
506
1075
  register(group, { kind: 'city', business });
507
1076
  const top = entry.form === 'tower' ? towerHeight * 1.45 + 1.8 : towerHeight + 1;
1077
+ markers.cities.set(business.code, new THREE.Vector3(x, top, z));
508
1078
  const cityLabel = addLabel(statusPhase(business.status) === 'concept' ? `${business.name}(${statusText(business.status)})` : business.name, new THREE.Vector3(x, top, z), 'is-city', { priority: LABEL_PRIORITY.city, owner: group });
509
1079
  cityLabel.node.style.borderColor = `${entry.color}80`;
510
1080
  cityLabel.business = business;
@@ -540,9 +1110,12 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
540
1110
  const treeCount = 2 + Math.floor(hashUnit(business.id) * 3);
541
1111
  for (let t = 0; t < treeCount; t += 1) {
542
1112
  const a = hashUnit(`${business.id}${t}`) * Math.PI * 2;
543
- tree(x + Math.cos(a) * size * 0.42, z + Math.sin(a) * size * 0.42, 0.8);
1113
+ tree(x + Math.cos(a) * size * 0.42, z + Math.sin(a) * size * 0.42, 0.8, 0.45);
544
1114
  }
545
1115
  }
1116
+ countryside(landRadius, cities, roads);
1117
+ plantTrees();
1118
+ placeLamps();
546
1119
  for (const { kind, angle } of sectors) {
547
1120
  addLabel(kindEntry(kind).label, new THREE.Vector3(Math.cos(angle) * 21, 0.2, Math.sin(angle) * 21), 'is-sector', { priority: LABEL_PRIORITY.sector });
548
1121
  }
@@ -559,11 +1132,13 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
559
1132
  function setHighlight(group, on) {
560
1133
  for (const entry of highlightable.get(group) ?? []) {
561
1134
  if (on) {
1135
+ highlightedMaterials.add(entry.material);
562
1136
  entry.material.emissive.set(0x3d8f74);
563
1137
  entry.material.emissiveIntensity = 0.35;
564
1138
  } else {
1139
+ highlightedMaterials.delete(entry.material);
565
1140
  entry.material.emissive.copy(entry.base);
566
- entry.material.emissiveIntensity = entry.intensity;
1141
+ entry.material.emissiveIntensity = litMaterials.has(entry.material) ? sky.glow : entry.intensity;
567
1142
  }
568
1143
  }
569
1144
  }
@@ -705,6 +1280,34 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
705
1280
  }
706
1281
  }
707
1282
 
1283
+ /** A short burst of rising lights over what moved since the last visit. */
1284
+ function markChanges(changes) {
1285
+ const points = [
1286
+ ...(changes?.cities ?? []).map((code) => markers.cities.get(code)),
1287
+ ...(changes?.plaza ?? []).map((key) => markers.plaza.get(key)),
1288
+ ].filter(Boolean);
1289
+ for (const point of points) {
1290
+ for (let i = 0; i < 10; i += 1) {
1291
+ // Around the building and from its middle, so the labels above it never hide the sparks.
1292
+ const angle = (i / 10) * Math.PI * 2;
1293
+ const spark = mesh(new THREE.SphereGeometry(0.6, 10, 8), new THREE.MeshBasicMaterial({ color: 0xffb21a, transparent: true, opacity: 0.95, fog: false }), { x: point.x + Math.cos(angle) * 2.6, y: point.y * 0.35, z: point.z + Math.sin(angle) * 2.6, shadow: false });
1294
+ spark.userData = { base: Math.max(1.2, point.y * 0.35), rise: Math.max(4, point.y * 0.8), delay: i * 220 };
1295
+ scene.add(spark);
1296
+ animated.sparkles.push(spark);
1297
+ }
1298
+ // A ring on the ground that pulses with the sparks.
1299
+ const ring = mesh(new THREE.RingGeometry(2.8, 3.5, 48), new THREE.MeshBasicMaterial({ color: 0xffb21a, transparent: true, opacity: 0.8, side: THREE.DoubleSide, depthTest: false, fog: false }), { x: point.x, y: 0.9, z: point.z, shadow: false });
1300
+ ring.rotation.x = -Math.PI / 2;
1301
+ ring.renderOrder = 2;
1302
+ ring.userData = { ring: true, base: 0.9, delay: 0 };
1303
+ scene.add(ring);
1304
+ animated.sparkles.push(ring);
1305
+ }
1306
+ animated.sparkleStart = null;
1307
+ return points.length;
1308
+ }
1309
+
1310
+ let lastSkyMinute = -1;
708
1311
  let running = true;
709
1312
  function frame(time) {
710
1313
  if (!running) return;
@@ -735,7 +1338,36 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
735
1338
  renderer.domElement.style.cursor = hovered ? 'pointer' : '';
736
1339
  }
737
1340
  }
1341
+ const minute = Math.floor(Date.now() / 60000);
1342
+ if (skyMode === 'auto' && minute !== lastSkyMinute) {
1343
+ if (lastSkyMinute !== -1 && skyAt(new Date().getHours(), 'auto').phase !== sky.phase) applySky();
1344
+ lastSkyMinute = minute;
1345
+ }
1346
+ if (animated.sparkles.length) {
1347
+ // Timed by the clock, not the frame timestamp, which can lag behind on a slow renderer.
1348
+ const clock = performance.now();
1349
+ animated.sparkleStart ??= clock;
1350
+ const elapsed = clock - animated.sparkleStart;
1351
+ for (const spark of animated.sparkles) {
1352
+ const t = Math.max(0, elapsed - spark.userData.delay) / 2600;
1353
+ const cycle = t % 1;
1354
+ if (spark.userData.ring) {
1355
+ spark.scale.setScalar(reducedMotion ? 1 : 1 + cycle * 0.6);
1356
+ spark.material.opacity = elapsed > 9000 ? Math.max(0, 0.8 - (elapsed - 9000) / 1500) : reducedMotion ? 0.8 : 0.8 * (1 - cycle);
1357
+ continue;
1358
+ }
1359
+ spark.position.y = spark.userData.base + (reducedMotion ? 0.6 : cycle * spark.userData.rise);
1360
+ spark.material.opacity = elapsed > 9000 ? Math.max(0, 1 - (elapsed - 9000) / 1500) : reducedMotion ? 0.9 : 0.95 * (1 - cycle);
1361
+ }
1362
+ if (elapsed > 10500) {
1363
+ for (const spark of animated.sparkles) scene.remove(spark);
1364
+ animated.sparkles = [];
1365
+ }
1366
+ }
738
1367
  if (!reducedMotion) {
1368
+ if (animated.clouds.length) placeClouds(time);
1369
+ if (animated.foam) animated.foam.material.opacity = 0.3 + 0.24 * (1 + Math.sin(time / 1400)) / 2;
1370
+ for (const jet of animated.jets) jet.scale.y = 0.85 + 0.2 * Math.sin(time / 260);
739
1371
  const wave = (1 + Math.sin(time / 380)) / 2;
740
1372
  for (const beam of beacons) beam.material.opacity = 0.35 + 0.3 * wave;
741
1373
  for (const ring of glows) {
@@ -753,6 +1385,13 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
753
1385
  return {
754
1386
  buildPlaza,
755
1387
  buildCities,
1388
+ markChanges,
1389
+ /** The sky: 'auto' follows the hour; 'day', 'dusk' or 'night' fix it. Returns the phase shown. */
1390
+ setSky(mode) {
1391
+ skyMode = mode;
1392
+ applySky();
1393
+ return sky.phase;
1394
+ },
756
1395
  flyTo,
757
1396
  resetView,
758
1397
  clearSelection() {
@@ -799,12 +1438,26 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
799
1438
  root.append(wrap);
800
1439
  let scene = null;
801
1440
 
1441
+ // The sky follows the hour by default; the viewer can fix it to see the lit windows at night.
1442
+ const SKY_MODES = [['auto', '空:時刻'], ['day', '空:昼'], ['dusk', '空:夕方'], ['night', '空:夜']];
1443
+ let skyIndex = 0;
1444
+ const skyButton = workspaceButton(doc, {
1445
+ text: SKY_MODES[0][1],
1446
+ onClick: () => {
1447
+ skyIndex = (skyIndex + 1) % SKY_MODES.length;
1448
+ skyButton.textContent = SKY_MODES[skyIndex][1];
1449
+ scene?.setSky(SKY_MODES[skyIndex][0]);
1450
+ },
1451
+ });
802
1452
  const header = workspacePageHeader(doc, {
803
1453
  crumbs: page?.crumbs ?? ['あなたのBrainbase', '世界'],
804
1454
  title: '世界',
805
1455
  lead: '事業を都市、案件を区画、判断の種類を広場の建物として描いています。見るための画面で、ここからは何も書き換えません。',
806
1456
  source: page?.source ?? null,
807
- actions: [workspaceButton(doc, { text: '全体に戻る', onClick: () => { scene?.clearSelection(); showEmptyRail(); scene?.resetView(); } })],
1457
+ actions: [
1458
+ skyButton,
1459
+ workspaceButton(doc, { text: '全体に戻る', onClick: () => { scene?.clearSelection(); showEmptyRail(); scene?.resetView(); } }),
1460
+ ],
808
1461
  });
809
1462
  const notices = el(doc, 'div', { className: 'bb-world-hud', attrs: { 'aria-live': 'polite' } });
810
1463
  const legend = el(doc, 'div', { className: 'bb-world-legend', attrs: { 'aria-label': '凡例' } });
@@ -916,7 +1569,7 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
916
1569
  ['分類', kindText],
917
1570
  ['状態', statusText(business.status)],
918
1571
  ['コード', business.code],
919
- ['進行中の案件(都市の広さ)', `${measures.open}件${measures.finished ? `(完了・終了${measures.finished}件は更地)` : ''}`],
1572
+ ['進行中の案件(都市の広さ)', `${measures.open}件${measures.finished ? `(完了・終了${measures.finished}件は記念公園)` : ''}`],
920
1573
  ['最近30日の動き(高さ・明かり)', `決定${measures.decisions}件${judgmentText}`],
921
1574
  ]) }));
922
1575
  const ul = el(doc, 'ul', { className: 'bb-world-rail-list' });
@@ -1072,6 +1725,46 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
1072
1725
  scene = createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear: showEmptyRail });
1073
1726
  scene.buildPlaza(rows, rowItems, (row) => (rowItems.get(row.key) ?? []).filter((ref) => isWaiting(proofs.get(ref.decision_attempt_id))).length, Date.now());
1074
1727
  scene.buildCities(layoutWorld(businesses), placement.byBusiness);
1728
+ showChangesSinceLastVisit(note, businesses, rows);
1729
+ }
1730
+
1731
+ /**
1732
+ * What moved since this viewer last opened the world (a per-viewer convenience kept in this browser;
1733
+ * nothing is lost if the browser forgets it).
1734
+ */
1735
+ function showChangesSinceLastVisit(note, businesses, rows) {
1736
+ const key = 'brainbase.world.lastVisit';
1737
+ let lastVisit = null;
1738
+ try {
1739
+ lastVisit = globalThis.localStorage?.getItem(key) ?? null;
1740
+ } catch {
1741
+ lastVisit = null;
1742
+ }
1743
+ const changes = changesSince(lastVisit, businesses, placement.byBusiness, rows);
1744
+ try {
1745
+ globalThis.localStorage?.setItem(key, new Date().toISOString());
1746
+ } catch {
1747
+ // Without storage the world simply shows no marks next time.
1748
+ }
1749
+ if (!changes) {
1750
+ note('前回から', '初めての表示です。次に開いたときから、前回から動きがあった所に光の印を出します。');
1751
+ return;
1752
+ }
1753
+ const names = new Map(businesses.map((business) => [business.code, business.name]));
1754
+ const cityNames = changes.cities.map((code) => names.get(code) ?? code);
1755
+ const last = new Date(changes.since);
1756
+ const pad = (value) => String(value).padStart(2, '0');
1757
+ const when = `${pad(last.getMonth() + 1)}/${pad(last.getDate())} ${pad(last.getHours())}:${pad(last.getMinutes())}`;
1758
+ if (cityNames.length === 0 && changes.plaza.length === 0) {
1759
+ note('前回から', `前回(${when})から、新しい決定や判断はありません。`);
1760
+ return;
1761
+ }
1762
+ const parts = [
1763
+ cityNames.length ? `都市:${cityNames.join('・')}` : null,
1764
+ changes.plaza.length ? `広場の判断の種類${changes.plaza.length}つ` : null,
1765
+ ].filter(Boolean);
1766
+ note('前回から', `前回(${when})から動きがあった所に光の印を出しています。${parts.join('、')}。`, 'attention');
1767
+ scene?.markChanges(changes);
1075
1768
  }
1076
1769
 
1077
1770
  void load();