@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.
- package/dist/organization-graph-web.js +1 -0
- package/dist/world-extension.d.ts +13 -1
- package/dist/world-extension.js +47 -1
- package/package.json +2 -2
- package/ui/world/world-placement.js +69 -0
- package/ui/world/world-view.js +415 -37
|
@@ -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'
|
|
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;
|
package/dist/world-extension.js
CHANGED
|
@@ -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.
|
|
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": "
|
|
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
|
+
}
|
package/ui/world/world-view.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
270
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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 =
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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,
|
|
425
|
-
const mid = texturedBlock(2.2, height * 0.45, 2.2, tone,
|
|
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,
|
|
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,
|
|
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
|
|
455
|
-
group.add(mesh(new THREE.BoxGeometry(1.8, 0.
|
|
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
|
|
500
|
-
|
|
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: [
|
|
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
|
|
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
|
-
|
|
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();
|