@unson/brainbase-mcp 0.15.1 → 0.15.3

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.
@@ -0,0 +1,1092 @@
1
+ /*
2
+ * World view (ledger P17, adopted 2026-10-04): the home's upper layer.
3
+ *
4
+ * A read-only 3D projection of two existing sources:
5
+ * - projects from the same Graph the other screens read (the organization
6
+ * Graph under C1, else this Mac's Graph; GET /api/extensions/world/businesses),
7
+ * top-level projects as cities and their sub-projects as districts, in the
8
+ * Graph's own kinds and statuses as the host's vocabulary names them;
9
+ * - the delegation map of the value-proof home (GET /api/value-proofs/home),
10
+ * drawn as buildings in the plaza, one per judgment kind.
11
+ * Judgments do not record a project yet (W4), so every judgment building stands
12
+ * in the plaza; nothing is assigned to a city by guesswork (P9). The world
13
+ * never writes. Selecting something opens its facts in the rail and hands off
14
+ * to the existing screens.
15
+ */
16
+
17
+ import { THREE, MapControls } from './world-vendor.js';
18
+ import { groupJudgmentPlaces, UNPLACED_REASON_TEXT } from './world-placement.js';
19
+ import {
20
+ makeWorkspaceElement as el,
21
+ workspacePageHeader,
22
+ workspaceRailHead,
23
+ workspaceRailBlock,
24
+ workspaceDefinition,
25
+ workspaceDetailEmpty,
26
+ workspaceButton,
27
+ } from '../../workspace-kit.js';
28
+ import { organizationRecordAnchor, organizationRecordLink, organizationWebOrigin } from '../../graph-view-shared.js';
29
+
30
+ export const WORLD_VIEW_CONTRACT_VERSION = 'brainbase.world-view.v0';
31
+
32
+ const STATE_LABELS = Object.freeze({ delegated: '任せている', verifying: '確かめ中', returned: '戻している' });
33
+ const STATE_COLORS = Object.freeze({ delegated: 0x087d62, verifying: 0xc99a3a, returned: 0xd92335 });
34
+ const REASON_LABELS = Object.freeze({
35
+ routine_in_scope: '範囲内の定型作業',
36
+ irreversible_action: '取り消せない操作',
37
+ owner_value_choice: '本人の価値判断',
38
+ missing_authority: '権限が足りない',
39
+ required_input_unavailable: '必要な情報が無い',
40
+ evidenced_terminal_blocker: '進められない障害',
41
+ });
42
+ const BUSINESS_STATE_TEXT = Object.freeze({
43
+ not_initialized: 'Graphがまだありません',
44
+ migration_required: 'Graphの形式を移す必要があります',
45
+ unavailable: 'Graphを読めません',
46
+ });
47
+
48
+ /*
49
+ * The words the world draws with come from the host (`vocabulary` in the
50
+ * businesses answer): the Graph's own kinds and statuses, labelled by the
51
+ * owner's vocabulary when one is given. Nothing here names an organization's
52
+ * kinds. Until the answer arrives, everything is unclassified and active.
53
+ */
54
+ let worldVocabulary = { kinds: [], statuses: [] };
55
+ const kindEntry = (key) => worldVocabulary.kinds.find((entry) => entry.key === (key ?? null))
56
+ ?? { key: key ?? null, label: key ?? '分類なし', definition: null, form: 'office', color: '#69746d' };
57
+ const kindColor = (key) => Number.parseInt(kindEntry(key).color.slice(1), 16);
58
+ const statusEntry = (status) => worldVocabulary.statuses.find((entry) => entry.key === status) ?? null;
59
+ const statusPhase = (status) => statusEntry(status)?.phase ?? 'active';
60
+ const isFinished = (status) => statusPhase(status) === 'finished';
61
+ const SECTION_TEXT = Object.freeze({ needs_human: 'あなたに戻した', blocked: '止まった', continued: '聞かずに進めた', other: 'その他' });
62
+ const BASIS_TEXT = Object.freeze({ repository: '作業したリポジトリがこの事業に登録されている', code: '作業したリポジトリ名がこの事業のコードと同じ' });
63
+ const statusText = (status) => (status ? statusEntry(status)?.label ?? status : '未記録');
64
+
65
+ const PLAZA_RADIUS = 7;
66
+ const CITY_RING = 30;
67
+
68
+ function hashUnit(text) {
69
+ let h = 2166136261;
70
+ for (let i = 0; i < text.length; i += 1) {
71
+ h ^= text.charCodeAt(i);
72
+ h = Math.imul(h, 16777619);
73
+ }
74
+ return ((h >>> 0) % 10000) / 10000;
75
+ }
76
+
77
+ async function readJson(fetcher, path) {
78
+ try {
79
+ const response = await fetcher(path);
80
+ if (!response.ok) return { ok: false, error: `HTTP ${response.status}` };
81
+ return { ok: true, data: await response.json() };
82
+ } catch (error) {
83
+ return { ok: false, error: error instanceof Error ? error.message : 'request_failed' };
84
+ }
85
+ }
86
+
87
+ function rowLabel(row) {
88
+ if (row.source === 'unrecorded') return '種類の記録なし';
89
+ if (row.source === 'reason_code') return REASON_LABELS[row.label] ?? row.label ?? '理由不明';
90
+ return row.label ?? '名前なし';
91
+ }
92
+
93
+ /** Waiting for the owner now: returned to them, not answered, and the conversation did not move on. */
94
+ function isWaiting(item) {
95
+ return Boolean(item && item.section === 'needs_human' && !item.conversation_moved_on_at && !item.answer);
96
+ }
97
+
98
+ function proofIndex(home) {
99
+ const index = new Map();
100
+ const sections = home && typeof home.sections === 'object' ? home.sections : {};
101
+ for (const items of Object.values(sections)) {
102
+ if (!Array.isArray(items)) continue;
103
+ for (const item of items) {
104
+ const proof = item?.proof;
105
+ if (proof?.decision_attempt_id) index.set(proof.decision_attempt_id, item);
106
+ }
107
+ }
108
+ return index;
109
+ }
110
+
111
+ /**
112
+ * Lays businesses out around the plaza, grouped by kind. Every city gets an
113
+ * equal slice of the circle (with a small gap between kinds) and neighbours
114
+ * alternate between an inner and an outer ring so plates do not overlap.
115
+ * Returns the cities, the centre angle of each kind and the world's extent.
116
+ */
117
+ export function layoutWorld(businesses) {
118
+ const kinds = worldVocabulary.kinds.map((entry) => entry.key);
119
+ const order = (kind) => {
120
+ const index = kinds.indexOf(kind ?? null);
121
+ return index === -1 ? kinds.length : index;
122
+ };
123
+ const ordered = [...businesses].sort((a, b) => order(a.kind) - order(b.kind));
124
+ const kindCount = new Set(ordered.map((business) => business.kind)).size;
125
+ const gap = 0.35;
126
+ const slot = (Math.PI * 2 - gap * kindCount) / Math.max(ordered.length, 1);
127
+ const cities = [];
128
+ const kindAngles = new Map();
129
+ let angle = -Math.PI / 2 - slot / 2;
130
+ let previousKind = null;
131
+ let extent = PLAZA_RADIUS;
132
+ ordered.forEach((business, index) => {
133
+ if (business.kind !== previousKind) {
134
+ angle += gap;
135
+ previousKind = business.kind;
136
+ }
137
+ angle += slot;
138
+ const n = business.engagements.length;
139
+ const size = 7 + 2.6 * Math.ceil(Math.sqrt(Math.max(n, 1)));
140
+ const ring = CITY_RING + (index % 2) * 16 + size * 0.35;
141
+ const city = { business, kind: business.kind, size, angle, x: Math.cos(angle) * ring, z: Math.sin(angle) * ring };
142
+ cities.push(city);
143
+ if (!kindAngles.has(business.kind ?? null)) kindAngles.set(business.kind ?? null, []);
144
+ kindAngles.get(business.kind ?? null).push(angle);
145
+ extent = Math.max(extent, ring + size * 0.75);
146
+ });
147
+ const sectors = [...kindAngles].map(([kind, angles]) => ({ kind, angle: angles.reduce((sum, value) => sum + value, 0) / angles.length }));
148
+ return { cities, sectors, extent };
149
+ }
150
+
151
+ function makeLabel(doc, text, className) {
152
+ const label = el(doc, 'div', { className: `bb-world-label ${className ?? ''}`.trim(), text });
153
+ label.setAttribute('aria-hidden', 'true');
154
+ return label;
155
+ }
156
+
157
+ function webglAvailable(doc) {
158
+ try {
159
+ const canvas = doc.createElement('canvas');
160
+ return Boolean(canvas.getContext('webgl2') || canvas.getContext('webgl'));
161
+ } catch {
162
+ return false;
163
+ }
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Scene (M2). Everything drawn here is a projection of the loaded facts; the
168
+ // shapes only encode kind and state, never invented quantities.
169
+ // ---------------------------------------------------------------------------
170
+
171
+ const RECENT_MS = 24 * 60 * 60 * 1000;
172
+ const LABEL_PRIORITY = Object.freeze({ selected: 0, plaza: 1, city: 2, sector: 3, judgment: 4, engagement: 5 });
173
+
174
+ function canvasTexture(doc, width, height, draw) {
175
+ const canvas = doc.createElement('canvas');
176
+ canvas.width = width;
177
+ canvas.height = height;
178
+ draw(canvas.getContext('2d'), width, height);
179
+ const texture = new THREE.CanvasTexture(canvas);
180
+ texture.colorSpace = THREE.SRGBColorSpace;
181
+ return texture;
182
+ }
183
+
184
+ function windowTexture(doc, lit, glowOnly = false) {
185
+ const texture = canvasTexture(doc, 64, 64, (ctx, w, h) => {
186
+ ctx.fillStyle = glowOnly ? '#000000' : '#ffffff';
187
+ ctx.fillRect(0, 0, w, h);
188
+ ctx.fillStyle = glowOnly ? '#ffffff' : lit ? '#ffe2a0' : '#c6d3de';
189
+ for (let y = 8; y < h; y += 16) {
190
+ for (let x = 6; x < w; x += 14) ctx.fillRect(x, y, 8, 8);
191
+ }
192
+ });
193
+ texture.wrapS = THREE.RepeatWrapping;
194
+ texture.wrapT = THREE.RepeatWrapping;
195
+ return texture;
196
+ }
197
+
198
+ function roundedPlate(width, depth, height, radius) {
199
+ const shape = new THREE.Shape();
200
+ const w = width / 2;
201
+ const d = depth / 2;
202
+ shape.moveTo(-w + radius, -d);
203
+ shape.lineTo(w - radius, -d);
204
+ shape.quadraticCurveTo(w, -d, w, -d + radius);
205
+ shape.lineTo(w, d - radius);
206
+ shape.quadraticCurveTo(w, d, w - radius, d);
207
+ shape.lineTo(-w + radius, d);
208
+ shape.quadraticCurveTo(-w, d, -w, d - radius);
209
+ shape.lineTo(-w, -d + radius);
210
+ shape.quadraticCurveTo(-w, -d, -w + radius, -d);
211
+ const geometry = new THREE.ExtrudeGeometry(shape, { depth: height, bevelEnabled: true, bevelThickness: 0.12, bevelSize: 0.12, bevelSegments: 2 });
212
+ geometry.rotateX(-Math.PI / 2);
213
+ return geometry;
214
+ }
215
+
216
+ function gableRoof(width, depth, rise) {
217
+ const shape = new THREE.Shape();
218
+ shape.moveTo(-width / 2 - 0.1, 0);
219
+ shape.lineTo(width / 2 + 0.1, 0);
220
+ shape.lineTo(0, rise);
221
+ shape.lineTo(-width / 2 - 0.1, 0);
222
+ const geometry = new THREE.ExtrudeGeometry(shape, { depth: depth + 0.2, bevelEnabled: false });
223
+ geometry.translate(0, 0, -(depth + 0.2) / 2);
224
+ return geometry;
225
+ }
226
+
227
+ function muted(color, amount) {
228
+ return new THREE.Color(color).lerp(new THREE.Color(0xb9c2bb), amount);
229
+ }
230
+
231
+ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }) {
232
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
233
+ renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio ?? 1, 2));
234
+ renderer.shadowMap.enabled = true;
235
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
236
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
237
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
238
+ renderer.toneMappingExposure = 1.05;
239
+ renderer.domElement.className = 'bb-world-canvas';
240
+ renderer.domElement.tabIndex = 0;
241
+ stage.prepend(renderer.domElement);
242
+
243
+ 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);
253
+
254
+ const camera = new THREE.OrthographicCamera(-50, 50, 50, -50, 0.1, 1000);
255
+ const home = { position: new THREE.Vector3(90, 95, 90), target: new THREE.Vector3(0, 0, 0), zoom: 1 };
256
+ camera.position.copy(home.position);
257
+ camera.lookAt(home.target);
258
+
259
+ const controls = new MapControls(camera, renderer.domElement);
260
+ controls.enableDamping = !reducedMotion;
261
+ controls.dampingFactor = 0.08;
262
+ controls.screenSpacePanning = false;
263
+ controls.minZoom = 0.5;
264
+ controls.maxZoom = 7;
265
+ controls.maxPolarAngle = Math.PI / 2.5;
266
+ controls.minPolarAngle = Math.PI / 6;
267
+ controls.target.copy(home.target);
268
+
269
+ scene.add(new THREE.HemisphereLight(0xf4f8ff, 0xd8ccb2, 1.25));
270
+ const sun = new THREE.DirectionalLight(0xfff1dc, 2.7);
271
+ sun.position.set(70, 110, 40);
272
+ sun.castShadow = true;
273
+ sun.shadow.mapSize.set(2048, 2048);
274
+ sun.shadow.radius = 4;
275
+ sun.shadow.bias = -0.0004;
276
+ Object.assign(sun.shadow.camera, { left: -150, right: 150, top: 150, bottom: -150, near: 1, far: 420 });
277
+ scene.add(sun);
278
+ const fill = new THREE.DirectionalLight(0xdfe9ff, 0.6);
279
+ fill.position.set(-80, 60, -60);
280
+ scene.add(fill);
281
+
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);
287
+
288
+ const textures = { lit: windowTexture(doc, true), unlit: windowTexture(doc, false), glow: windowTexture(doc, true, true) };
289
+ const pickables = [];
290
+ const labels = [];
291
+ const beacons = [];
292
+ const glows = [];
293
+ const highlightable = new Map();
294
+
295
+ function addLabel(text, position, className, { minZoom = 0, priority = LABEL_PRIORITY.city, owner = null } = {}) {
296
+ const node = makeLabel(doc, text, className);
297
+ labelsLayer.append(node);
298
+ const label = { node, position: position.clone(), minZoom, priority, owner, size: null };
299
+ labels.push(label);
300
+ return label;
301
+ }
302
+
303
+ function mesh(geometry, material, { x = 0, y = 0, z = 0, shadow = true } = {}) {
304
+ const object = new THREE.Mesh(geometry, material);
305
+ object.position.set(x, y, z);
306
+ object.castShadow = shadow;
307
+ object.receiveShadow = true;
308
+ return object;
309
+ }
310
+
311
+ function standard(color, options = {}) {
312
+ return new THREE.MeshStandardMaterial({ color, roughness: 0.8, metalness: 0.03, ...options });
313
+ }
314
+
315
+ function texturedBlock(w, h, d, color, lit, options = {}) {
316
+ const map = (lit ? textures.lit : textures.unlit).clone();
317
+ map.needsUpdate = true;
318
+ map.repeat.set(Math.max(1, Math.round(w / 1.4)), Math.max(1, Math.round(h / 1.2)));
319
+ const material = standard(color, { map, ...options });
320
+ if (lit) {
321
+ const glow = textures.glow.clone();
322
+ glow.needsUpdate = true;
323
+ glow.repeat.copy(map.repeat);
324
+ material.emissive = new THREE.Color(0xffc867);
325
+ material.emissiveMap = glow;
326
+ material.emissiveIntensity = 0.6;
327
+ }
328
+ return mesh(new THREE.BoxGeometry(w, h, d), material, { y: h / 2 });
329
+ }
330
+
331
+ function register(group, data) {
332
+ group.userData = data;
333
+ pickables.push(group);
334
+ const materials = [];
335
+ group.traverse((child) => {
336
+ if (child.isMesh && child.material?.emissive) materials.push(child.material);
337
+ });
338
+ highlightable.set(group, materials.map((material) => ({ material, base: material.emissive.clone(), intensity: material.emissiveIntensity })));
339
+ }
340
+
341
+ function road(from, to, width = 1.9) {
342
+ const length = from.distanceTo(to);
343
+ const angle = -Math.atan2(to.z - from.z, to.x - from.x) + Math.PI / 2;
344
+ const surface = mesh(new THREE.PlaneGeometry(width, length), standard(0xd3d9d1, { roughness: 1 }), { x: (from.x + to.x) / 2, y: 0.03, z: (from.z + to.z) / 2, shadow: false });
345
+ surface.rotation.set(-Math.PI / 2, 0, angle);
346
+ scene.add(surface);
347
+ const line = mesh(new THREE.PlaneGeometry(0.12, length), new THREE.MeshBasicMaterial({ color: 0xf8faf7 }), { x: (from.x + to.x) / 2, y: 0.05, z: (from.z + to.z) / 2, shadow: false });
348
+ line.rotation.set(-Math.PI / 2, 0, angle);
349
+ scene.add(line);
350
+ }
351
+
352
+ 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);
358
+ }
359
+
360
+ // --- plaza ---------------------------------------------------------------
361
+ function buildPlaza(rows, rowItems, waitingOf, now) {
362
+ const plaza = new THREE.Group();
363
+ plaza.add(mesh(new THREE.CylinderGeometry(PLAZA_RADIUS + 2.2, PLAZA_RADIUS + 2.6, 0.3, 64), standard(0x9fbf9d, { roughness: 1 }), { y: 0.15 }));
364
+ plaza.add(mesh(new THREE.CylinderGeometry(PLAZA_RADIUS, PLAZA_RADIUS + 0.4, 0.7, 64), standard(0xf3efe6, { roughness: 0.9 }), { y: 0.35 }));
365
+ plaza.add(mesh(new THREE.TorusGeometry(PLAZA_RADIUS - 0.3, 0.08, 6, 64), standard(0xd8cfbd), { y: 0.72 }));
366
+ plaza.children[2].rotation.x = Math.PI / 2;
367
+ scene.add(plaza);
368
+ register(plaza, { kind: 'plaza' });
369
+ addLabel('広場(判断)', new THREE.Vector3(0, 0.8, PLAZA_RADIUS + 2.8), 'is-plaza', { priority: LABEL_PRIORITY.plaza });
370
+ for (let i = 0; i < 10; i += 1) {
371
+ const angle = (i / 10) * Math.PI * 2 + 0.3;
372
+ tree(Math.cos(angle) * (PLAZA_RADIUS + 1.3), Math.sin(angle) * (PLAZA_RADIUS + 1.3), 0.7);
373
+ }
374
+ rows.forEach((row, index) => {
375
+ const angle = (index / Math.max(rows.length, 1)) * Math.PI * 2 - Math.PI / 2;
376
+ const r = rows.length === 1 ? 0 : PLAZA_RADIUS * 0.56;
377
+ const total = row.counts.continued + row.counts.returned;
378
+ const height = 1.6 + Math.log2(1 + total) * 1.7;
379
+ const x = Math.cos(angle) * r;
380
+ const z = Math.sin(angle) * r;
381
+ const recent = Number.isFinite(Date.parse(row.latest_recorded_at)) && now - Date.parse(row.latest_recorded_at) < RECENT_MS;
382
+ const group = new THREE.Group();
383
+ group.position.set(x, 0.7, z);
384
+ const color = STATE_COLORS[row.state] ?? 0x929b95;
385
+ group.add(mesh(new THREE.BoxGeometry(2.6, 0.25, 2.6), standard(0xe6ded0), { y: 0.12 }));
386
+ const body = texturedBlock(2.1, height, 2.1, color, recent);
387
+ body.position.y += 0.25;
388
+ group.add(body);
389
+ const cap = texturedBlock(1.4, 0.7, 1.4, color, recent);
390
+ cap.position.y += 0.25 + height;
391
+ group.add(cap);
392
+ scene.add(group);
393
+ register(group, { kind: 'judgment', row, items: rowItems.get(row.key) ?? [] });
394
+ const top = 0.7 + 0.25 + height + 0.7;
395
+ const waiting = waitingOf(row);
396
+ if (waiting > 0) {
397
+ 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 });
398
+ scene.add(beam);
399
+ beacons.push(beam);
400
+ }
401
+ if (row.counts.corrected_or_reverted > 0) {
402
+ scene.add(mesh(new THREE.BoxGeometry(0.08, 1.4, 0.08), standard(0x1e2822), { x, y: top + 0.7, z }));
403
+ scene.add(mesh(new THREE.BoxGeometry(0.9, 0.55, 0.06), standard(0xd92335, { emissive: new THREE.Color(0x5a0b13) }), { x: x + 0.48, y: top + 1.1, z }));
404
+ }
405
+ if (recent) {
406
+ const ring = mesh(new THREE.RingGeometry(1.7, 1.95, 40), new THREE.MeshBasicMaterial({ color: 0xffc867, transparent: true, opacity: 0.6, side: THREE.DoubleSide }), { x, y: 0.74, z, shadow: false });
407
+ ring.rotation.x = -Math.PI / 2;
408
+ scene.add(ring);
409
+ glows.push(ring);
410
+ }
411
+ const stateText = row.state === 'returned' && waiting === 0 ? '戻した・会話で先に進んだ' : STATE_LABELS[row.state] ?? row.state;
412
+ addLabel(`${rowLabel(row)}・${stateText}`, new THREE.Vector3(x, top + 0.2, z), `is-judgment is-${row.state}${waiting > 0 ? ' is-waiting' : ''}`, { minZoom: 1.6, priority: waiting > 0 ? LABEL_PRIORITY.plaza : LABEL_PRIORITY.judgment, owner: group });
413
+ });
414
+ }
415
+
416
+ // --- cities ----------------------------------------------------------------
417
+ function landmark(form, color, height, status) {
418
+ const group = new THREE.Group();
419
+ const phase = statusPhase(status);
420
+ const concept = phase === 'concept';
421
+ const tone = phase === 'maintenance' ? muted(color, 0.55) : new THREE.Color(color);
422
+ const options = concept ? { transparent: true, opacity: 0.38 } : {};
423
+ 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 });
426
+ mid.position.y += height;
427
+ group.add(base, mid, mesh(new THREE.CylinderGeometry(0.05, 0.05, 1.6, 6), standard(0x46524a), { y: height * 1.45 + 0.8 }));
428
+ } else if (form === 'hall') {
429
+ const hall = texturedBlock(4.4, height * 0.55, 3, tone, !concept, options);
430
+ const roof = mesh(gableRoof(4.4, 3, 1.4), standard(muted(color, 0.15).multiplyScalar(0.85), options), { y: height * 0.55 });
431
+ roof.rotation.y = Math.PI / 2;
432
+ group.add(hall, roof);
433
+ } else if (form === 'dome') {
434
+ group.add(mesh(new THREE.CylinderGeometry(1.9, 2.1, height * 0.5, 24), standard(tone, options), { y: height * 0.25 }));
435
+ 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
+ } else {
437
+ group.add(texturedBlock(3.6, height * 0.75, 2.8, tone, !concept, options));
438
+ group.add(mesh(new THREE.BoxGeometry(1.2, 0.6, 1), standard(0xb8c2bb), { y: height * 0.75 + 0.3 }));
439
+ }
440
+ if (concept) {
441
+ // Scaffold outline: a business that is still a concept.
442
+ const outline = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(3.2, height * 1.2, 3.2)), new THREE.LineBasicMaterial({ color: 0x46524a }));
443
+ outline.position.y = height * 0.6;
444
+ group.add(outline);
445
+ }
446
+ return group;
447
+ }
448
+
449
+ function house(engagement, color) {
450
+ const group = new THREE.Group();
451
+ const status = engagement.status;
452
+ const phase = statusPhase(status);
453
+ 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 }));
456
+ group.userData.finished = true;
457
+ return group;
458
+ }
459
+ const h = 0.9 + hashUnit(engagement.id) * 1.1;
460
+ const tone = phase === 'maintenance' ? muted(color, 0.6) : new THREE.Color(0xf2efe8);
461
+ group.add(texturedBlock(1.6, h, 1.4, tone, phase === 'active'));
462
+ const roof = mesh(gableRoof(1.6, 1.4, 0.8), standard(phase === 'maintenance' ? muted(color, 0.6) : color), { y: h });
463
+ roof.rotation.y = hashUnit(`${engagement.id}r`) > 0.5 ? Math.PI / 2 : 0;
464
+ group.add(roof);
465
+ return group;
466
+ }
467
+
468
+ function judgmentHall(judgments) {
469
+ const group = new THREE.Group();
470
+ const waiting = judgments.some((entry) => isWaiting(entry.item));
471
+ const returned = judgments.some((entry) => entry.item?.section === 'needs_human');
472
+ const blocked = judgments.some((entry) => entry.item?.section === 'blocked');
473
+ const color = waiting || returned ? 0xd92335 : blocked ? 0xc99a3a : 0x087d62;
474
+ const h = 1 + Math.log2(1 + judgments.length) * 0.9;
475
+ group.add(mesh(new THREE.CylinderGeometry(1, 1.15, 0.3, 20), standard(0xe6ded0), { y: 0.15 }));
476
+ group.add(mesh(new THREE.CylinderGeometry(0.75, 0.8, h, 16), standard(waiting || returned ? muted(color, 0.25) : new THREE.Color(0xf4f1ea)), { y: 0.3 + h / 2 }));
477
+ group.add(mesh(new THREE.ConeGeometry(1.05, 0.9, 16), standard(color), { y: 0.3 + h + 0.45 }));
478
+ group.userData.top = 0.3 + h + 0.9;
479
+ group.userData.waiting = waiting;
480
+ return group;
481
+ }
482
+
483
+ function buildCities({ cities, sectors, extent }, judgmentsByBusiness = new Map()) {
484
+ fitExtent(extent);
485
+ for (let i = 0; i < 12; i += 1) {
486
+ const angle = (i / 12) * Math.PI * 2;
487
+ 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);
488
+ }
489
+ for (const city of cities) {
490
+ const { business, size, x, z, kind } = city;
491
+ const entry = kindEntry(kind);
492
+ const color = kindColor(kind);
493
+ const start = new THREE.Vector3(x, 0, z).setLength(PLAZA_RADIUS + 4);
494
+ road(start, new THREE.Vector3(x, 0, z));
495
+ const group = new THREE.Group();
496
+ group.position.set(x, 0, z);
497
+ const plateColor = new THREE.Color(0xf7f8f4).lerp(new THREE.Color(color), 0.1);
498
+ 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));
501
+ scene.add(group);
502
+ register(group, { kind: 'city', business });
503
+ const top = entry.form === 'tower' ? towerHeight * 1.45 + 1.8 : towerHeight + 1;
504
+ 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
+ cityLabel.node.style.borderColor = `${entry.color}80`;
506
+ cityLabel.business = business;
507
+ const n = business.engagements.length;
508
+ const cols = Math.max(2, Math.ceil(Math.sqrt(n + 1)));
509
+ const step = 2.5;
510
+ const cells = [];
511
+ for (let row = 0; row < cols; row += 1) {
512
+ for (let col = 0; col < cols; col += 1) {
513
+ const cx = -((cols - 1) * step) / 2 + col * step;
514
+ const cz = -((cols - 1) * step) / 2 + row * step;
515
+ if (Math.abs(cx) < 2.2 && Math.abs(cz) < 2.2) continue; // the landmark's lot
516
+ cells.push([cx, cz]);
517
+ }
518
+ }
519
+ cells.sort((a, b) => Math.hypot(...a) - Math.hypot(...b));
520
+ business.engagements.forEach((engagement, index) => {
521
+ const [cx, cz] = cells[index % cells.length];
522
+ const lot = house(engagement, color);
523
+ const finished = lot.userData.finished === true;
524
+ lot.position.set(x + cx * (size / (cols * step + 2)), 0.45, z + cz * (size / (cols * step + 2)));
525
+ scene.add(lot);
526
+ register(lot, { kind: 'engagement', engagement, business, finished });
527
+ const label = addLabel(finished ? `${engagement.name}(${statusText(engagement.status)})` : engagement.name, new THREE.Vector3(lot.position.x, 2.6, lot.position.z), `is-engagement${finished ? ' is-finished' : ''}`, { minZoom: 3.4, priority: LABEL_PRIORITY.engagement, owner: lot });
528
+ label.business = business;
529
+ });
530
+ const judgments = judgmentsByBusiness.get(business.code) ?? [];
531
+ if (judgments.length > 0) {
532
+ const hall = judgmentHall(judgments);
533
+ hall.position.set(x + size * 0.34, 0.45, z - size * 0.34);
534
+ scene.add(hall);
535
+ register(hall, { kind: 'cityJudgments', business, judgments });
536
+ const top = 0.45 + hall.userData.top;
537
+ if (hall.userData.waiting) {
538
+ const beam = mesh(new THREE.CylinderGeometry(0.22, 0.22, 16, 12), new THREE.MeshBasicMaterial({ color: 0xd92335, transparent: true, opacity: 0.55 }), { x: hall.position.x, y: top + 8, z: hall.position.z, shadow: false });
539
+ scene.add(beam);
540
+ beacons.push(beam);
541
+ }
542
+ const hallLabel = addLabel(`判断 ${judgments.length}件`, new THREE.Vector3(hall.position.x, top + 0.2, hall.position.z), `is-hall${hall.userData.waiting ? ' is-waiting' : ''}`, { minZoom: 1.3, priority: LABEL_PRIORITY.judgment, owner: hall });
543
+ hallLabel.business = business;
544
+ }
545
+ const treeCount = 2 + Math.floor(hashUnit(business.id) * 3);
546
+ for (let t = 0; t < treeCount; t += 1) {
547
+ const a = hashUnit(`${business.id}${t}`) * Math.PI * 2;
548
+ tree(x + Math.cos(a) * size * 0.42, z + Math.sin(a) * size * 0.42, 0.8);
549
+ }
550
+ }
551
+ for (const { kind, angle } of sectors) {
552
+ addLabel(kindEntry(kind).label, new THREE.Vector3(Math.cos(angle) * 21, 0.2, Math.sin(angle) * 21), 'is-sector', { priority: LABEL_PRIORITY.sector });
553
+ }
554
+ }
555
+
556
+ // --- selection & hover -------------------------------------------------
557
+ const selectionRing = mesh(new THREE.RingGeometry(1, 1.035, 96), new THREE.MeshBasicMaterial({ color: 0x087d62, transparent: true, opacity: 0.9, side: THREE.DoubleSide }), { y: 0.08, shadow: false });
558
+ selectionRing.rotation.x = -Math.PI / 2;
559
+ selectionRing.visible = false;
560
+ scene.add(selectionRing);
561
+ let selected = null;
562
+ let hovered = null;
563
+
564
+ function setHighlight(group, on) {
565
+ for (const entry of highlightable.get(group) ?? []) {
566
+ if (on) {
567
+ entry.material.emissive.set(0x3d8f74);
568
+ entry.material.emissiveIntensity = 0.35;
569
+ } else {
570
+ entry.material.emissive.copy(entry.base);
571
+ entry.material.emissiveIntensity = entry.intensity;
572
+ }
573
+ }
574
+ }
575
+
576
+ function select(group) {
577
+ selected = group;
578
+ if (!group) {
579
+ selectionRing.visible = false;
580
+ return;
581
+ }
582
+ const box3 = new THREE.Box3().setFromObject(group);
583
+ const sizeVector = box3.getSize(new THREE.Vector3());
584
+ const radius = Math.max(sizeVector.x, sizeVector.z) * 0.62 + 0.4;
585
+ selectionRing.scale.set(radius, radius, 1);
586
+ selectionRing.position.set((box3.min.x + box3.max.x) / 2, group.userData.kind === 'judgment' ? 0.76 : 0.6, (box3.min.z + box3.max.z) / 2);
587
+ selectionRing.visible = true;
588
+ }
589
+
590
+ const raycaster = new THREE.Raycaster();
591
+ const pointer = new THREE.Vector2();
592
+ function pickAt(clientX, clientY) {
593
+ const rect = renderer.domElement.getBoundingClientRect();
594
+ pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1;
595
+ pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1;
596
+ raycaster.setFromCamera(pointer, camera);
597
+ for (const hit of raycaster.intersectObjects(pickables, true)) {
598
+ let object = hit.object;
599
+ while (object && !pickables.includes(object)) object = object.parent;
600
+ if (object) return object;
601
+ }
602
+ return null;
603
+ }
604
+
605
+ let downAt = null;
606
+ let pendingHover = null;
607
+ renderer.domElement.addEventListener('pointerdown', (event) => {
608
+ downAt = { x: event.clientX, y: event.clientY };
609
+ });
610
+ renderer.domElement.addEventListener('pointermove', (event) => {
611
+ pendingHover = { x: event.clientX, y: event.clientY };
612
+ });
613
+ renderer.domElement.addEventListener('pointerleave', () => {
614
+ pendingHover = null;
615
+ if (hovered) setHighlight(hovered, false);
616
+ hovered = null;
617
+ });
618
+ renderer.domElement.addEventListener('pointerup', (event) => {
619
+ if (!downAt || Math.hypot(event.clientX - downAt.x, event.clientY - downAt.y) > 5) return;
620
+ const group = pickAt(event.clientX, event.clientY);
621
+ if (group) {
622
+ select(group);
623
+ const center = new THREE.Box3().setFromObject(group).getCenter(new THREE.Vector3());
624
+ onPick(group.userData, center);
625
+ } else {
626
+ select(null);
627
+ onClear();
628
+ }
629
+ });
630
+ renderer.domElement.addEventListener('keydown', (event) => {
631
+ if (event.key === 'Escape') {
632
+ select(null);
633
+ onClear();
634
+ resetView();
635
+ }
636
+ });
637
+
638
+ let flight = null;
639
+ function flyTo(target, zoom) {
640
+ const offset = camera.position.clone().sub(controls.target);
641
+ const to = { target: new THREE.Vector3(target.x, 0, target.z), position: new THREE.Vector3(target.x, 0, target.z).add(offset), zoom };
642
+ if (reducedMotion) {
643
+ controls.target.copy(to.target);
644
+ camera.position.copy(to.position);
645
+ camera.zoom = zoom;
646
+ camera.updateProjectionMatrix();
647
+ return;
648
+ }
649
+ controls.enabled = false;
650
+ flight = { from: { target: controls.target.clone(), position: camera.position.clone(), zoom: camera.zoom }, to, t: 0 };
651
+ }
652
+
653
+ function resetView() {
654
+ flyTo(home.target, home.zoom);
655
+ }
656
+
657
+ // --- frame loop -----------------------------------------------------------
658
+ const projected = new THREE.Vector3();
659
+ let worldExtent = 60;
660
+ let width = 0;
661
+ let height = 0;
662
+ function resize() {
663
+ const rect = stage.getBoundingClientRect();
664
+ if (rect.width === 0 || rect.height === 0) return;
665
+ width = rect.width;
666
+ height = rect.height;
667
+ renderer.setSize(width, height, false);
668
+ const aspect = width / height;
669
+ const span = Math.max((worldExtent * 0.9) / Math.min(aspect, 1.25), 24);
670
+ Object.assign(camera, { left: -span * aspect, right: span * aspect, top: span, bottom: -span });
671
+ camera.updateProjectionMatrix();
672
+ }
673
+ function fitExtent(extent) {
674
+ worldExtent = extent;
675
+ if (width > 0) resize();
676
+ }
677
+ const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(resize) : null;
678
+ observer?.observe(stage);
679
+
680
+ function placeLabels() {
681
+ const placed = [];
682
+ const selectedBusiness = selected?.userData?.business ?? null;
683
+ const visibleLabels = [];
684
+ for (const label of labels) {
685
+ projected.copy(label.position).project(camera);
686
+ const onScreen = projected.z < 1 && projected.x > -1.05 && projected.x < 1.05 && projected.y > -1.05 && projected.y < 1.05;
687
+ const focused = selectedBusiness && label.business === selectedBusiness;
688
+ const zoomOk = camera.zoom >= label.minZoom || (focused && label.priority === LABEL_PRIORITY.engagement && camera.zoom >= 1.8);
689
+ if (!onScreen || !zoomOk) {
690
+ label.node.hidden = true;
691
+ continue;
692
+ }
693
+ label.screen = { x: ((projected.x + 1) / 2) * width, y: ((1 - projected.y) / 2) * height };
694
+ label.rank = label.owner === selected ? LABEL_PRIORITY.selected : focused && label.priority === LABEL_PRIORITY.city ? LABEL_PRIORITY.selected : label.priority;
695
+ visibleLabels.push(label);
696
+ }
697
+ visibleLabels.sort((a, b) => a.rank - b.rank);
698
+ for (const label of visibleLabels) {
699
+ label.node.hidden = false;
700
+ label.size ??= { w: label.node.offsetWidth, h: label.node.offsetHeight };
701
+ const rect = { x: label.screen.x - label.size.w / 2, y: label.screen.y - label.size.h, w: label.size.w, h: label.size.h };
702
+ const clash = placed.some((other) => rect.x < other.x + other.w + 2 && rect.x + rect.w + 2 > other.x && rect.y < other.y + other.h + 2 && rect.y + rect.h + 2 > other.y);
703
+ if (clash && label.rank > LABEL_PRIORITY.selected) {
704
+ label.node.hidden = true;
705
+ continue;
706
+ }
707
+ placed.push(rect);
708
+ label.node.style.transform = `translate(${Math.round(rect.x)}px, ${Math.round(rect.y)}px)`;
709
+ label.node.classList.toggle('is-selected', label.rank === LABEL_PRIORITY.selected);
710
+ }
711
+ }
712
+
713
+ let running = true;
714
+ function frame(time) {
715
+ if (!running) return;
716
+ requestAnimationFrame(frame);
717
+ if (!stage.isConnected || stage.offsetParent === null) return;
718
+ if (width === 0) resize();
719
+ if (flight) {
720
+ flight.started ??= time;
721
+ flight.t = Math.min((time - flight.started) / 850, 1);
722
+ const e = 1 - (1 - flight.t) ** 3;
723
+ controls.target.lerpVectors(flight.from.target, flight.to.target, e);
724
+ camera.position.lerpVectors(flight.from.position, flight.to.position, e);
725
+ camera.zoom = flight.from.zoom + (flight.to.zoom - flight.from.zoom) * e;
726
+ camera.updateProjectionMatrix();
727
+ camera.lookAt(controls.target);
728
+ if (flight.t >= 1) {
729
+ flight = null;
730
+ controls.enabled = true;
731
+ }
732
+ }
733
+ if (pendingHover) {
734
+ const group = pickAt(pendingHover.x, pendingHover.y);
735
+ pendingHover = null;
736
+ if (group !== hovered) {
737
+ if (hovered) setHighlight(hovered, false);
738
+ hovered = group;
739
+ if (hovered) setHighlight(hovered, true);
740
+ renderer.domElement.style.cursor = hovered ? 'pointer' : '';
741
+ }
742
+ }
743
+ if (!reducedMotion) {
744
+ const wave = (1 + Math.sin(time / 380)) / 2;
745
+ for (const beam of beacons) beam.material.opacity = 0.35 + 0.3 * wave;
746
+ for (const ring of glows) {
747
+ ring.material.opacity = 0.25 + 0.45 * (1 + Math.sin(time / 900)) / 2;
748
+ ring.scale.setScalar(1 + 0.08 * Math.sin(time / 900));
749
+ }
750
+ if (selectionRing.visible) selectionRing.material.opacity = 0.6 + 0.35 * wave;
751
+ }
752
+ if (!flight) controls.update();
753
+ renderer.render(scene, camera);
754
+ placeLabels();
755
+ }
756
+ requestAnimationFrame(frame);
757
+
758
+ return {
759
+ buildPlaza,
760
+ buildCities,
761
+ flyTo,
762
+ resetView,
763
+ clearSelection() {
764
+ select(null);
765
+ },
766
+ dispose() {
767
+ running = false;
768
+ observer?.disconnect();
769
+ controls.dispose();
770
+ renderer.dispose();
771
+ },
772
+ };
773
+ }
774
+
775
+ function renderFallbackList(doc, root, businesses, rows) {
776
+ const list = el(doc, 'div', { className: 'bb-world-fallback' });
777
+ list.append(el(doc, 'h2', { text: '事業' }));
778
+ const ul = el(doc, 'ul');
779
+ for (const business of businesses) {
780
+ ul.append(el(doc, 'li', { text: `${business.name}(${kindEntry(business.kind).label}・案件${business.engagements.length}件)` }));
781
+ }
782
+ list.append(ul, el(doc, 'h2', { text: '判断の種類' }));
783
+ const ol = el(doc, 'ul');
784
+ for (const row of rows) ol.append(el(doc, 'li', { text: `${rowLabel(row)}:${STATE_LABELS[row.state] ?? row.state}` }));
785
+ list.append(ol);
786
+ root.append(list);
787
+ }
788
+
789
+ /** Why the host has no judgments to give, in the words the screen shows. */
790
+ const JUDGMENT_UNAVAILABLE_TEXT = Object.freeze({
791
+ judgment_journal_not_connected: '判断の記録は各メンバーのMacにあり、組織版には送らないため未接続です',
792
+ });
793
+
794
+ /**
795
+ * @param {object} options
796
+ * @param {(project: { id: string, code?: string | null }) => string} [options.projectHref] Where a business or
797
+ * engagement opens in the host's プロジェクトと関係者. The local web opens `#projects?project=<id>`; an
798
+ * organization web passes its own route.
799
+ */
800
+ export function createWorldView({ root, rail, page, document: explicitDocument, fetcher, organizationGraph = null, projectHref = (project) => `#projects?project=${encodeURIComponent(project.id)}` }) {
801
+ const doc = explicitDocument ?? globalThis.document;
802
+ const reducedMotion = Boolean(globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches);
803
+ const wrap = el(doc, 'div', { className: 'bb-world' });
804
+ root.append(wrap);
805
+ let scene = null;
806
+
807
+ const header = workspacePageHeader(doc, {
808
+ crumbs: page?.crumbs ?? ['あなたのBrainbase', '世界'],
809
+ title: '世界',
810
+ lead: '事業を都市、案件を区画、判断の種類を広場の建物として描いています。見るための画面で、ここからは何も書き換えません。',
811
+ source: page?.source ?? null,
812
+ actions: [workspaceButton(doc, { text: '全体に戻る', onClick: () => { scene?.clearSelection(); showEmptyRail(); scene?.resetView(); } })],
813
+ });
814
+ const notices = el(doc, 'div', { className: 'bb-world-hud', attrs: { 'aria-live': 'polite' } });
815
+ const legend = el(doc, 'div', { className: 'bb-world-legend', attrs: { 'aria-label': '凡例' } });
816
+ for (const [state, label] of Object.entries(STATE_LABELS)) {
817
+ const chip = el(doc, 'span', { className: `bb-world-chip is-${state}`, text: label });
818
+ legend.append(chip);
819
+ }
820
+ legend.append(el(doc, 'span', { className: 'bb-world-chip is-flag', text: '赤い旗=任せたのに訂正・取り消し' }));
821
+ const stage = el(doc, 'div', { className: 'bb-world-stage' });
822
+ const labelsLayer = el(doc, 'div', { className: 'bb-world-labels' });
823
+ stage.append(labelsLayer, notices, legend);
824
+ wrap.append(header, stage);
825
+
826
+ function showRail(nodes) {
827
+ if (!rail) return;
828
+ rail.replaceChildren(...nodes);
829
+ }
830
+ const showEmptyRail = () => showRail([workspaceDetailEmpty(doc, { mark: '◇', title: '都市や建物を選ぶと、ここに出ます', text: 'ドラッグで移動、ホイールで寄る・離れる、右ドラッグで回転します。Escで全体に戻ります。' })]);
831
+ showEmptyRail();
832
+
833
+ let proofs = new Map();
834
+ let placement = { byBusiness: new Map(), unplaced: [] };
835
+ /** Judgments the host kept back because they are not this organization's (organization mode only). */
836
+ let withheld = 0;
837
+
838
+ function judgmentText(item, fallback) {
839
+ const textValue = item?.proof?.interruption?.question_display_text ?? item?.proof?.decision?.summary ?? fallback;
840
+ const suffix = item?.conversation_moved_on_at ? '(会話で先に進んだ)' : item?.answer ? '(回答済み)' : isWaiting(item) ? '(あなたを待っています)' : '';
841
+ return `${textValue}${suffix}`;
842
+ }
843
+
844
+ /** Descends to the same judgment in 今日 (the shell opens `#today?decision=`). */
845
+ function decisionLink(decisionAttemptId) {
846
+ const link = el(doc, 'a', { className: 'bb-world-rail-link', text: '「今日」でこの判断を開く', attrs: { href: `#today?decision=${encodeURIComponent(decisionAttemptId)}` } });
847
+ return link;
848
+ }
849
+
850
+ /** Descends to the same project in プロジェクトと関係者 (the shell opens `#projects?project=`). */
851
+ function projectLink(project, label) {
852
+ return el(doc, 'a', { className: 'bb-world-rail-link', text: label, attrs: { href: projectHref(project) } });
853
+ }
854
+
855
+ /**
856
+ * The same project in the organization web, where it is corrected (Inspector UX-20261004-01). Only when
857
+ * the host reads the organization Graph and was told where its web is.
858
+ */
859
+ function organizationLink(project) {
860
+ const origin = organizationWebOrigin(organizationGraph);
861
+ if (!origin) return null;
862
+ // A business without its own code carries its id as its code; that is not a code the organization web knows.
863
+ const code = project.code && project.code !== project.id ? project.code : null;
864
+ const target = organizationRecordLink(origin, 'projects', { id: project.id, type: 'project', metadata: { code } });
865
+ return target ? organizationRecordAnchor(doc, target, 'bb-world-rail-link') : null;
866
+ }
867
+
868
+ /** 詳しく見る: プロジェクトと関係者 here, and the organization web when there is one. */
869
+ function detailBlock(project, label) {
870
+ const content = [projectLink(project, label), organizationLink(project)].filter(Boolean);
871
+ return workspaceRailBlock(doc, { title: '詳しく見る', content });
872
+ }
873
+
874
+ function judgmentList(entries, describe) {
875
+ const ul = el(doc, 'ul', { className: 'bb-world-rail-list' });
876
+ const sorted = [...entries].sort((a, b) => String(b.item?.proof?.recorded_at ?? '').localeCompare(String(a.item?.proof?.recorded_at ?? '')));
877
+ for (const entry of sorted.slice(0, 15)) {
878
+ const li = el(doc, 'li');
879
+ li.append(el(doc, 'span', { text: judgmentText(entry.item, entry.decision_attempt_id) }));
880
+ li.append(el(doc, 'small', { className: 'bb-world-rail-note', text: describe(entry) }));
881
+ li.append(decisionLink(entry.decision_attempt_id));
882
+ ul.append(li);
883
+ }
884
+ return ul;
885
+ }
886
+
887
+ function cityJudgmentBlock(business) {
888
+ const entries = placement.byBusiness.get(business.code) ?? [];
889
+ if (entries.length === 0) {
890
+ return workspaceRailBlock(doc, { title: 'この事業の判断', content: { text: 'この事業のリポジトリで作業した判断の記録はありません。' } });
891
+ }
892
+ return workspaceRailBlock(doc, {
893
+ title: `この事業の判断(${entries.length}件)`,
894
+ content: [
895
+ judgmentList(entries, (entry) => `${SECTION_TEXT[entry.item?.section] ?? '状態不明'}・作業場所 ${entry.workspace}(${BASIS_TEXT[entry.basis]})`),
896
+ { text: '作業した場所で置いています。判断の対象とは違うことがあります。', className: 'bb-world-rail-caveat' },
897
+ ],
898
+ });
899
+ }
900
+
901
+ function onPick(data, position) {
902
+ if (data.kind === 'city' || data.kind === 'engagement') {
903
+ const business = data.business;
904
+ scene?.flyTo(new THREE.Vector3(position.x, 0, position.z), data.kind === "engagement" ? 3.6 : 2.8);
905
+ const blocks = [workspaceRailHead(doc, {
906
+ kicker: data.kind === 'engagement' ? `${business.name} の案件` : `事業・${kindEntry(business.kind).label}`,
907
+ title: data.kind === 'engagement' ? data.engagement.name : business.name,
908
+ lead: data.kind === 'engagement' ? data.engagement.summary ?? undefined : business.purpose ?? undefined,
909
+ })];
910
+ if (data.kind === 'engagement') {
911
+ blocks.push(workspaceRailBlock(doc, { title: '登録', content: workspaceDefinition(doc, [['状態', statusText(data.engagement.status)], ['Graph ID', data.engagement.id]]) }));
912
+ blocks.push(detailBlock(data.engagement, '「プロジェクトと関係者」でこの案件を開く'));
913
+ } else {
914
+ const open = business.engagements.filter((engagement) => !isFinished(engagement.status)).length;
915
+ const kind = kindEntry(business.kind);
916
+ const kindText = kind.definition ? `${kind.label}(${kind.definition})` : kind.label;
917
+ blocks.push(workspaceRailBlock(doc, { title: '登録', content: workspaceDefinition(doc, [['分類', kindText], ['状態', statusText(business.status)], ['コード', business.code], ['案件', `${business.engagements.length}件(動いている${open}件)`]]) }));
918
+ const ul = el(doc, 'ul', { className: 'bb-world-rail-list' });
919
+ for (const engagement of business.engagements) {
920
+ const li = el(doc, 'li', { text: `${engagement.name}(${statusText(engagement.status)})` });
921
+ li.append(projectLink(engagement, '開く'));
922
+ ul.append(li);
923
+ }
924
+ blocks.push(workspaceRailBlock(doc, { title: '案件(区画)', content: business.engagements.length ? ul : { text: '登録された案件はありません' } }));
925
+ blocks.push(detailBlock(business, '「プロジェクトと関係者」でこの事業を開く'));
926
+ }
927
+ blocks.push(cityJudgmentBlock(business));
928
+ showRail(blocks);
929
+ return;
930
+ }
931
+ if (data.kind === 'cityJudgments') {
932
+ const business = data.business;
933
+ scene?.flyTo(new THREE.Vector3(position.x, 0, position.z), 3.2);
934
+ showRail([
935
+ workspaceRailHead(doc, { kicker: `${business.name} の判断所`, title: `判断 ${data.judgments.length}件`, lead: business.purpose ?? undefined }),
936
+ cityJudgmentBlock(business),
937
+ workspaceRailBlock(doc, { title: '詳しく見る', content: workspaceButton(doc, { text: '「今日」で開く', variant: 'primary', onClick: () => { globalThis.location.hash = '#today'; } }) }),
938
+ ]);
939
+ return;
940
+ }
941
+ if (data.kind === 'judgment') {
942
+ const { row, items } = data;
943
+ scene?.flyTo(new THREE.Vector3(0, 0, 0), 2.2);
944
+ const blocks = [workspaceRailHead(doc, {
945
+ kicker: `判断の種類・${STATE_LABELS[row.state] ?? row.state}`,
946
+ title: rowLabel(row),
947
+ sub: `最新 ${row.latest_recorded_at?.slice(0, 16).replace('T', ' ') ?? '不明'}`,
948
+ })];
949
+ blocks.push(workspaceRailBlock(doc, {
950
+ title: '件数',
951
+ content: workspaceDefinition(doc, [
952
+ ['聞かずに進めた', `${row.counts.continued}件`],
953
+ ['あなたに戻した', `${row.counts.returned}件`],
954
+ ['評価済み', `${row.counts.rated}件`],
955
+ ['訂正・取り消し', `${row.counts.corrected_or_reverted}件`],
956
+ ]),
957
+ }));
958
+ const ul = el(doc, 'ul', { className: 'bb-world-rail-list' });
959
+ for (const ref of items.slice(0, 12)) {
960
+ const li = el(doc, 'li', { text: judgmentText(proofs.get(ref.decision_attempt_id), ref.decision_attempt_id) });
961
+ li.append(decisionLink(ref.decision_attempt_id));
962
+ ul.append(li);
963
+ }
964
+ blocks.push(workspaceRailBlock(doc, { title: '判断(新しい順)', content: items.length ? ul : { text: '判断はありません' } }));
965
+ blocks.push(workspaceRailBlock(doc, {
966
+ title: '詳しく見る',
967
+ content: workspaceButton(doc, { text: '「今日」で開く', variant: 'primary', onClick: () => { globalThis.location.hash = '#today'; } }),
968
+ }));
969
+ showRail(blocks);
970
+ return;
971
+ }
972
+ if (data.kind === 'plaza') {
973
+ scene?.flyTo(new THREE.Vector3(0, 0, 0), 2.2);
974
+ const blocks = [workspaceRailHead(doc, {
975
+ kicker: '広場',
976
+ title: '判断の種類(全体)',
977
+ lead: '建物1つが判断の種類1つで、全事業の判断をまとめています。高さは件数、色は任せ方の状態です。事業ごとの判断は、各都市の判断所にあります。',
978
+ })];
979
+ blocks.push(workspaceRailBlock(doc, {
980
+ title: `どの事業にも置けない判断(${placement.unplaced.length}件)`,
981
+ content: placement.unplaced.length
982
+ ? judgmentList(placement.unplaced, (entry) => `${UNPLACED_REASON_TEXT[entry.reason] ?? entry.reason}${entry.workspace ? `(作業場所 ${entry.workspace})` : ''}`)
983
+ : { text: 'ありません' },
984
+ }));
985
+ if (withheld) {
986
+ blocks.push(workspaceRailBlock(doc, {
987
+ title: `この世界に出していない判断(${withheld}件)`,
988
+ content: { text: 'この組織の事業で作業したと確かめられない判断です。ほかの会社の仕事が混ざるため、内容も作業場所も出しません。「今日」では見られます。' },
989
+ }));
990
+ }
991
+ showRail(blocks);
992
+ }
993
+ }
994
+
995
+ async function load() {
996
+ const businessesResult = await readJson(fetcher, '/api/extensions/world/businesses');
997
+ // A world drawn from an organization's Graph shows only that organization's judgments: the host keeps
998
+ // the others (another company's work, or unknown) back and sends only how many it kept back.
999
+ const organizationScoped = businessesResult.ok && businessesResult.data?.status === 'ok'
1000
+ && businessesResult.data.source?.authority === 'organization_graph';
1001
+ let homeResult;
1002
+ let placesResult;
1003
+ if (organizationScoped) {
1004
+ const scoped = await readJson(fetcher, '/api/extensions/world/organization-judgments');
1005
+ const available = scoped.ok && scoped.data?.status === 'available';
1006
+ withheld = available ? scoped.data.withheld : 0;
1007
+ homeResult = available ? { ok: true, data: scoped.data.home } : scoped.ok ? { ok: true, data: { status: 'unavailable', reason: scoped.data?.reason } } : scoped;
1008
+ placesResult = available ? { ok: true, data: { status: 'available', places: scoped.data.places } } : { ok: true, data: { status: 'unavailable', reason: scoped.data?.reason } };
1009
+ } else {
1010
+ [homeResult, placesResult] = await Promise.all([
1011
+ readJson(fetcher, '/api/value-proofs/home'),
1012
+ readJson(fetcher, '/api/extensions/world/judgment-places'),
1013
+ ]);
1014
+ }
1015
+ notices.replaceChildren();
1016
+ const note = (label, textValue, tone = 'info') => {
1017
+ const line = el(doc, 'p', { className: `bb-world-hud-line is-${tone}`, attrs: tone === 'info' ? {} : { role: 'alert' } });
1018
+ line.append(el(doc, 'strong', { text: label }), el(doc, 'span', { text: textValue }));
1019
+ notices.append(line);
1020
+ };
1021
+ const businessPayload = businessesResult.ok ? businessesResult.data : null;
1022
+ const businesses = businessPayload?.status === 'ok' ? businessPayload.businesses : [];
1023
+ if (businessPayload?.status === 'ok' && businessPayload.vocabulary) worldVocabulary = businessPayload.vocabulary;
1024
+ if (!businessesResult.ok || businessPayload?.status !== 'ok') {
1025
+ const reason = businessesResult.ok ? `${BUSINESS_STATE_TEXT[businessPayload?.status] ?? '状態不明'}(${businessPayload?.reason ?? '理由不明'})` : `取得に失敗しました(${businessesResult.error})`;
1026
+ note('事業', `${reason}。事業は0件ではありません。`, 'warning');
1027
+ } else {
1028
+ const extra = [];
1029
+ if (businessPayload.unplaced.length) extra.push(`親の事業がGraphに無い案件${businessPayload.unplaced.length}件は描いていません`);
1030
+ if (businessPayload.excluded.inactive) extra.push(`終了・統合済み${businessPayload.excluded.inactive}件は除外`);
1031
+ const sourceText = businessPayload.source.authority === 'organization_graph' ? `組織のGraph(${businessPayload.source.server})` : 'このMacのGraph';
1032
+ const unclassified = businesses.filter((business) => !business.kind).length;
1033
+ if (unclassified) extra.push(`分類の無い事業${unclassified}件は「分類なし」として描いています`);
1034
+ if (businessPayload.vocabulary?.terms === 'unavailable') extra.push('組織の用語を読めないため、分類の名前は語彙ファイルか値のままです');
1035
+ note('事業', `${sourceText}の事業${businesses.length}件・案件${businesses.reduce((sum, business) => sum + business.engagements.length, 0)}件。${extra.join('。')}${extra.length ? '。' : ''}`);
1036
+ }
1037
+ const home = homeResult.ok ? homeResult.data : null;
1038
+ const readable = home?.status === 'available' && Array.isArray(home.delegation_map?.rows);
1039
+ const rows = readable ? home.delegation_map.rows : [];
1040
+ if (!homeResult.ok || !readable) {
1041
+ const reason = homeResult.ok ? home?.reason ?? home?.status ?? '状態不明' : homeResult.error;
1042
+ if (JUDGMENT_UNAVAILABLE_TEXT[reason]) note('判断', `${JUDGMENT_UNAVAILABLE_TEXT[reason]}。事業だけを描いています。`);
1043
+ else note('判断', `判断の記録を読めません(${reason})。0件ではありません。`, 'warning');
1044
+ } else {
1045
+ proofs = proofIndex(home);
1046
+ const waiting = [...proofs.values()].filter(isWaiting).length;
1047
+ const places = placesResult.ok && placesResult.data?.status === 'available' ? placesResult.data.places : null;
1048
+ if (places) {
1049
+ // Only judgments the home lists are placed, so the world and 今日 count the same judgments.
1050
+ const known = places.filter((place) => proofs.has(place.decision_attempt_id)).map((place) => ({ ...place, item: proofs.get(place.decision_attempt_id) }));
1051
+ placement = groupJudgmentPlaces(known, businesses);
1052
+ const placed = known.length - placement.unplaced.length;
1053
+ const kept = withheld ? `この組織の事業で作業したと確かめられない判断${withheld}件は、この世界には出していません(「今日」で見られます)。` : '';
1054
+ note('判断', `${home.delegation_map.judged}件のうち${placed}件を作業した事業の判断所に、${placement.unplaced.length}件は事業が分からないため広場に。${kept}今あなたを待っている判断${waiting}件${waiting ? '(光の柱)' : ''}。`, waiting ? 'attention' : 'info');
1055
+ } else {
1056
+ note('判断', `${home.delegation_map.judged}件。作業した場所を読めないため、事業には置かず広場の種類別だけで描いています(${placesResult.ok ? placesResult.data?.reason ?? '理由不明' : placesResult.error})。今あなたを待っている判断${waiting}件。`, 'warning');
1057
+ }
1058
+ }
1059
+ const rowItems = new Map(rows.map((row) => [row.key, Array.isArray(row.items) ? row.items : []]));
1060
+
1061
+ if (!webglAvailable(doc)) {
1062
+ note('表示', 'この環境では3Dを表示できないため、一覧で出しています。', 'warning');
1063
+ stage.hidden = true;
1064
+ renderFallbackList(doc, wrap, businesses, rows);
1065
+ return;
1066
+ }
1067
+ scene = createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear: showEmptyRail });
1068
+ scene.buildPlaza(rows, rowItems, (row) => (rowItems.get(row.key) ?? []).filter((ref) => isWaiting(proofs.get(ref.decision_attempt_id))).length, Date.now());
1069
+ scene.buildCities(layoutWorld(businesses), placement.byBusiness);
1070
+ }
1071
+
1072
+ void load();
1073
+ return {
1074
+ dispose() {
1075
+ scene?.dispose();
1076
+ },
1077
+ };
1078
+ }
1079
+
1080
+ export const screen = Object.freeze({
1081
+ id: 'world',
1082
+ label: '世界',
1083
+ usesGraph: false,
1084
+ rail: true,
1085
+ source: '組織のGraph・判断journal(読み取りのみ)',
1086
+ mount(container, context) {
1087
+ const root = context.document.createElement('div');
1088
+ root.className = 'bb-shell-page';
1089
+ container.append(root);
1090
+ return createWorldView({ root, rail: context.rail, page: context.page, document: context.document, fetcher: context.fetcher, organizationGraph: context.organizationGraph ?? null });
1091
+ },
1092
+ });