@unson/brainbase-mcp 0.15.4 → 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.
@@ -130,6 +130,7 @@ export function projectOrganizationGraph(records, owner) {
130
130
  ...(type === 'project' && summary ? { goal: summary } : {}),
131
131
  ...(type === 'project' && status ? { status } : {}),
132
132
  ...(type === 'project' && text(payload.kind) ? { kind: text(payload.kind) } : {}),
133
+ ...(type === 'decision' && text(payload.decided_at) ? { decided_at: text(payload.decided_at) } : {}),
133
134
  ...(type === 'project' ? projectPlacement(record, payload, catalogByCode) : {})
134
135
  }
135
136
  });
@@ -74,6 +74,15 @@ export interface WorldBusiness {
74
74
  /** Repository names the Graph registers for this project (`metadata.repositories`). */
75
75
  readonly repositories: readonly string[];
76
76
  readonly engagements: readonly WorldEngagement[];
77
+ /** How much happened lately: decisions of this business (or its engagements) in the last 30 days. */
78
+ readonly activity: WorldBusinessActivity;
79
+ }
80
+ export interface WorldBusinessActivity {
81
+ readonly window_days: 30;
82
+ /** Decisions decided in the window, belonging to this business by scope code or a `governs` edge. */
83
+ readonly decisions: number;
84
+ /** The newest decision date of this business at any time, or null when it has none dated. */
85
+ readonly latest_decision_at: string | null;
77
86
  }
78
87
  export interface WorldBusinessProjection {
79
88
  readonly businesses: readonly WorldBusiness[];
@@ -109,7 +118,9 @@ export type WorldBusinessesResponse = ({
109
118
  * Nothing here knows any organization's words: kinds and statuses are the
110
119
  * Graph's own values.
111
120
  */
112
- export declare function projectWorldFromGraph(graph: Pick<GraphFileV2, 'entities'>, now?: Date): WorldBusinessProjection;
121
+ export declare function projectWorldFromGraph(graph: Pick<GraphFileV2, 'entities'> & {
122
+ readonly edges?: GraphFileV2['edges'];
123
+ }, now?: Date): WorldBusinessProjection;
113
124
  /**
114
125
  * The vocabulary the world draws with: the host's kinds in their order, then
115
126
  * kinds the Graph uses that the host did not name (under their own value),
@@ -135,6 +146,7 @@ export declare function readWorldBusinesses(context: Pick<LocalWebModuleContext,
135
146
  */
136
147
  export declare function projectOrganizationWorld(records: {
137
148
  readonly projects: readonly unknown[];
149
+ readonly decisions?: readonly unknown[];
138
150
  readonly glossaryTerms?: readonly unknown[];
139
151
  }, options: {
140
152
  readonly server: string;
@@ -31,6 +31,7 @@ const DEFAULT_STATUSES = Object.freeze({
31
31
  const KIND_PALETTE = ['#1261ad', '#b07a1f', '#35684c', '#6b4fa0', '#a8433a', '#2f7d86', '#7a6a2b', '#5b6470'];
32
32
  const UNCLASSIFIED_COLOR = '#69746d';
33
33
  const HEX_COLOR = /^#[0-9a-f]{6}$/iu;
34
+ const ACTIVITY_WINDOW_DAYS = 30;
34
35
  function isRecord(value) {
35
36
  return value !== null && typeof value === 'object' && !Array.isArray(value);
36
37
  }
@@ -93,6 +94,46 @@ export function projectWorldFromGraph(graph, now = new Date()) {
93
94
  else
94
95
  unplaced.push(engagementOf(entity));
95
96
  }
97
+ // Which business a decision belongs to: its scope code (organization Graph) or the project it
98
+ // governs (local Graph), resolved to the city that project is or hangs under.
99
+ const cityOfKey = new Map();
100
+ for (const [cityId, { entity, engagements }] of cities) {
101
+ cityOfKey.set(entity.id, cityId);
102
+ const code = text(entity.metadata?.code);
103
+ if (code)
104
+ cityOfKey.set(code, cityId);
105
+ for (const engagement of engagements) {
106
+ cityOfKey.set(engagement.id, cityId);
107
+ if (engagement.code)
108
+ cityOfKey.set(engagement.code, cityId);
109
+ }
110
+ }
111
+ const governs = new Map();
112
+ for (const edge of graph.edges ?? []) {
113
+ if (edge.relation !== 'governs')
114
+ continue;
115
+ governs.set(edge.fromId, [...(governs.get(edge.fromId) ?? []), edge.toId]);
116
+ }
117
+ const windowStart = now.getTime() - ACTIVITY_WINDOW_DAYS * 24 * 60 * 60 * 1000;
118
+ const activity = new Map();
119
+ for (const decision of graph.entities) {
120
+ if (decision.type !== 'decision')
121
+ continue;
122
+ const at = text(decision.metadata?.decided_at) ?? text(decision.validFrom);
123
+ const time = at ? Date.parse(at) : Number.NaN;
124
+ if (!Number.isFinite(time))
125
+ continue;
126
+ const keys = [text(decision.metadata?.project_code), ...(governs.get(decision.id) ?? [])].filter((key) => key !== null);
127
+ const cityIds = new Set(keys.map((key) => cityOfKey.get(key)).filter((id) => id !== undefined));
128
+ for (const cityId of cityIds) {
129
+ const entry = activity.get(cityId) ?? { decisions: 0, latest: null };
130
+ if (time >= windowStart && time <= now.getTime())
131
+ entry.decisions += 1;
132
+ if (!entry.latest || time > Date.parse(entry.latest))
133
+ entry.latest = new Date(time).toISOString();
134
+ activity.set(cityId, entry);
135
+ }
136
+ }
96
137
  const businesses = [...cities.values()].map(({ entity, engagements }) => ({
97
138
  id: entity.id,
98
139
  code: text(entity.metadata?.code) ?? entity.id,
@@ -104,6 +145,11 @@ export function projectWorldFromGraph(graph, now = new Date()) {
104
145
  ? entity.metadata.repositories.map(text).filter((name) => name !== null)
105
146
  : [],
106
147
  engagements: engagements.sort((a, b) => a.name.localeCompare(b.name, 'ja')),
148
+ activity: {
149
+ window_days: ACTIVITY_WINDOW_DAYS,
150
+ decisions: activity.get(entity.id)?.decisions ?? 0,
151
+ latest_decision_at: activity.get(entity.id)?.latest ?? null,
152
+ },
107
153
  }));
108
154
  return {
109
155
  businesses: businesses.sort((a, b) => a.name.localeCompare(b.name, 'ja')),
@@ -231,7 +277,7 @@ export async function readWorldBusinesses(context, options = {}) {
231
277
  export function projectOrganizationWorld(records, options) {
232
278
  const now = options.now ?? new Date();
233
279
  const { graph } = projectOrganizationGraph({
234
- entities: { person: [], org: [], project: records.projects, decision: [], raci_assignment: [] },
280
+ entities: { person: [], org: [], project: records.projects, decision: (records.decisions ?? []), raci_assignment: [] },
235
281
  memberOf: [],
236
282
  assignedTo: [],
237
283
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unson/brainbase-mcp",
3
- "version": "0.15.4",
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": "1a548f70b900633c8cdcfe3c468de0a082b156fe"
361
+ "gitHead": "94fabc0ef66f6296d9f68fb5e4e54d87a63fc65b"
362
362
  }
@@ -64,3 +64,72 @@ export function districtLots(count, step = 2.5) {
64
64
  cols += 1;
65
65
  }
66
66
  }
67
+
68
+ const DAY_MS = 24 * 60 * 60 * 1000;
69
+
70
+ /**
71
+ * How a city reads (story-world-size-and-activity-v1): its size is the engagements still going, its
72
+ * height and lit windows are what happened in the last 30 days (decisions the Graph records for it,
73
+ * and judgments placed in it when the host has them). Finished engagements stay as empty lots but do
74
+ * not make the city bigger.
75
+ */
76
+ export function cityMeasures(business, { isFinished, judgments = [], now = Date.now() } = {}) {
77
+ const engagements = Array.isArray(business?.engagements) ? business.engagements : [];
78
+ const open = engagements.filter((engagement) => !isFinished?.(engagement.status)).length;
79
+ const since = now - (business?.activity?.window_days ?? 30) * DAY_MS;
80
+ const recentJudgments = judgments.filter((entry) => {
81
+ const at = Date.parse(entry?.item?.proof?.recorded_at ?? '');
82
+ return Number.isFinite(at) && at >= since && at <= now;
83
+ }).length;
84
+ const decisions = Number.isInteger(business?.activity?.decisions) ? business.activity.decisions : 0;
85
+ const recent = decisions + recentJudgments;
86
+ return {
87
+ open,
88
+ finished: engagements.length - open,
89
+ decisions,
90
+ judgments: recentJudgments,
91
+ recent,
92
+ towerHeight: 3.2 + Math.min(Math.log2(1 + recent) * 1.6, 5.5),
93
+ lit: recent > 0,
94
+ };
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 { 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,
@@ -135,7 +135,8 @@ export function layoutWorld(businesses) {
135
135
  previousKind = business.kind;
136
136
  }
137
137
  angle += slot;
138
- const n = business.engagements.length;
138
+ // The city's size is the engagements still going (finished ones stay as empty lots).
139
+ const n = business.engagements.filter((engagement) => !isFinished(engagement.status)).length;
139
140
  const size = 7 + 2.6 * Math.ceil(Math.sqrt(Math.max(n, 1)));
140
141
  const ring = CITY_RING + (index % 2) * 16 + size * 0.35;
141
142
  const city = { business, kind: business.kind, size, angle, x: Math.cos(angle) * ring, z: Math.sin(angle) * ring };
@@ -241,15 +242,29 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
241
242
  stage.prepend(renderer.domElement);
242
243
 
243
244
  const scene = new THREE.Scene();
244
- scene.background = canvasTexture(doc, 4, 256, (ctx, w, h) => {
245
- const gradient = ctx.createLinearGradient(0, 0, 0, h);
246
- gradient.addColorStop(0, '#dfeee6');
247
- gradient.addColorStop(0.55, '#eef4ef');
248
- gradient.addColorStop(1, '#f6f1e6');
249
- ctx.fillStyle = gradient;
250
- ctx.fillRect(0, 0, w, h);
251
- });
252
- 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);
253
268
 
254
269
  const camera = new THREE.OrthographicCamera(-50, 50, 50, -50, 0.1, 1000);
255
270
  const home = { position: new THREE.Vector3(90, 95, 90), target: new THREE.Vector3(0, 0, 0), zoom: 1 };
@@ -266,8 +281,9 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
266
281
  controls.minPolarAngle = Math.PI / 6;
267
282
  controls.target.copy(home.target);
268
283
 
269
- scene.add(new THREE.HemisphereLight(0xf4f8ff, 0xd8ccb2, 1.25));
270
- 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);
271
287
  sun.position.set(70, 110, 40);
272
288
  sun.castShadow = true;
273
289
  sun.shadow.mapSize.set(2048, 2048);
@@ -279,11 +295,10 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
279
295
  fill.position.set(-80, 60, -60);
280
296
  scene.add(fill);
281
297
 
282
- const ground = new THREE.Mesh(new THREE.CircleGeometry(230, 72), new THREE.MeshStandardMaterial({ color: 0xe4ebe1, roughness: 1 }));
283
- ground.rotation.x = -Math.PI / 2;
284
- ground.receiveShadow = true;
285
- ground.userData = { kind: 'ground' };
286
- 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: [] };
287
302
 
288
303
  const textures = { lit: windowTexture(doc, true), unlit: windowTexture(doc, false), glow: windowTexture(doc, true, true) };
289
304
  const pickables = [];
@@ -323,7 +338,8 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
323
338
  glow.repeat.copy(map.repeat);
324
339
  material.emissive = new THREE.Color(0xffc867);
325
340
  material.emissiveMap = glow;
326
- material.emissiveIntensity = 0.6;
341
+ material.emissiveIntensity = sky.glow;
342
+ litMaterials.add(material);
327
343
  }
328
344
  return mesh(new THREE.BoxGeometry(w, h, d), material, { y: h / 2 });
329
345
  }
@@ -349,14 +365,215 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
349
365
  scene.add(line);
350
366
  }
351
367
 
368
+ // Trees are many; they are drawn as two instanced meshes (trunks, crowns) once the world is built.
369
+ const treePlacements = [];
352
370
  function tree(x, z, scale = 1) {
353
- const group = new THREE.Group();
354
- group.add(mesh(new THREE.CylinderGeometry(0.12 * scale, 0.16 * scale, 0.7 * scale, 6), standard(0x8a6d4e), { y: 0.35 * scale }));
355
- group.add(mesh(new THREE.ConeGeometry(0.7 * scale, 1.6 * scale, 7), standard(0x6f9a72, { roughness: 0.95 }), { y: 1.4 * scale }));
356
- group.position.set(x, 0, z);
357
- 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;
358
432
  }
359
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
+
360
577
  // --- plaza ---------------------------------------------------------------
361
578
  function buildPlaza(rows, rowItems, waitingOf, now) {
362
579
  const plaza = new THREE.Group();
@@ -364,6 +581,20 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
364
581
  plaza.add(mesh(new THREE.CylinderGeometry(PLAZA_RADIUS, PLAZA_RADIUS + 0.4, 0.7, 64), standard(0xf3efe6, { roughness: 0.9 }), { y: 0.35 }));
365
582
  plaza.add(mesh(new THREE.TorusGeometry(PLAZA_RADIUS - 0.3, 0.08, 6, 64), standard(0xd8cfbd), { y: 0.72 }));
366
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
+ }
367
598
  scene.add(plaza);
368
599
  register(plaza, { kind: 'plaza' });
369
600
  addLabel('広場(判断)', new THREE.Vector3(0, 0.8, PLAZA_RADIUS + 2.8), 'is-plaza', { priority: LABEL_PRIORITY.plaza });
@@ -392,6 +623,7 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
392
623
  scene.add(group);
393
624
  register(group, { kind: 'judgment', row, items: rowItems.get(row.key) ?? [] });
394
625
  const top = 0.7 + 0.25 + height + 0.7;
626
+ markers.plaza.set(row.key, new THREE.Vector3(x, top, z));
395
627
  const waiting = waitingOf(row);
396
628
  if (waiting > 0) {
397
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 });
@@ -414,19 +646,21 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
414
646
  }
415
647
 
416
648
  // --- cities ----------------------------------------------------------------
417
- function landmark(form, color, height, status) {
649
+ function landmark(form, color, height, status, active = true) {
418
650
  const group = new THREE.Group();
419
651
  const phase = statusPhase(status);
420
652
  const concept = phase === 'concept';
653
+ // Windows are lit only when something happened in the last 30 days.
654
+ const lit = !concept && active;
421
655
  const tone = phase === 'maintenance' ? muted(color, 0.55) : new THREE.Color(color);
422
656
  const options = concept ? { transparent: true, opacity: 0.38 } : {};
423
657
  if (form === 'tower') {
424
- const base = texturedBlock(3, height, 3, tone, !concept, { roughness: 0.4, ...options });
425
- const mid = texturedBlock(2.2, height * 0.45, 2.2, tone, !concept, { roughness: 0.4, ...options });
658
+ const base = texturedBlock(3, height, 3, tone, lit, { roughness: 0.4, ...options });
659
+ const mid = texturedBlock(2.2, height * 0.45, 2.2, tone, lit, { roughness: 0.4, ...options });
426
660
  mid.position.y += height;
427
661
  group.add(base, mid, mesh(new THREE.CylinderGeometry(0.05, 0.05, 1.6, 6), standard(0x46524a), { y: height * 1.45 + 0.8 }));
428
662
  } else if (form === 'hall') {
429
- const hall = texturedBlock(4.4, height * 0.55, 3, tone, !concept, options);
663
+ const hall = texturedBlock(4.4, height * 0.55, 3, tone, lit, options);
430
664
  const roof = mesh(gableRoof(4.4, 3, 1.4), standard(muted(color, 0.15).multiplyScalar(0.85), options), { y: height * 0.55 });
431
665
  roof.rotation.y = Math.PI / 2;
432
666
  group.add(hall, roof);
@@ -434,7 +668,7 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
434
668
  group.add(mesh(new THREE.CylinderGeometry(1.9, 2.1, height * 0.5, 24), standard(tone, options), { y: height * 0.25 }));
435
669
  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 }));
436
670
  } else {
437
- group.add(texturedBlock(3.6, height * 0.75, 2.8, tone, !concept, options));
671
+ group.add(texturedBlock(3.6, height * 0.75, 2.8, tone, lit, options));
438
672
  group.add(mesh(new THREE.BoxGeometry(1.2, 0.6, 1), standard(0xb8c2bb), { y: height * 0.75 + 0.3 }));
439
673
  }
440
674
  if (concept) {
@@ -451,8 +685,11 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
451
685
  const status = engagement.status;
452
686
  const phase = statusPhase(status);
453
687
  if (phase === 'finished') {
454
- // A finished engagement leaves its lot: a low slab, no roof.
455
- 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 }));
456
693
  group.userData.finished = true;
457
694
  return group;
458
695
  }
@@ -480,8 +717,13 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
480
717
  return group;
481
718
  }
482
719
 
720
+ const markers = { cities: new Map(), plaza: new Map() };
483
721
  function buildCities({ cities, sectors, extent }, judgmentsByBusiness = new Map()) {
484
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);
485
727
  for (let i = 0; i < 12; i += 1) {
486
728
  const angle = (i / 12) * Math.PI * 2;
487
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);
@@ -492,15 +734,19 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
492
734
  const color = kindColor(kind);
493
735
  const start = new THREE.Vector3(x, 0, z).setLength(PLAZA_RADIUS + 4);
494
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);
495
739
  const group = new THREE.Group();
496
740
  group.position.set(x, 0, z);
497
741
  const plateColor = new THREE.Color(0xf7f8f4).lerp(new THREE.Color(color), 0.1);
498
742
  group.add(mesh(roundedPlate(size, size, 0.45, 1.6), standard(plateColor, { roughness: 0.95 })));
499
- const towerHeight = 3.2 + Math.min(business.engagements.length, 8) * 0.55;
500
- group.add(landmark(entry.form, color, towerHeight, business.status));
743
+ const measures = cityMeasures(business, { isFinished, judgments: judgmentsByBusiness.get(business.code) ?? [] });
744
+ const towerHeight = measures.towerHeight;
745
+ group.add(landmark(entry.form, color, towerHeight, business.status, measures.lit));
501
746
  scene.add(group);
502
747
  register(group, { kind: 'city', business });
503
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));
504
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 });
505
751
  cityLabel.node.style.borderColor = `${entry.color}80`;
506
752
  cityLabel.business = business;
@@ -539,6 +785,9 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
539
785
  tree(x + Math.cos(a) * size * 0.42, z + Math.sin(a) * size * 0.42, 0.8);
540
786
  }
541
787
  }
788
+ countryside(landRadius, cities, roads);
789
+ plantTrees();
790
+ placeLamps();
542
791
  for (const { kind, angle } of sectors) {
543
792
  addLabel(kindEntry(kind).label, new THREE.Vector3(Math.cos(angle) * 21, 0.2, Math.sin(angle) * 21), 'is-sector', { priority: LABEL_PRIORITY.sector });
544
793
  }
@@ -555,11 +804,13 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
555
804
  function setHighlight(group, on) {
556
805
  for (const entry of highlightable.get(group) ?? []) {
557
806
  if (on) {
807
+ highlightedMaterials.add(entry.material);
558
808
  entry.material.emissive.set(0x3d8f74);
559
809
  entry.material.emissiveIntensity = 0.35;
560
810
  } else {
811
+ highlightedMaterials.delete(entry.material);
561
812
  entry.material.emissive.copy(entry.base);
562
- entry.material.emissiveIntensity = entry.intensity;
813
+ entry.material.emissiveIntensity = litMaterials.has(entry.material) ? sky.glow : entry.intensity;
563
814
  }
564
815
  }
565
816
  }
@@ -701,6 +952,34 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
701
952
  }
702
953
  }
703
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;
704
983
  let running = true;
705
984
  function frame(time) {
706
985
  if (!running) return;
@@ -731,7 +1010,35 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
731
1010
  renderer.domElement.style.cursor = hovered ? 'pointer' : '';
732
1011
  }
733
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
+ }
734
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);
735
1042
  const wave = (1 + Math.sin(time / 380)) / 2;
736
1043
  for (const beam of beacons) beam.material.opacity = 0.35 + 0.3 * wave;
737
1044
  for (const ring of glows) {
@@ -749,6 +1056,13 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
749
1056
  return {
750
1057
  buildPlaza,
751
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
+ },
752
1066
  flyTo,
753
1067
  resetView,
754
1068
  clearSelection() {
@@ -795,12 +1109,26 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
795
1109
  root.append(wrap);
796
1110
  let scene = null;
797
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
+ });
798
1123
  const header = workspacePageHeader(doc, {
799
1124
  crumbs: page?.crumbs ?? ['あなたのBrainbase', '世界'],
800
1125
  title: '世界',
801
1126
  lead: '事業を都市、案件を区画、判断の種類を広場の建物として描いています。見るための画面で、ここからは何も書き換えません。',
802
1127
  source: page?.source ?? null,
803
- 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
+ ],
804
1132
  });
805
1133
  const notices = el(doc, 'div', { className: 'bb-world-hud', attrs: { 'aria-live': 'polite' } });
806
1134
  const legend = el(doc, 'div', { className: 'bb-world-legend', attrs: { 'aria-label': '凡例' } });
@@ -825,6 +1153,8 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
825
1153
  let placement = { byBusiness: new Map(), unplaced: [] };
826
1154
  /** Judgments the host kept back because they are not this organization's (organization mode only). */
827
1155
  let withheld = 0;
1156
+ /** Whether the host gave judgments at all (an organization web does not: they stay on each Mac). */
1157
+ let judgmentsConnected = false;
828
1158
 
829
1159
  function judgmentText(item, fallback) {
830
1160
  const textValue = item?.proof?.interruption?.question_display_text ?? item?.proof?.decision?.summary ?? fallback;
@@ -902,10 +1232,17 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
902
1232
  blocks.push(workspaceRailBlock(doc, { title: '登録', content: workspaceDefinition(doc, [['状態', statusText(data.engagement.status)], ['Graph ID', data.engagement.id]]) }));
903
1233
  blocks.push(detailBlock(data.engagement, '「プロジェクトと関係者」でこの案件を開く'));
904
1234
  } else {
905
- const open = business.engagements.filter((engagement) => !isFinished(engagement.status)).length;
1235
+ const measures = cityMeasures(business, { isFinished, judgments: placement.byBusiness.get(business.code) ?? [] });
906
1236
  const kind = kindEntry(business.kind);
907
1237
  const kindText = kind.definition ? `${kind.label}(${kind.definition})` : kind.label;
908
- blocks.push(workspaceRailBlock(doc, { title: '登録', content: workspaceDefinition(doc, [['分類', kindText], ['状態', statusText(business.status)], ['コード', business.code], ['案件', `${business.engagements.length}件(動いている${open}件)`]]) }));
1238
+ const judgmentText = judgmentsConnected ? `・判断${measures.judgments}件` : '(判断は未接続)';
1239
+ blocks.push(workspaceRailBlock(doc, { title: '登録', content: workspaceDefinition(doc, [
1240
+ ['分類', kindText],
1241
+ ['状態', statusText(business.status)],
1242
+ ['コード', business.code],
1243
+ ['進行中の案件(都市の広さ)', `${measures.open}件${measures.finished ? `(完了・終了${measures.finished}件は記念公園)` : ''}`],
1244
+ ['最近30日の動き(高さ・明かり)', `決定${measures.decisions}件${judgmentText}`],
1245
+ ]) }));
909
1246
  const ul = el(doc, 'ul', { className: 'bb-world-rail-list' });
910
1247
  for (const engagement of business.engagements) {
911
1248
  const li = el(doc, 'li', { text: `${engagement.name}(${statusText(engagement.status)})` });
@@ -1034,6 +1371,7 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
1034
1371
  else note('判断', `判断の記録を読めません(${reason})。0件ではありません。`, 'warning');
1035
1372
  } else {
1036
1373
  proofs = proofIndex(home);
1374
+ judgmentsConnected = true;
1037
1375
  const waiting = [...proofs.values()].filter(isWaiting).length;
1038
1376
  const places = placesResult.ok && placesResult.data?.status === 'available' ? placesResult.data.places : null;
1039
1377
  if (places) {
@@ -1058,6 +1396,46 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
1058
1396
  scene = createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear: showEmptyRail });
1059
1397
  scene.buildPlaza(rows, rowItems, (row) => (rowItems.get(row.key) ?? []).filter((ref) => isWaiting(proofs.get(ref.decision_attempt_id))).length, Date.now());
1060
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);
1061
1439
  }
1062
1440
 
1063
1441
  void load();