@unson/brainbase-mcp 0.15.5 → 0.15.6

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.6",
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": "94fabc0ef66f6296d9f68fb5e4e54d87a63fc65b"
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,
@@ -242,15 +242,29 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
242
242
  stage.prepend(renderer.domElement);
243
243
 
244
244
  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);
245
+ // Scenery (sky, sea, land, mountains, trees, lamps) represents no data: it only sets the place.
246
+ let skyMode = 'auto';
247
+ let sky = skyAt(new Date().getHours(), skyMode);
248
+ function skyTexture(current) {
249
+ return canvasTexture(doc, 256, 256, (ctx, w, h) => {
250
+ const gradient = ctx.createLinearGradient(0, 0, 0, h);
251
+ gradient.addColorStop(0, current.top);
252
+ gradient.addColorStop(0.55, current.middle);
253
+ gradient.addColorStop(1, current.bottom);
254
+ ctx.fillStyle = gradient;
255
+ ctx.fillRect(0, 0, w, h);
256
+ if (current.stars) {
257
+ ctx.fillStyle = 'rgba(255,255,255,0.85)';
258
+ for (let i = 0; i < 90; i += 1) {
259
+ const sx = hashUnit(`star${i}x`) * w;
260
+ const sy = hashUnit(`star${i}y`) * h * 0.55;
261
+ ctx.fillRect(sx, sy, hashUnit(`star${i}s`) > 0.85 ? 1.6 : 0.9, hashUnit(`star${i}s`) > 0.85 ? 1.6 : 0.9);
262
+ }
263
+ }
264
+ });
265
+ }
266
+ scene.background = skyTexture(sky);
267
+ scene.fog = new THREE.Fog(sky.fog, 170, 340);
254
268
 
255
269
  const camera = new THREE.OrthographicCamera(-50, 50, 50, -50, 0.1, 1000);
256
270
  const home = { position: new THREE.Vector3(90, 95, 90), target: new THREE.Vector3(0, 0, 0), zoom: 1 };
@@ -267,8 +281,9 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
267
281
  controls.minPolarAngle = Math.PI / 6;
268
282
  controls.target.copy(home.target);
269
283
 
270
- scene.add(new THREE.HemisphereLight(0xf4f8ff, 0xd8ccb2, 1.25));
271
- const sun = new THREE.DirectionalLight(0xfff1dc, 2.7);
284
+ const hemisphere = new THREE.HemisphereLight(0xf4f8ff, 0xd8ccb2, sky.hemi);
285
+ scene.add(hemisphere);
286
+ const sun = new THREE.DirectionalLight(sky.sun, sky.sunIntensity);
272
287
  sun.position.set(70, 110, 40);
273
288
  sun.castShadow = true;
274
289
  sun.shadow.mapSize.set(2048, 2048);
@@ -280,11 +295,10 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
280
295
  fill.position.set(-80, 60, -60);
281
296
  scene.add(fill);
282
297
 
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);
298
+ // Lit windows and street lamps follow the sky (brightest at night); their meaning does not change.
299
+ const litMaterials = new Set();
300
+ const lampMaterials = [];
301
+ const animated = { water: null, jets: [], sparkles: [] };
288
302
 
289
303
  const textures = { lit: windowTexture(doc, true), unlit: windowTexture(doc, false), glow: windowTexture(doc, true, true) };
290
304
  const pickables = [];
@@ -324,7 +338,8 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
324
338
  glow.repeat.copy(map.repeat);
325
339
  material.emissive = new THREE.Color(0xffc867);
326
340
  material.emissiveMap = glow;
327
- material.emissiveIntensity = 0.6;
341
+ material.emissiveIntensity = sky.glow;
342
+ litMaterials.add(material);
328
343
  }
329
344
  return mesh(new THREE.BoxGeometry(w, h, d), material, { y: h / 2 });
330
345
  }
@@ -350,14 +365,215 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
350
365
  scene.add(line);
351
366
  }
352
367
 
368
+ // Trees are many; they are drawn as two instanced meshes (trunks, crowns) once the world is built.
369
+ const treePlacements = [];
353
370
  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);
371
+ treePlacements.push([x, z, scale]);
372
+ }
373
+ function plantTrees() {
374
+ if (treePlacements.length === 0) return;
375
+ const trunks = new THREE.InstancedMesh(new THREE.CylinderGeometry(0.12, 0.16, 0.7, 6), standard(0x8a6d4e), treePlacements.length);
376
+ const crowns = new THREE.InstancedMesh(new THREE.ConeGeometry(0.7, 1.6, 7), standard(0x6f9a72, { roughness: 0.95 }), treePlacements.length);
377
+ const matrix = new THREE.Matrix4();
378
+ const tint = new THREE.Color();
379
+ treePlacements.forEach(([x, z, scale], index) => {
380
+ matrix.makeScale(scale, scale, scale).setPosition(x, 0.35 * scale, z);
381
+ trunks.setMatrixAt(index, matrix);
382
+ matrix.makeScale(scale, scale, scale).setPosition(x, 1.4 * scale, z);
383
+ crowns.setMatrixAt(index, matrix);
384
+ crowns.setColorAt(index, tint.setHSL(0.33 + (hashUnit(`${x},${z}`) - 0.5) * 0.05, 0.22, 0.45 + (hashUnit(`${z},${x}`) - 0.5) * 0.1));
385
+ });
386
+ for (const instanced of [trunks, crowns]) {
387
+ instanced.castShadow = true;
388
+ instanced.receiveShadow = true;
389
+ scene.add(instanced);
390
+ }
391
+ treePlacements.length = 0;
392
+ }
393
+
394
+ // --- scenery (no data) -----------------------------------------------------
395
+ function grassTexture() {
396
+ const texture = canvasTexture(doc, 256, 256, (ctx, w, h) => {
397
+ ctx.fillStyle = '#cfe0c2';
398
+ ctx.fillRect(0, 0, w, h);
399
+ for (let i = 0; i < 900; i += 1) {
400
+ const tone = hashUnit(`g${i}t`);
401
+ ctx.fillStyle = tone > 0.66 ? 'rgba(150,186,132,0.35)' : tone > 0.33 ? 'rgba(214,230,196,0.45)' : 'rgba(176,204,152,0.3)';
402
+ ctx.beginPath();
403
+ ctx.arc(hashUnit(`g${i}x`) * w, hashUnit(`g${i}y`) * h, 2 + hashUnit(`g${i}r`) * 7, 0, Math.PI * 2);
404
+ ctx.fill();
405
+ }
406
+ });
407
+ texture.wrapS = THREE.RepeatWrapping;
408
+ texture.wrapT = THREE.RepeatWrapping;
409
+ texture.repeat.set(18, 18);
410
+ return texture;
411
+ }
412
+
413
+ function waterTexture() {
414
+ const texture = canvasTexture(doc, 128, 128, (ctx, w, h) => {
415
+ ctx.fillStyle = '#8fc0d2';
416
+ ctx.fillRect(0, 0, w, h);
417
+ ctx.strokeStyle = 'rgba(255,255,255,0.35)';
418
+ ctx.lineWidth = 1.2;
419
+ for (let i = 0; i < 26; i += 1) {
420
+ const x = hashUnit(`w${i}x`) * w;
421
+ const y = hashUnit(`w${i}y`) * h;
422
+ ctx.beginPath();
423
+ ctx.moveTo(x, y);
424
+ ctx.quadraticCurveTo(x + 6, y - 3, x + 12, y);
425
+ ctx.stroke();
426
+ }
427
+ });
428
+ texture.wrapS = THREE.RepeatWrapping;
429
+ texture.wrapT = THREE.RepeatWrapping;
430
+ texture.repeat.set(40, 40);
431
+ return texture;
359
432
  }
360
433
 
434
+ function pavingTexture() {
435
+ return canvasTexture(doc, 128, 128, (ctx, w, h) => {
436
+ ctx.fillStyle = '#f1ece1';
437
+ ctx.fillRect(0, 0, w, h);
438
+ ctx.strokeStyle = 'rgba(176,164,140,0.55)';
439
+ ctx.lineWidth = 1;
440
+ for (let r = 8; r < w / 2; r += 9) {
441
+ ctx.beginPath();
442
+ ctx.arc(w / 2, h / 2, r, 0, Math.PI * 2);
443
+ ctx.stroke();
444
+ }
445
+ for (let i = 0; i < 24; i += 1) {
446
+ const a = (i / 24) * Math.PI * 2;
447
+ ctx.beginPath();
448
+ ctx.moveTo(w / 2 + Math.cos(a) * 8, h / 2 + Math.sin(a) * 8);
449
+ ctx.lineTo(w / 2 + Math.cos(a) * w / 2, h / 2 + Math.sin(a) * h / 2);
450
+ ctx.stroke();
451
+ }
452
+ });
453
+ }
454
+
455
+ /** Land, beach, sea and a far shore of mountains around the cities; scenery only. */
456
+ function buildTerrain(extent) {
457
+ const landRadius = extent + 22;
458
+ const land = mesh(new THREE.CircleGeometry(landRadius, 96), standard(0xffffff, { map: grassTexture(), roughness: 1 }), { shadow: false });
459
+ land.rotation.x = -Math.PI / 2;
460
+ land.receiveShadow = true;
461
+ scene.add(land);
462
+ const beach = mesh(new THREE.RingGeometry(landRadius, landRadius + 4.5, 96), new THREE.MeshLambertMaterial({ color: 0xeadfc4 }), { y: -0.05, shadow: false });
463
+ beach.rotation.x = -Math.PI / 2;
464
+ beach.receiveShadow = false;
465
+ scene.add(beach);
466
+ const water = waterTexture();
467
+ const sea = mesh(new THREE.PlaneGeometry(900, 900), new THREE.MeshLambertMaterial({ map: water }), { y: -0.35, shadow: false });
468
+ sea.rotation.x = -Math.PI / 2;
469
+ sea.receiveShadow = false;
470
+ scene.add(sea);
471
+ animated.water = water;
472
+ // A far shore: low mountains across the water, faded by the fog.
473
+ for (let i = 0; i < 22; i += 1) {
474
+ const angle = Math.PI * 0.85 + (i / 21) * Math.PI * 0.95 + (hashUnit(`m${i}a`) - 0.5) * 0.08;
475
+ const distance = landRadius + 110 + hashUnit(`m${i}d`) * 50;
476
+ const peak = 10 + hashUnit(`m${i}h`) * 16;
477
+ const radius = 10 + hashUnit(`m${i}r`) * 9;
478
+ const mountain = mesh(new THREE.ConeGeometry(radius, peak, 7), new THREE.MeshLambertMaterial({ color: 0xb4c4bc, flatShading: true }), { x: Math.cos(angle) * distance, y: peak / 2 - 0.4, z: Math.sin(angle) * distance, shadow: false });
479
+ mountain.rotation.y = hashUnit(`m${i}y`) * Math.PI;
480
+ mountain.receiveShadow = false;
481
+ scene.add(mountain);
482
+ if (peak > 21) {
483
+ const cap = mesh(new THREE.ConeGeometry(radius * 0.32, peak * 0.3, 7), new THREE.MeshLambertMaterial({ color: 0xf4f6f6, flatShading: true }), { x: mountain.position.x, y: peak - 0.4 - peak * 0.15, z: mountain.position.z, shadow: false });
484
+ cap.rotation.y = mountain.rotation.y;
485
+ scene.add(cap);
486
+ }
487
+ }
488
+ return landRadius;
489
+ }
490
+
491
+ const lampPlacements = [];
492
+ function streetLamp(x, z) {
493
+ lampPlacements.push([x, z]);
494
+ }
495
+ function placeLamps() {
496
+ if (lampPlacements.length === 0) return;
497
+ const poles = new THREE.InstancedMesh(new THREE.CylinderGeometry(0.05, 0.07, 1.6, 6), new THREE.MeshLambertMaterial({ color: 0x4b5550 }), lampPlacements.length);
498
+ const bulbMaterial = standard(0xfff3d6, { emissive: new THREE.Color(0xffd27a), emissiveIntensity: sky.lamps ? 1.6 : 0 });
499
+ lampMaterials.push(bulbMaterial);
500
+ const bulbs = new THREE.InstancedMesh(new THREE.SphereGeometry(0.16, 10, 8), bulbMaterial, lampPlacements.length);
501
+ const matrix = new THREE.Matrix4();
502
+ lampPlacements.forEach(([x, z], index) => {
503
+ poles.setMatrixAt(index, matrix.makeTranslation(x, 0.8, z));
504
+ bulbs.setMatrixAt(index, matrix.makeTranslation(x, 1.68, z));
505
+ });
506
+ scene.add(poles, bulbs);
507
+ lampPlacements.length = 0;
508
+ }
509
+
510
+ /** Trees and lamps along a road, kept off the plaza and the city plates. */
511
+ function avenue(from, to, keepOut) {
512
+ const length = from.distanceTo(to);
513
+ const dir = to.clone().sub(from).normalize();
514
+ const side = new THREE.Vector3(-dir.z, 0, dir.x);
515
+ for (let d = 3; d < length - 2; d += 4.2) {
516
+ const point = from.clone().addScaledVector(dir, d);
517
+ if (keepOut(point)) continue;
518
+ const index = Math.round(d / 4.2);
519
+ for (const sign of [-1, 1]) {
520
+ const at = point.clone().addScaledVector(side, sign * 1.9);
521
+ if (index % 3 === 0 && sign === 1) streetLamp(at.x, at.z);
522
+ else tree(at.x, at.z, 0.55 + hashUnit(`${from.x}${d}${sign}`) * 0.2);
523
+ }
524
+ }
525
+ }
526
+
527
+ /** Groves and fields between the cities; scenery only (no houses, which could read as engagements). */
528
+ function countryside(landRadius, cities, roads) {
529
+ const blocked = (x, z, margin) => {
530
+ if (Math.hypot(x, z) < PLAZA_RADIUS + 8) return true;
531
+ if (Math.hypot(x, z) > landRadius - 4) return true;
532
+ if (cities.some((city) => Math.hypot(x - city.x, z - city.z) < city.size * 0.8 + margin)) return true;
533
+ return roads.some(([a, b]) => {
534
+ const ab = b.clone().sub(a);
535
+ const t = Math.max(0, Math.min(1, new THREE.Vector3(x, 0, z).sub(a).dot(ab) / ab.lengthSq()));
536
+ return a.clone().addScaledVector(ab, t).distanceTo(new THREE.Vector3(x, 0, z)) < margin;
537
+ });
538
+ };
539
+ let groves = 0;
540
+ for (let i = 0; i < 90 && groves < 26; i += 1) {
541
+ const angle = hashUnit(`grove${i}a`) * Math.PI * 2;
542
+ const radius = PLAZA_RADIUS + 10 + hashUnit(`grove${i}r`) * (landRadius - PLAZA_RADIUS - 14);
543
+ const x = Math.cos(angle) * radius;
544
+ const z = Math.sin(angle) * radius;
545
+ if (blocked(x, z, 4)) continue;
546
+ groves += 1;
547
+ if (hashUnit(`grove${i}k`) > 0.7) {
548
+ // A field: a flat patch in a crop colour.
549
+ 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 });
550
+ field.rotation.set(-Math.PI / 2, 0, hashUnit(`f${i}r`) * Math.PI);
551
+ scene.add(field);
552
+ continue;
553
+ }
554
+ const count = 4 + Math.floor(hashUnit(`grove${i}n`) * 6);
555
+ for (let t = 0; t < count; t += 1) {
556
+ const a = hashUnit(`grove${i}t${t}a`) * Math.PI * 2;
557
+ const r = hashUnit(`grove${i}t${t}r`) * 3.2;
558
+ tree(x + Math.cos(a) * r, z + Math.sin(a) * r, 0.6 + hashUnit(`grove${i}t${t}s`) * 0.5);
559
+ }
560
+ }
561
+ }
562
+
563
+ function applySky() {
564
+ sky = skyAt(new Date().getHours(), skyMode);
565
+ scene.background?.dispose?.();
566
+ scene.background = skyTexture(sky);
567
+ scene.fog.color.setHex(sky.fog);
568
+ sun.color.setHex(sky.sun);
569
+ sun.intensity = sky.sunIntensity;
570
+ hemisphere.intensity = sky.hemi;
571
+ renderer.toneMappingExposure = sky.exposure;
572
+ for (const material of litMaterials) if (!highlightedMaterials.has(material)) material.emissiveIntensity = sky.glow;
573
+ for (const material of lampMaterials) material.emissiveIntensity = sky.lamps ? 1.6 : 0;
574
+ }
575
+ const highlightedMaterials = new Set();
576
+
361
577
  // --- plaza ---------------------------------------------------------------
362
578
  function buildPlaza(rows, rowItems, waitingOf, now) {
363
579
  const plaza = new THREE.Group();
@@ -365,6 +581,20 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
365
581
  plaza.add(mesh(new THREE.CylinderGeometry(PLAZA_RADIUS, PLAZA_RADIUS + 0.4, 0.7, 64), standard(0xf3efe6, { roughness: 0.9 }), { y: 0.35 }));
366
582
  plaza.add(mesh(new THREE.TorusGeometry(PLAZA_RADIUS - 0.3, 0.08, 6, 64), standard(0xd8cfbd), { y: 0.72 }));
367
583
  plaza.children[2].rotation.x = Math.PI / 2;
584
+ const paving = mesh(new THREE.CircleGeometry(PLAZA_RADIUS - 0.05, 64), standard(0xffffff, { map: pavingTexture(), roughness: 0.95 }), { y: 0.705, shadow: false });
585
+ paving.rotation.x = -Math.PI / 2;
586
+ plaza.add(paving);
587
+ if (rows.length !== 1) {
588
+ // A fountain in the middle of the plaza (scenery; the kinds of judgment stand around it).
589
+ plaza.add(mesh(new THREE.CylinderGeometry(1.5, 1.7, 0.45, 32), standard(0xd9d1c1), { y: 0.92 }));
590
+ const pool = mesh(new THREE.CircleGeometry(1.3, 32), standard(0x9fd0de, { roughness: 0.2, metalness: 0.1 }), { y: 1.15, shadow: false });
591
+ pool.rotation.x = -Math.PI / 2;
592
+ plaza.add(pool);
593
+ plaza.add(mesh(new THREE.CylinderGeometry(0.18, 0.24, 0.9, 12), standard(0xd9d1c1), { y: 1.5 }));
594
+ 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 });
595
+ plaza.add(jet);
596
+ animated.jets.push(jet);
597
+ }
368
598
  scene.add(plaza);
369
599
  register(plaza, { kind: 'plaza' });
370
600
  addLabel('広場(判断)', new THREE.Vector3(0, 0.8, PLAZA_RADIUS + 2.8), 'is-plaza', { priority: LABEL_PRIORITY.plaza });
@@ -393,6 +623,7 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
393
623
  scene.add(group);
394
624
  register(group, { kind: 'judgment', row, items: rowItems.get(row.key) ?? [] });
395
625
  const top = 0.7 + 0.25 + height + 0.7;
626
+ markers.plaza.set(row.key, new THREE.Vector3(x, top, z));
396
627
  const waiting = waitingOf(row);
397
628
  if (waiting > 0) {
398
629
  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 });
@@ -454,8 +685,11 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
454
685
  const status = engagement.status;
455
686
  const phase = statusPhase(status);
456
687
  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 }));
688
+ // A finished engagement becomes a small memorial park: a lawn, a stone and two bushes.
689
+ group.add(mesh(new THREE.BoxGeometry(1.8, 0.16, 1.8), standard(0xa9c99a, { roughness: 1 }), { y: 0.08 }));
690
+ group.add(mesh(new THREE.BoxGeometry(0.28, 0.9, 0.28), standard(0xdcd5c6), { y: 0.61 }));
691
+ group.add(mesh(new THREE.ConeGeometry(0.2, 0.25, 4), standard(0xdcd5c6), { y: 1.18 }));
692
+ for (const [bx, bz] of [[-0.55, 0.45], [0.55, -0.4]]) group.add(mesh(new THREE.SphereGeometry(0.28, 8, 6), standard(0x7fa877, { roughness: 1 }), { x: bx, y: 0.3, z: bz }));
459
693
  group.userData.finished = true;
460
694
  return group;
461
695
  }
@@ -483,8 +717,13 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
483
717
  return group;
484
718
  }
485
719
 
720
+ const markers = { cities: new Map(), plaza: new Map() };
486
721
  function buildCities({ cities, sectors, extent }, judgmentsByBusiness = new Map()) {
487
722
  fitExtent(extent);
723
+ const landRadius = buildTerrain(extent);
724
+ const roads = [];
725
+ const keepOut = (point) => Math.hypot(point.x, point.z) < PLAZA_RADIUS + 5
726
+ || cities.some((city) => Math.hypot(point.x - city.x, point.z - city.z) < city.size * 0.62 + 1.2);
488
727
  for (let i = 0; i < 12; i += 1) {
489
728
  const angle = (i / 12) * Math.PI * 2;
490
729
  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,6 +734,8 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
495
734
  const color = kindColor(kind);
496
735
  const start = new THREE.Vector3(x, 0, z).setLength(PLAZA_RADIUS + 4);
497
736
  road(start, new THREE.Vector3(x, 0, z));
737
+ roads.push([start, new THREE.Vector3(x, 0, z)]);
738
+ avenue(start, new THREE.Vector3(x, 0, z), keepOut);
498
739
  const group = new THREE.Group();
499
740
  group.position.set(x, 0, z);
500
741
  const plateColor = new THREE.Color(0xf7f8f4).lerp(new THREE.Color(color), 0.1);
@@ -505,6 +746,7 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
505
746
  scene.add(group);
506
747
  register(group, { kind: 'city', business });
507
748
  const top = entry.form === 'tower' ? towerHeight * 1.45 + 1.8 : towerHeight + 1;
749
+ markers.cities.set(business.code, new THREE.Vector3(x, top, z));
508
750
  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
751
  cityLabel.node.style.borderColor = `${entry.color}80`;
510
752
  cityLabel.business = business;
@@ -543,6 +785,9 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
543
785
  tree(x + Math.cos(a) * size * 0.42, z + Math.sin(a) * size * 0.42, 0.8);
544
786
  }
545
787
  }
788
+ countryside(landRadius, cities, roads);
789
+ plantTrees();
790
+ placeLamps();
546
791
  for (const { kind, angle } of sectors) {
547
792
  addLabel(kindEntry(kind).label, new THREE.Vector3(Math.cos(angle) * 21, 0.2, Math.sin(angle) * 21), 'is-sector', { priority: LABEL_PRIORITY.sector });
548
793
  }
@@ -559,11 +804,13 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
559
804
  function setHighlight(group, on) {
560
805
  for (const entry of highlightable.get(group) ?? []) {
561
806
  if (on) {
807
+ highlightedMaterials.add(entry.material);
562
808
  entry.material.emissive.set(0x3d8f74);
563
809
  entry.material.emissiveIntensity = 0.35;
564
810
  } else {
811
+ highlightedMaterials.delete(entry.material);
565
812
  entry.material.emissive.copy(entry.base);
566
- entry.material.emissiveIntensity = entry.intensity;
813
+ entry.material.emissiveIntensity = litMaterials.has(entry.material) ? sky.glow : entry.intensity;
567
814
  }
568
815
  }
569
816
  }
@@ -705,6 +952,34 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
705
952
  }
706
953
  }
707
954
 
955
+ /** A short burst of rising lights over what moved since the last visit. */
956
+ function markChanges(changes) {
957
+ const points = [
958
+ ...(changes?.cities ?? []).map((code) => markers.cities.get(code)),
959
+ ...(changes?.plaza ?? []).map((key) => markers.plaza.get(key)),
960
+ ].filter(Boolean);
961
+ for (const point of points) {
962
+ for (let i = 0; i < 10; i += 1) {
963
+ // Around the building and from its middle, so the labels above it never hide the sparks.
964
+ const angle = (i / 10) * Math.PI * 2;
965
+ 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 });
966
+ spark.userData = { base: Math.max(1.2, point.y * 0.35), rise: Math.max(4, point.y * 0.8), delay: i * 220 };
967
+ scene.add(spark);
968
+ animated.sparkles.push(spark);
969
+ }
970
+ // A ring on the ground that pulses with the sparks.
971
+ 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 });
972
+ ring.rotation.x = -Math.PI / 2;
973
+ ring.renderOrder = 2;
974
+ ring.userData = { ring: true, base: 0.9, delay: 0 };
975
+ scene.add(ring);
976
+ animated.sparkles.push(ring);
977
+ }
978
+ animated.sparkleStart = null;
979
+ return points.length;
980
+ }
981
+
982
+ let lastSkyMinute = -1;
708
983
  let running = true;
709
984
  function frame(time) {
710
985
  if (!running) return;
@@ -735,7 +1010,35 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
735
1010
  renderer.domElement.style.cursor = hovered ? 'pointer' : '';
736
1011
  }
737
1012
  }
1013
+ const minute = Math.floor(Date.now() / 60000);
1014
+ if (skyMode === 'auto' && minute !== lastSkyMinute) {
1015
+ if (lastSkyMinute !== -1 && skyAt(new Date().getHours(), 'auto').phase !== sky.phase) applySky();
1016
+ lastSkyMinute = minute;
1017
+ }
1018
+ if (animated.sparkles.length) {
1019
+ // Timed by the clock, not the frame timestamp, which can lag behind on a slow renderer.
1020
+ const clock = performance.now();
1021
+ animated.sparkleStart ??= clock;
1022
+ const elapsed = clock - animated.sparkleStart;
1023
+ for (const spark of animated.sparkles) {
1024
+ const t = Math.max(0, elapsed - spark.userData.delay) / 2600;
1025
+ const cycle = t % 1;
1026
+ if (spark.userData.ring) {
1027
+ spark.scale.setScalar(reducedMotion ? 1 : 1 + cycle * 0.6);
1028
+ spark.material.opacity = elapsed > 9000 ? Math.max(0, 0.8 - (elapsed - 9000) / 1500) : reducedMotion ? 0.8 : 0.8 * (1 - cycle);
1029
+ continue;
1030
+ }
1031
+ spark.position.y = spark.userData.base + (reducedMotion ? 0.6 : cycle * spark.userData.rise);
1032
+ spark.material.opacity = elapsed > 9000 ? Math.max(0, 1 - (elapsed - 9000) / 1500) : reducedMotion ? 0.9 : 0.95 * (1 - cycle);
1033
+ }
1034
+ if (elapsed > 10500) {
1035
+ for (const spark of animated.sparkles) scene.remove(spark);
1036
+ animated.sparkles = [];
1037
+ }
1038
+ }
738
1039
  if (!reducedMotion) {
1040
+ if (animated.water) animated.water.offset.set((time / 90000) % 1, (time / 140000) % 1);
1041
+ for (const jet of animated.jets) jet.scale.y = 0.85 + 0.2 * Math.sin(time / 260);
739
1042
  const wave = (1 + Math.sin(time / 380)) / 2;
740
1043
  for (const beam of beacons) beam.material.opacity = 0.35 + 0.3 * wave;
741
1044
  for (const ring of glows) {
@@ -753,6 +1056,13 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
753
1056
  return {
754
1057
  buildPlaza,
755
1058
  buildCities,
1059
+ markChanges,
1060
+ /** The sky: 'auto' follows the hour; 'day', 'dusk' or 'night' fix it. Returns the phase shown. */
1061
+ setSky(mode) {
1062
+ skyMode = mode;
1063
+ applySky();
1064
+ return sky.phase;
1065
+ },
756
1066
  flyTo,
757
1067
  resetView,
758
1068
  clearSelection() {
@@ -799,12 +1109,26 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
799
1109
  root.append(wrap);
800
1110
  let scene = null;
801
1111
 
1112
+ // The sky follows the hour by default; the viewer can fix it to see the lit windows at night.
1113
+ const SKY_MODES = [['auto', '空:時刻'], ['day', '空:昼'], ['dusk', '空:夕方'], ['night', '空:夜']];
1114
+ let skyIndex = 0;
1115
+ const skyButton = workspaceButton(doc, {
1116
+ text: SKY_MODES[0][1],
1117
+ onClick: () => {
1118
+ skyIndex = (skyIndex + 1) % SKY_MODES.length;
1119
+ skyButton.textContent = SKY_MODES[skyIndex][1];
1120
+ scene?.setSky(SKY_MODES[skyIndex][0]);
1121
+ },
1122
+ });
802
1123
  const header = workspacePageHeader(doc, {
803
1124
  crumbs: page?.crumbs ?? ['あなたのBrainbase', '世界'],
804
1125
  title: '世界',
805
1126
  lead: '事業を都市、案件を区画、判断の種類を広場の建物として描いています。見るための画面で、ここからは何も書き換えません。',
806
1127
  source: page?.source ?? null,
807
- actions: [workspaceButton(doc, { text: '全体に戻る', onClick: () => { scene?.clearSelection(); showEmptyRail(); scene?.resetView(); } })],
1128
+ actions: [
1129
+ skyButton,
1130
+ workspaceButton(doc, { text: '全体に戻る', onClick: () => { scene?.clearSelection(); showEmptyRail(); scene?.resetView(); } }),
1131
+ ],
808
1132
  });
809
1133
  const notices = el(doc, 'div', { className: 'bb-world-hud', attrs: { 'aria-live': 'polite' } });
810
1134
  const legend = el(doc, 'div', { className: 'bb-world-legend', attrs: { 'aria-label': '凡例' } });
@@ -916,7 +1240,7 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
916
1240
  ['分類', kindText],
917
1241
  ['状態', statusText(business.status)],
918
1242
  ['コード', business.code],
919
- ['進行中の案件(都市の広さ)', `${measures.open}件${measures.finished ? `(完了・終了${measures.finished}件は更地)` : ''}`],
1243
+ ['進行中の案件(都市の広さ)', `${measures.open}件${measures.finished ? `(完了・終了${measures.finished}件は記念公園)` : ''}`],
920
1244
  ['最近30日の動き(高さ・明かり)', `決定${measures.decisions}件${judgmentText}`],
921
1245
  ]) }));
922
1246
  const ul = el(doc, 'ul', { className: 'bb-world-rail-list' });
@@ -1072,6 +1396,46 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
1072
1396
  scene = createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear: showEmptyRail });
1073
1397
  scene.buildPlaza(rows, rowItems, (row) => (rowItems.get(row.key) ?? []).filter((ref) => isWaiting(proofs.get(ref.decision_attempt_id))).length, Date.now());
1074
1398
  scene.buildCities(layoutWorld(businesses), placement.byBusiness);
1399
+ showChangesSinceLastVisit(note, businesses, rows);
1400
+ }
1401
+
1402
+ /**
1403
+ * What moved since this viewer last opened the world (a per-viewer convenience kept in this browser;
1404
+ * nothing is lost if the browser forgets it).
1405
+ */
1406
+ function showChangesSinceLastVisit(note, businesses, rows) {
1407
+ const key = 'brainbase.world.lastVisit';
1408
+ let lastVisit = null;
1409
+ try {
1410
+ lastVisit = globalThis.localStorage?.getItem(key) ?? null;
1411
+ } catch {
1412
+ lastVisit = null;
1413
+ }
1414
+ const changes = changesSince(lastVisit, businesses, placement.byBusiness, rows);
1415
+ try {
1416
+ globalThis.localStorage?.setItem(key, new Date().toISOString());
1417
+ } catch {
1418
+ // Without storage the world simply shows no marks next time.
1419
+ }
1420
+ if (!changes) {
1421
+ note('前回から', '初めての表示です。次に開いたときから、前回から動きがあった所に光の印を出します。');
1422
+ return;
1423
+ }
1424
+ const names = new Map(businesses.map((business) => [business.code, business.name]));
1425
+ const cityNames = changes.cities.map((code) => names.get(code) ?? code);
1426
+ const last = new Date(changes.since);
1427
+ const pad = (value) => String(value).padStart(2, '0');
1428
+ const when = `${pad(last.getMonth() + 1)}/${pad(last.getDate())} ${pad(last.getHours())}:${pad(last.getMinutes())}`;
1429
+ if (cityNames.length === 0 && changes.plaza.length === 0) {
1430
+ note('前回から', `前回(${when})から、新しい決定や判断はありません。`);
1431
+ return;
1432
+ }
1433
+ const parts = [
1434
+ cityNames.length ? `都市:${cityNames.join('・')}` : null,
1435
+ changes.plaza.length ? `広場の判断の種類${changes.plaza.length}つ` : null,
1436
+ ].filter(Boolean);
1437
+ note('前回から', `前回(${when})から動きがあった所に光の印を出しています。${parts.join('、')}。`, 'attention');
1438
+ scene?.markChanges(changes);
1075
1439
  }
1076
1440
 
1077
1441
  void load();