@unson/brainbase-mcp 0.20.0 → 0.21.1

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.
@@ -12,10 +12,20 @@
12
12
  * in the plaza; nothing is assigned to a city by guesswork (P9). The world
13
13
  * never writes. Selecting something opens its facts in the rail and hands off
14
14
  * to the existing screens.
15
+ *
16
+ * Inside a city (story-world-work-sites-and-gaps-v1) the host's Canonical Tasks
17
+ * stand as small work sites around the plate, read only when the city is
18
+ * opened. A site's wall is its recorded state; a sign says something about it
19
+ * cannot be seen from the records (an empty assignee field, no source link, a
20
+ * review date passed); a dashed ring says the text names a person the assignee
21
+ * field does not. Above the cities, a sign counts the open work with such a
22
+ * gap, and a grey sign says the work could not be read (never zero).
15
23
  */
16
24
 
17
25
  import { THREE, MapControls } from './world-vendor.js';
18
26
  import { changesSince, cityMeasures, districtLots, groupJudgmentPlaces, skyAt, UNPLACED_REASON_TEXT } from './world-placement.js';
27
+ import { createDistrictView } from './world-district.js';
28
+ import { WORK_STATES, workCityBlocks, workPickBlock, workSign, workSiteBlocks } from './world-work-rail.js';
19
29
  import {
20
30
  makeWorkspaceElement as el,
21
31
  workspacePageHeader,
@@ -169,7 +179,15 @@ function webglAvailable(doc) {
169
179
  // ---------------------------------------------------------------------------
170
180
 
171
181
  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 });
182
+ const LABEL_PRIORITY = Object.freeze({ selected: 0, plaza: 1, city: 2, sector: 3, judgment: 4, engagement: 5, site: 6 });
183
+ const WORK_COLORS = Object.freeze(Object.fromEntries(WORK_STATES.map((state) => [state.key, state.color])));
184
+ const GAP_SHORT_TEXT = Object.freeze({
185
+ assignee_unlinked_mentioned: '担当欄に未接続(本文に人物)',
186
+ assignee_unrecorded: '担当の記録なし',
187
+ outcome_unlinked: '成果物の記録が未接続',
188
+ source_unlinked: '出典リンクなし',
189
+ review_overdue: '見直し超過',
190
+ });
173
191
 
174
192
  function canvasTexture(doc, width, height, draw) {
175
193
  const canvas = doc.createElement('canvas');
@@ -364,7 +382,7 @@ function muted(color, amount) {
364
382
  return new THREE.Color(color).lerp(new THREE.Color(0xb9c2bb), amount);
365
383
  }
366
384
 
367
- function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }) {
385
+ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear, onEscape }) {
368
386
  const renderer = new THREE.WebGLRenderer({ antialias: true });
369
387
  renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio ?? 1, 2));
370
388
  renderer.shadowMap.enabled = true;
@@ -1042,6 +1060,8 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
1042
1060
  }
1043
1061
 
1044
1062
  const markers = { cities: new Map(), plaza: new Map() };
1063
+ /** code → { city (layout), group, label } */
1064
+ const cityIndex = new Map();
1045
1065
  function buildCities({ cities, sectors, extent }, judgmentsByBusiness = new Map()) {
1046
1066
  fitExtent(extent);
1047
1067
  const landRadius = buildTerrain(extent);
@@ -1074,8 +1094,10 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
1074
1094
  register(group, { kind: 'city', business });
1075
1095
  const top = entry.form === 'tower' ? towerHeight * 1.45 + 1.8 : towerHeight + 1;
1076
1096
  markers.cities.set(business.code, new THREE.Vector3(x, top, z));
1097
+ cityIndex.set(business.code, { city, group, label: null });
1077
1098
  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 });
1078
1099
  cityLabel.node.style.borderColor = `${entry.color}80`;
1100
+ cityIndex.get(business.code).label = cityLabel;
1079
1101
  cityLabel.business = business;
1080
1102
  const n = business.engagements.length;
1081
1103
  const step = 2.5;
@@ -1152,7 +1174,8 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
1152
1174
  const sizeVector = box3.getSize(new THREE.Vector3());
1153
1175
  const radius = Math.max(sizeVector.x, sizeVector.z) * 0.62 + 0.4;
1154
1176
  selectionRing.scale.set(radius, radius, 1);
1155
- 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);
1177
+ const ringY = group.userData.kind === 'judgment' ? 0.76 : group.userData.kind === 'site' ? 0.1 : 0.6;
1178
+ selectionRing.position.set((box3.min.x + box3.max.x) / 2, ringY, (box3.min.z + box3.max.z) / 2);
1156
1179
  selectionRing.visible = true;
1157
1180
  }
1158
1181
 
@@ -1197,11 +1220,8 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
1197
1220
  }
1198
1221
  });
1199
1222
  renderer.domElement.addEventListener('keydown', (event) => {
1200
- if (event.key === 'Escape') {
1201
- select(null);
1202
- onClear();
1203
- resetView();
1204
- }
1223
+ // Escape goes one level up; the view decides what that level is.
1224
+ if (event.key === 'Escape') onEscape();
1205
1225
  });
1206
1226
 
1207
1227
  let flight = null;
@@ -1223,6 +1243,130 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
1223
1243
  flyTo(home.target, home.zoom);
1224
1244
  }
1225
1245
 
1246
+ // --- the work inside a city (sites, signs, roads between businesses) -------
1247
+ const signTextures = new Map();
1248
+ function signTexture(kind) {
1249
+ if (!signTextures.has(kind)) {
1250
+ signTextures.set(kind, canvasTexture(doc, 64, 64, (ctx, w, h) => {
1251
+ ctx.fillStyle = kind === 'unreadable' ? '#8a9096' : '#e3a21a';
1252
+ ctx.fillRect(0, 0, w, h);
1253
+ ctx.fillStyle = '#ffffff';
1254
+ ctx.font = 'bold 44px sans-serif';
1255
+ ctx.textAlign = 'center';
1256
+ ctx.textBaseline = 'middle';
1257
+ ctx.fillText(kind === 'unreadable' ? '…' : '?', w / 2, h / 2 + 2);
1258
+ }));
1259
+ }
1260
+ return signTextures.get(kind);
1261
+ }
1262
+ /** A small sign on a pole: amber '?' (something cannot be seen) or grey '…' (could not be read). */
1263
+ function sign(kind, height = 1.2, boardSize = 0.5) {
1264
+ const group = new THREE.Group();
1265
+ group.add(mesh(new THREE.CylinderGeometry(0.035, 0.045, height, 6), standard(0x4b524e), { y: height / 2 }));
1266
+ const face = new THREE.MeshBasicMaterial({ map: signTexture(kind) });
1267
+ const edge = standard(0xf3efe6);
1268
+ group.add(mesh(new THREE.BoxGeometry(boardSize, boardSize, 0.06), [edge, edge, edge, edge, face, face], { y: height + boardSize / 2 - 0.05 }));
1269
+ return group;
1270
+ }
1271
+
1272
+ const citySigns = [];
1273
+ /** Signs over the cities from the work summary; a city without one has no gap, or its work is not connected. */
1274
+ function setWorkSigns(summaries) {
1275
+ for (const post of citySigns.splice(0)) scene.remove(post);
1276
+ for (const [code, entry] of cityIndex) {
1277
+ const summary = summaries?.[code];
1278
+ entry.label?.node.querySelector('.bb-world-badge')?.remove();
1279
+ if (entry.label) entry.label.size = null;
1280
+ const signal = workSign(summary);
1281
+ if (!signal) continue;
1282
+ const { text } = signal;
1283
+ const unreadable = signal.kind === 'unreadable';
1284
+ const { city } = entry;
1285
+ const post = sign(unreadable ? 'unreadable' : 'check', 2.2, 0.9);
1286
+ post.position.set(city.x - city.size / 2 + 0.6, 0.45, city.z + city.size / 2 - 0.6);
1287
+ scene.add(post);
1288
+ citySigns.push(post);
1289
+ if (entry.label) entry.label.node.append(el(doc, 'span', { className: `bb-world-badge ${unreadable ? 'is-unreadable' : 'is-check'}`, text }));
1290
+ }
1291
+ }
1292
+
1293
+ const workLayer = { group: null, code: null, labels: [] };
1294
+ function clearWork() {
1295
+ if (workLayer.group) scene.remove(workLayer.group);
1296
+ for (const label of workLayer.labels) {
1297
+ label.node.remove();
1298
+ const index = labels.indexOf(label);
1299
+ if (index !== -1) labels.splice(index, 1);
1300
+ }
1301
+ workLayer.group = null;
1302
+ workLayer.code = null;
1303
+ workLayer.labels = [];
1304
+ }
1305
+
1306
+ /** An arc between two cities: solid for a recorded relation, dashed for an inferred one. */
1307
+ function cityLink(from, to, link) {
1308
+ const a = new THREE.Vector3(from.x, 1.2, from.z);
1309
+ const b = new THREE.Vector3(to.x, 1.2, to.z);
1310
+ const mid = a.clone().add(b).multiplyScalar(0.5);
1311
+ mid.y = 4 + a.distanceTo(b) * 0.12;
1312
+ const curve = new THREE.QuadraticBezierCurve3(a, mid, b);
1313
+ const material = new THREE.MeshBasicMaterial({ color: link === 'recorded' ? 0x1261ad : 0x7a6a9e, transparent: true, opacity: 0.85, fog: false });
1314
+ const group = new THREE.Group();
1315
+ if (link === 'recorded') {
1316
+ group.add(mesh(new THREE.TubeGeometry(curve, 48, 0.12, 6), material, { shadow: false }));
1317
+ } else {
1318
+ const segments = 28;
1319
+ for (let i = 0; i < segments; i += 2) {
1320
+ const piece = new THREE.CatmullRomCurve3([curve.getPoint(i / segments), curve.getPoint((i + 0.5) / segments), curve.getPoint((i + 1) / segments)]);
1321
+ group.add(mesh(new THREE.TubeGeometry(piece, 4, 0.1, 6), material, { shadow: false }));
1322
+ }
1323
+ }
1324
+ return { group, mid: curve.getPoint(0.5) };
1325
+ }
1326
+
1327
+ /** Draws the roads from one city to the businesses its work relates to (its work itself is inside its district). */
1328
+ function showWork(code, work) {
1329
+ clearWork();
1330
+ const entry = cityIndex.get(code);
1331
+ if (!entry || work?.status !== 'ok') return;
1332
+ const { city } = entry;
1333
+ const layer = new THREE.Group();
1334
+ // Roads to other businesses: recorded relations solid; decisions found only by their titles dashed.
1335
+ const links = new Map();
1336
+ for (const relation of work.relations) {
1337
+ if (!relation.business_code || relation.link === 'unreadable') continue;
1338
+ const key = `${relation.business_code}|${relation.link}`;
1339
+ links.set(key, [...(links.get(key) ?? []), relation.label]);
1340
+ }
1341
+ for (const policy of work.purpose.policies) {
1342
+ if (policy.link !== 'inferred' || !policy.scope_code || policy.scope_code === code || !cityIndex.has(policy.scope_code)) continue;
1343
+ const key = `${policy.scope_code}|inferred`;
1344
+ links.set(key, links.get(key) ?? []);
1345
+ }
1346
+ for (const [key, texts] of links) {
1347
+ const [otherCode, link] = key.split('|');
1348
+ const other = cityIndex.get(otherCode);
1349
+ if (!other) continue;
1350
+ const inferredDecisions = link === 'inferred' ? work.purpose.policies.filter((policy) => policy.link === 'inferred' && policy.scope_code === otherCode).length : 0;
1351
+ const words = [...texts, ...(inferredDecisions ? [`${other.city.business.name}の範囲の決定${inferredDecisions}件が題名で${city.business.name}を指す(推定)`] : [])];
1352
+ const { group, mid } = cityLink(other.city, city, link);
1353
+ layer.add(group);
1354
+ const label = addLabel(`${link === 'recorded' ? '━' : '┅'} ${words.join('・')}`, mid, `is-link is-${link}`, { minZoom: 0.8, priority: LABEL_PRIORITY.judgment });
1355
+ workLayer.labels.push(label);
1356
+ }
1357
+ scene.add(layer);
1358
+ workLayer.group = layer;
1359
+ workLayer.code = code;
1360
+ }
1361
+
1362
+ /** Selects a city by its code, as a click would (without the host's callbacks). */
1363
+ function selectKey({ code }) {
1364
+ const group = cityIndex.get(code)?.group;
1365
+ if (!group) return null;
1366
+ select(group);
1367
+ return { data: group.userData, center: new THREE.Box3().setFromObject(group).getCenter(new THREE.Vector3()) };
1368
+ }
1369
+
1226
1370
  // --- frame loop -----------------------------------------------------------
1227
1371
  const projected = new THREE.Vector3();
1228
1372
  let worldExtent = 60;
@@ -1255,7 +1399,7 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
1255
1399
  const onScreen = projected.z < 1 && projected.x > -1.05 && projected.x < 1.05 && projected.y > -1.05 && projected.y < 1.05;
1256
1400
  const focused = selectedBusiness && label.business === selectedBusiness;
1257
1401
  const zoomOk = camera.zoom >= label.minZoom || (focused && label.priority === LABEL_PRIORITY.engagement && camera.zoom >= 1.8);
1258
- if (!onScreen || !zoomOk) {
1402
+ if (!onScreen || !zoomOk || label.hiddenByFocus) {
1259
1403
  label.node.hidden = true;
1260
1404
  continue;
1261
1405
  }
@@ -1308,9 +1452,11 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
1308
1452
 
1309
1453
  let lastSkyMinute = -1;
1310
1454
  let running = true;
1455
+ let paused = false;
1311
1456
  function frame(time) {
1312
1457
  if (!running) return;
1313
1458
  requestAnimationFrame(frame);
1459
+ if (paused) return;
1314
1460
  if (!stage.isConnected || stage.offsetParent === null) return;
1315
1461
  if (width === 0) resize();
1316
1462
  if (flight) {
@@ -1396,6 +1542,16 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
1396
1542
  clearSelection() {
1397
1543
  select(null);
1398
1544
  },
1545
+ setWorkSigns,
1546
+ showWork,
1547
+ selectKey,
1548
+ /** While a district is open the world is not drawn (its canvas and labels are hidden). */
1549
+ setPaused(value) {
1550
+ paused = value;
1551
+ renderer.domElement.hidden = value;
1552
+ labelsLayer.hidden = value;
1553
+ if (!value) resize();
1554
+ },
1399
1555
  dispose() {
1400
1556
  running = false;
1401
1557
  observer?.disconnect();
@@ -1424,13 +1580,45 @@ const JUDGMENT_UNAVAILABLE_TEXT = Object.freeze({
1424
1580
  judgment_journal_not_connected: '判断の記録は各メンバーのMacにあり、組織版には送らないため未接続です',
1425
1581
  });
1426
1582
 
1583
+ /**
1584
+ * Where the selected city and work site are kept: the page address (`world_business`, `world_site`),
1585
+ * so leaving for another screen and coming back opens the same place. A host may pass its own.
1586
+ */
1587
+ function addressSelection() {
1588
+ return {
1589
+ read() {
1590
+ try {
1591
+ const params = new URLSearchParams(globalThis.location?.search ?? '');
1592
+ return { business: params.get('world_business'), site: params.get('world_site') };
1593
+ } catch {
1594
+ return { business: null, site: null };
1595
+ }
1596
+ },
1597
+ write({ business, site }) {
1598
+ try {
1599
+ const url = new URL(globalThis.location.href);
1600
+ if (business) url.searchParams.set('world_business', business);
1601
+ else url.searchParams.delete('world_business');
1602
+ if (site) url.searchParams.set('world_site', site);
1603
+ else url.searchParams.delete('world_site');
1604
+ globalThis.history?.replaceState(globalThis.history.state, '', `${url.pathname}${url.search}${url.hash}`);
1605
+ } catch {
1606
+ // Without an address the selection simply is not kept.
1607
+ }
1608
+ },
1609
+ };
1610
+ }
1611
+
1427
1612
  /**
1428
1613
  * @param {object} options
1429
1614
  * @param {(project: { id: string, code?: string | null }) => string} [options.projectHref] Where a business or
1430
1615
  * engagement opens in the host's プロジェクトと関係者. The local web opens `#projects?project=<id>`; an
1431
1616
  * organization web passes its own route.
1617
+ * @param {(site: { task_id: string }) => string | null} [options.taskHref] Where a work site's task opens in the
1618
+ * host's task screen (to check or correct it). Without one the rail says it cannot be corrected here.
1619
+ * @param {{ read(): { business: string | null, site: string | null }, write(selection): void }} [options.selection]
1432
1620
  */
1433
- export function createWorldView({ root, rail, page, document: explicitDocument, fetcher, projectHref = (project) => `#projects?project=${encodeURIComponent(project.id)}` }) {
1621
+ export function createWorldView({ root, rail, page, document: explicitDocument, fetcher, projectHref = (project) => `#projects?project=${encodeURIComponent(project.id)}`, taskHref = null, selection = addressSelection() }) {
1434
1622
  const doc = explicitDocument ?? globalThis.document;
1435
1623
  const reducedMotion = Boolean(globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches);
1436
1624
  const wrap = el(doc, 'div', { className: 'bb-world' });
@@ -1451,11 +1639,12 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
1451
1639
  const header = workspacePageHeader(doc, {
1452
1640
  crumbs: page?.crumbs ?? ['あなたのBrainbase', '世界'],
1453
1641
  title: '世界',
1454
- lead: '事業を都市、案件を区画、判断の種類を広場の建物として描いています。見るための画面で、ここからは何も書き換えません。',
1642
+ lead: '事業を都市、案件を区画、判断の種類を広場の建物として描いています。都市を選ぶとその事業の区画に入り、仕事(タスク)が通りに面した現場として建ち、記録から把握できていないことが街の様子で見えます。見るための画面で、ここからは何も書き換えません。',
1455
1643
  source: page?.source ?? null,
1456
1644
  actions: [
1457
1645
  skyButton,
1458
- workspaceButton(doc, { text: '全体に戻る', onClick: () => { scene?.clearSelection(); showEmptyRail(); scene?.resetView(); } }),
1646
+ // Back to the whole world, keeping the city in hand (its ring and its rail stay).
1647
+ workspaceButton(doc, { text: '全体に戻る', onClick: () => goToWorld() }),
1459
1648
  ],
1460
1649
  });
1461
1650
  const notices = el(doc, 'div', { className: 'bb-world-hud', attrs: { 'aria-live': 'polite' } });
@@ -1465,9 +1654,18 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
1465
1654
  legend.append(chip);
1466
1655
  }
1467
1656
  legend.append(el(doc, 'span', { className: 'bb-world-chip is-flag', text: '赤い旗=任せたのに訂正・取り消し' }));
1657
+ // Shown once a city's work is open.
1658
+ const workLegend = el(doc, 'div', { className: 'bb-world-legend is-work', attrs: { 'aria-label': '仕事の凡例' } });
1659
+ workLegend.hidden = true;
1660
+ for (const state of WORK_STATES.slice(0, 3)) workLegend.append(el(doc, 'span', { className: `bb-world-chip is-work-${state.key}`, text: `${state.label}(記録上)` }));
1661
+ workLegend.append(
1662
+ el(doc, 'span', { className: 'bb-world-chip is-sign', text: '?の札=把握できていないことがある' }),
1663
+ el(doc, 'span', { className: 'bb-world-chip is-ring', text: '破線の輪=本文に人物(担当欄は未接続)' }),
1664
+ el(doc, 'span', { className: 'bb-world-chip is-road', text: '実線=記録された関係・破線=推定' }),
1665
+ );
1468
1666
  const stage = el(doc, 'div', { className: 'bb-world-stage' });
1469
1667
  const labelsLayer = el(doc, 'div', { className: 'bb-world-labels' });
1470
- stage.append(labelsLayer, notices, legend);
1668
+ stage.append(labelsLayer, notices, legend, workLegend);
1471
1669
  wrap.append(header, stage);
1472
1670
 
1473
1671
  function showRail(nodes) {
@@ -1534,45 +1732,341 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
1534
1732
  });
1535
1733
  }
1536
1734
 
1735
+ // --- which city and work site are open (story-world-work-sites-and-gaps-v1) ---
1736
+ let knownBusinesses = [];
1737
+ /** Whether the host has a task store: true, false (not connected), or null (not known yet / unreadable). */
1738
+ let workConnected = null;
1739
+ const workCache = new Map();
1740
+ const current = { code: null, siteId: null, level: 'world', focus: null, pendingSite: null };
1741
+
1742
+ /** Inside a city the notices fold away (they would cover it); its rail carries the sources and times. */
1743
+ function setLevel(level) {
1744
+ current.level = level;
1745
+ notices.hidden = level !== 'world';
1746
+ }
1747
+
1748
+ function setCurrent(code, siteId) {
1749
+ current.code = code;
1750
+ current.siteId = siteId;
1751
+ selection?.write?.({ business: code, site: siteId });
1752
+ }
1753
+
1754
+ function businessOf(code) {
1755
+ return knownBusinesses.find((business) => business.code === code) ?? null;
1756
+ }
1757
+
1758
+ /** The registration, engagements, link out and judgments of a city (as before the work layer). */
1759
+ function cityRegistrationBlocks(business) {
1760
+ const blocks = [];
1761
+ const measures = cityMeasures(business, { isFinished, judgments: placement.byBusiness.get(business.code) ?? [] });
1762
+ const kind = kindEntry(business.kind);
1763
+ const kindText = kind.definition ? `${kind.label}(${kind.definition})` : kind.label;
1764
+ const judgmentText = judgmentsConnected ? `・判断${measures.judgments}件` : '(判断は未接続)';
1765
+ blocks.push(workspaceRailBlock(doc, { title: '登録', content: workspaceDefinition(doc, [
1766
+ ['分類', kindText],
1767
+ ['状態', statusText(business.status)],
1768
+ ['コード', business.code],
1769
+ ['進行中の案件(都市の広さ)', `${measures.open}件${measures.finished ? `(完了・終了${measures.finished}件は記念公園)` : ''}`],
1770
+ ['最近30日の動き(高さ・明かり)', `決定${measures.decisions}件${judgmentText}`],
1771
+ ]) }));
1772
+ const ul = el(doc, 'ul', { className: 'bb-world-rail-list' });
1773
+ for (const engagement of business.engagements) {
1774
+ const li = el(doc, 'li', { text: `${engagement.name}(${statusText(engagement.status)})` });
1775
+ li.append(projectLink(engagement, '開く'));
1776
+ ul.append(li);
1777
+ }
1778
+ blocks.push(workspaceRailBlock(doc, { title: '案件(区画)', content: business.engagements.length ? ul : { text: '登録された案件はありません' } }));
1779
+ blocks.push(detailBlock(business, '「プロジェクトと関係者」でこの事業を開く'));
1780
+ blocks.push(cityJudgmentBlock(business));
1781
+ return blocks;
1782
+ }
1783
+
1784
+ function workBlocksFor(business) {
1785
+ if (workConnected === false) {
1786
+ return [workspaceRailBlock(doc, { title: '仕事', content: { text: 'この画面はタスクの正本に接続していないため、都市の中の仕事は描いていません。仕事が0件という意味ではありません。' } })];
1787
+ }
1788
+ const work = workCache.get(business.code) ?? null;
1789
+ return workCityBlocks(doc, {
1790
+ business,
1791
+ work,
1792
+ onSelectGap: (kind) => showFocus(business, { kind }),
1793
+ onSelectStatus: (status) => showFocus(business, { status }),
1794
+ onReload: () => {
1795
+ workCache.delete(business.code);
1796
+ renderCityRail(business);
1797
+ void loadWork(business);
1798
+ },
1799
+ });
1800
+ }
1801
+
1802
+ /** Brings a rail block the viewer just asked for into view (the rail may be scrolled down, or below the city). */
1803
+ function reveal(node) {
1804
+ try {
1805
+ node?.scrollIntoView?.({ block: 'start', behavior: reducedMotion ? 'auto' : 'smooth' });
1806
+ } catch {
1807
+ // Without scrolling the block is still in the rail.
1808
+ }
1809
+ }
1810
+
1811
+ function renderCityRail(business, { revealFocus = false } = {}) {
1812
+ const blocks = [workspaceRailHead(doc, { kicker: `事業・${kindEntry(business.kind).label}`, title: business.name, lead: business.purpose ?? undefined })];
1813
+ let focusBlock = null;
1814
+ const work = workCache.get(business.code);
1815
+ if (current.focus && work?.status === 'ok') {
1816
+ const ids = new Set(current.focus.kind
1817
+ ? work.summary.gaps?.[current.focus.kind] ?? []
1818
+ : work.sites.filter((site) => site.work.open && site.work.status === current.focus.status).map((site) => site.task_id));
1819
+ const sites = work.sites.filter((site) => ids.has(site.task_id));
1820
+ const label = current.focus.kind
1821
+ ? sites[0]?.gaps.find((gap) => gap.kind === current.focus.kind)?.label ?? current.focus.kind
1822
+ : `記録上の状態「${WORK_STATES.find((state) => state.key === current.focus.status)?.label ?? current.focus.status}」`;
1823
+ blocks.push(focusBlock = workPickBlock(doc, {
1824
+ title: `${label} ${sites.length}件`,
1825
+ sites,
1826
+ note: '該当する仕事を街の中で濃く、ほかを薄く出しています。選ぶと、その仕事の根拠が開きます。',
1827
+ onSelectSite: (taskId) => openSiteById(business, taskId),
1828
+ }));
1829
+ blocks.push(workspaceRailBlock(doc, { title: '', content: workspaceButton(doc, { text: 'すべての仕事を表示', onClick: () => showFocus(business, null) }) }));
1830
+ }
1831
+ blocks.push(...workBlocksFor(business), ...cityRegistrationBlocks(business));
1832
+ showRail(blocks);
1833
+ if (revealFocus) reveal(focusBlock ?? blocks[0]);
1834
+ }
1835
+
1836
+ /** The open tasks a focus picks: a kind of gap, or a recorded state; null for all. */
1837
+ function focusIdsFor(work, focus) {
1838
+ if (!focus || work?.status !== 'ok') return null;
1839
+ return focus.kind
1840
+ ? work.summary.gaps?.[focus.kind] ?? []
1841
+ : work.sites.filter((site) => site.work.open && site.work.status === focus.status).map((site) => site.task_id);
1842
+ }
1843
+
1844
+ function showFocus(business, focus) {
1845
+ current.focus = focus;
1846
+ district?.focus(focusIdsFor(workCache.get(business.code), focus));
1847
+ renderCityRail(business, { revealFocus: true });
1848
+ }
1849
+
1850
+ // --- inside a district (AC-11): the world rests behind it while it is open ---------------------
1851
+ let district = null;
1852
+ let districtCode = null;
1853
+ function enterDistrict(business, work) {
1854
+ if (!scene || !work || work.status === 'not_connected') return false;
1855
+ district ??= createDistrictView({
1856
+ doc,
1857
+ stage,
1858
+ reducedMotion,
1859
+ onPick: (site) => {
1860
+ const owner = businessOf(current.code);
1861
+ if (owner) openSite(owner, site);
1862
+ },
1863
+ onClear: () => {
1864
+ const owner = businessOf(current.code);
1865
+ if (owner && current.level === 'site') openCity(owner, null);
1866
+ },
1867
+ onEscape: goUp,
1868
+ });
1869
+ scene.setPaused(true);
1870
+ legend.hidden = true;
1871
+ workLegend.hidden = true;
1872
+ if (districtCode !== business.code || !district.isOpen()) {
1873
+ district.show(business, work, { neighbours: new Map(knownBusinesses.map((entry) => [entry.code, entry.name])) });
1874
+ districtCode = business.code;
1875
+ }
1876
+ district.focus(focusIdsFor(work, current.focus));
1877
+ return true;
1878
+ }
1879
+
1880
+ function leaveDistrict() {
1881
+ if (!district?.isOpen()) return;
1882
+ district.hide();
1883
+ districtCode = null;
1884
+ scene?.setPaused(false);
1885
+ legend.hidden = false;
1886
+ }
1887
+
1888
+ async function loadWork(business) {
1889
+ if (workConnected === false || workCache.has(business.code)) return workCache.get(business.code) ?? null;
1890
+ const result = await readJson(fetcher, `/api/extensions/world/businesses/${encodeURIComponent(business.code)}/work`);
1891
+ const work = result.ok ? result.data : { status: 'unavailable', reason: result.error };
1892
+ if (work?.status === 'not_connected') workConnected = false;
1893
+ workCache.set(business.code, work);
1894
+ if (current.code !== business.code) return work;
1895
+ scene?.showWork(business.code, work);
1896
+ if (current.level !== 'world') enterDistrict(business, work);
1897
+ if (current.pendingSite) {
1898
+ const siteId = current.pendingSite;
1899
+ current.pendingSite = null;
1900
+ if (openSiteById(business, siteId)) return work;
1901
+ }
1902
+ if (current.level !== 'site') renderCityRail(business);
1903
+ return work;
1904
+ }
1905
+
1906
+ /** Opens a city: its rail, and its district once its work is read (the district is where its work is). */
1907
+ function openCity(business, center, { zoom = 2.8 } = {}) {
1908
+ if (current.code !== business.code) current.focus = null;
1909
+ setCurrent(business.code, null);
1910
+ setLevel('city');
1911
+ const work = workCache.get(business.code);
1912
+ if (districtCode === business.code && district?.isOpen()) {
1913
+ district.home();
1914
+ district.focus(focusIdsFor(work, current.focus));
1915
+ } else {
1916
+ if (center) scene?.flyTo(new THREE.Vector3(center.x, 0, center.z), zoom);
1917
+ if (work) {
1918
+ scene?.showWork(business.code, work);
1919
+ enterDistrict(business, work);
1920
+ }
1921
+ }
1922
+ renderCityRail(business);
1923
+ if (!work) void loadWork(business);
1924
+ }
1925
+
1926
+ function openSite(business, site) {
1927
+ setCurrent(business.code, site.task_id);
1928
+ setLevel('site');
1929
+ if (districtCode === business.code) district?.select(site.task_id);
1930
+ const work = workCache.get(business.code);
1931
+ const back = workspaceButton(doc, { text: `← ${business.name}の区画に戻る`, onClick: () => goUp() });
1932
+ const head = workspaceRailHead(doc, { kicker: `${business.name} の仕事(タスク)`, title: site.title, sub: `記録上:${site.work.label}${site.gaps.length ? `・把握できていないこと${site.gaps.length}種` : ''}` });
1933
+ showRail([
1934
+ head,
1935
+ workspaceRailBlock(doc, { title: '', content: back }),
1936
+ ...workSiteBlocks(doc, { business, site, readAt: work?.reads?.tasks?.read_at ?? null, taskHref }),
1937
+ ]);
1938
+ reveal(head);
1939
+ }
1940
+
1941
+ function openSiteById(business, taskId) {
1942
+ const work = workCache.get(business.code);
1943
+ const site = work?.status === 'ok' ? work.sites.find((entry) => entry.task_id === taskId) : null;
1944
+ if (!site) return false;
1945
+ openSite(business, site);
1946
+ return true;
1947
+ }
1948
+
1949
+ /** The whole world again, with the open city kept in hand (ring and rail stay). */
1950
+ function goToWorld() {
1951
+ leaveDistrict();
1952
+ setLevel('world');
1953
+ if (current.code) {
1954
+ const business = businessOf(current.code);
1955
+ scene?.selectKey({ code: current.code });
1956
+ if (business) renderCityRail(business);
1957
+ setCurrent(current.code, null);
1958
+ } else {
1959
+ scene?.clearSelection();
1960
+ showEmptyRail();
1961
+ }
1962
+ scene?.resetView();
1963
+ }
1964
+
1965
+ function clearAll() {
1966
+ setCurrent(null, null);
1967
+ setLevel('world');
1968
+ current.focus = null;
1969
+ showEmptyRail();
1970
+ }
1971
+
1972
+ /** One level up: a work site → its district, a district → the whole world (city kept), the world → nothing selected. */
1973
+ function goUp() {
1974
+ const business = current.code ? businessOf(current.code) : null;
1975
+ if (business && current.level === 'site') {
1976
+ district?.clearSelection();
1977
+ openCity(business, null);
1978
+ return;
1979
+ }
1980
+ if (business && current.level === 'city') {
1981
+ goToWorld();
1982
+ return;
1983
+ }
1984
+ scene?.clearSelection();
1985
+ clearAll();
1986
+ scene?.resetView();
1987
+ }
1988
+
1989
+ function restoreSelection() {
1990
+ const saved = selection?.read?.() ?? {};
1991
+ const business = saved.business ? businessOf(saved.business) : null;
1992
+ if (!business) return;
1993
+ const picked = scene?.selectKey({ code: business.code });
1994
+ current.pendingSite = saved.site ?? null;
1995
+ openCity(business, picked?.center ?? null);
1996
+ }
1997
+
1998
+ async function loadWorkSummary(note) {
1999
+ const result = await readJson(fetcher, '/api/extensions/world/work-summary');
2000
+ if (!result.ok) {
2001
+ note('仕事', `仕事の要約を読めません(${result.error})。都市を選ぶと、その事業の仕事を読みにいきます。0件ではありません。`, 'warning');
2002
+ return;
2003
+ }
2004
+ if (result.data?.status === 'not_connected') {
2005
+ workConnected = false;
2006
+ note('仕事', 'この画面はタスクの正本に接続していないため、都市の中の仕事は描いていません(0件ではありません)。');
2007
+ return;
2008
+ }
2009
+ if (result.data?.status !== 'ok') {
2010
+ note('仕事', `仕事の要約を読めません(${result.data?.reason ?? result.data?.status ?? '理由不明'})。0件ではありません。`, 'warning');
2011
+ return;
2012
+ }
2013
+ workConnected = true;
2014
+ const summaries = result.data.businesses ?? {};
2015
+ scene?.setWorkSigns(summaries);
2016
+ const entries = Object.values(summaries);
2017
+ const readable = entries.filter((entry) => entry.state === 'complete' || entry.state === 'partial');
2018
+ const unreadable = entries.length - readable.length;
2019
+ const open = readable.reduce((sum, entry) => sum + (entry.open ?? 0), 0);
2020
+ const needs = readable.reduce((sum, entry) => sum + (entry.needs_check?.length ?? 0), 0);
2021
+ const cities = readable.filter((entry) => entry.needs_check?.length).length;
2022
+ const partial = readable.filter((entry) => entry.state === 'partial').length;
2023
+ // Which kinds of gap, across the businesses read: what is not visible, not how bad it is.
2024
+ const kinds = new Map();
2025
+ for (const entry of readable) for (const [kind, ids] of Object.entries(entry.gaps ?? {})) kinds.set(kind, (kinds.get(kind) ?? 0) + ids.length);
2026
+ const kindText = [...kinds].sort((a, b) => b[1] - a[1]).map(([kind, count]) => `${GAP_SHORT_TEXT[kind] ?? kind}${count}`).join('・');
2027
+ const parts = readable.length
2028
+ ? [`未完了の仕事${open}件のうち${needs}件(${cities}事業)に、記録から把握できていないことがあります(?の札${kindText ? `。内訳:${kindText}` : ''})`]
2029
+ : [];
2030
+ if (unreadable) parts.push(`${readable.length ? '' : 'どの事業の仕事も読めませんでした。'}仕事を読めなかった事業${unreadable}件(灰色の札。0件ではありません)`);
2031
+ if (partial) parts.push(`一部だけ読めた事業${partial}件`);
2032
+ note('仕事', `${parts.join('。')}。「止まっている」という意味ではありません(${shortTimeText(result.data.as_of)}時点)。`);
2033
+ }
2034
+
2035
+ function shortTimeText(value) {
2036
+ const at = Date.parse(value ?? '');
2037
+ if (!Number.isFinite(at)) return '時点不明';
2038
+ const date = new Date(at);
2039
+ const pad = (number) => String(number).padStart(2, '0');
2040
+ return `${date.getMonth() + 1}/${date.getDate()} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
2041
+ }
2042
+
1537
2043
  function onPick(data, position) {
1538
- if (data.kind === 'city' || data.kind === 'engagement') {
2044
+ if (data.kind === 'site') {
2045
+ openSite(data.business, data.site);
2046
+ return;
2047
+ }
2048
+ if (data.kind === 'city') {
2049
+ openCity(data.business, position);
2050
+ return;
2051
+ }
2052
+ if (data.kind === 'engagement') {
1539
2053
  const business = data.business;
1540
- scene?.flyTo(new THREE.Vector3(position.x, 0, position.z), data.kind === "engagement" ? 3.6 : 2.8);
1541
- const blocks = [workspaceRailHead(doc, {
1542
- kicker: data.kind === 'engagement' ? `${business.name} の案件` : `事業・${kindEntry(business.kind).label}`,
1543
- title: data.kind === 'engagement' ? data.engagement.name : business.name,
1544
- lead: data.kind === 'engagement' ? data.engagement.summary ?? undefined : business.purpose ?? undefined,
1545
- })];
1546
- if (data.kind === 'engagement') {
1547
- blocks.push(workspaceRailBlock(doc, { title: '登録', content: workspaceDefinition(doc, [['状態', statusText(data.engagement.status)], ['Graph ID', data.engagement.id]]) }));
1548
- blocks.push(detailBlock(data.engagement, '「プロジェクトと関係者」でこの案件を開く'));
1549
- } else {
1550
- const measures = cityMeasures(business, { isFinished, judgments: placement.byBusiness.get(business.code) ?? [] });
1551
- const kind = kindEntry(business.kind);
1552
- const kindText = kind.definition ? `${kind.label}(${kind.definition})` : kind.label;
1553
- const judgmentText = judgmentsConnected ? `・判断${measures.judgments}件` : '(判断は未接続)';
1554
- blocks.push(workspaceRailBlock(doc, { title: '登録', content: workspaceDefinition(doc, [
1555
- ['分類', kindText],
1556
- ['状態', statusText(business.status)],
1557
- ['コード', business.code],
1558
- ['進行中の案件(都市の広さ)', `${measures.open}件${measures.finished ? `(完了・終了${measures.finished}件は記念公園)` : ''}`],
1559
- ['最近30日の動き(高さ・明かり)', `決定${measures.decisions}件${judgmentText}`],
1560
- ]) }));
1561
- const ul = el(doc, 'ul', { className: 'bb-world-rail-list' });
1562
- for (const engagement of business.engagements) {
1563
- const li = el(doc, 'li', { text: `${engagement.name}(${statusText(engagement.status)})` });
1564
- li.append(projectLink(engagement, '開く'));
1565
- ul.append(li);
1566
- }
1567
- blocks.push(workspaceRailBlock(doc, { title: '案件(区画)', content: business.engagements.length ? ul : { text: '登録された案件はありません' } }));
1568
- blocks.push(detailBlock(business, '「プロジェクトと関係者」でこの事業を開く'));
1569
- }
1570
- blocks.push(cityJudgmentBlock(business));
1571
- showRail(blocks);
2054
+ setCurrent(business.code, null);
2055
+ setLevel('city');
2056
+ scene?.flyTo(new THREE.Vector3(position.x, 0, position.z), 3.6);
2057
+ if (!workCache.has(business.code)) void loadWork(business);
2058
+ showRail([
2059
+ workspaceRailHead(doc, { kicker: `${business.name} の案件`, title: data.engagement.name, lead: data.engagement.summary ?? undefined }),
2060
+ workspaceRailBlock(doc, { title: '登録', content: workspaceDefinition(doc, [['状態', statusText(data.engagement.status)], ['Graph ID', data.engagement.id]]) }),
2061
+ detailBlock(data.engagement, '「プロジェクトと関係者」でこの案件を開く'),
2062
+ workspaceRailBlock(doc, { title: '', content: workspaceButton(doc, { text: `${business.name}の仕事と関係を見る`, onClick: () => openCity(business, position) }) }),
2063
+ cityJudgmentBlock(business),
2064
+ ]);
1572
2065
  return;
1573
2066
  }
1574
2067
  if (data.kind === 'cityJudgments') {
1575
2068
  const business = data.business;
2069
+ setCurrent(business.code, null);
1576
2070
  scene?.flyTo(new THREE.Vector3(position.x, 0, position.z), 3.2);
1577
2071
  showRail([
1578
2072
  workspaceRailHead(doc, { kicker: `${business.name} の判断所`, title: `判断 ${data.judgments.length}件`, lead: business.purpose ?? undefined }),
@@ -1583,6 +2077,7 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
1583
2077
  }
1584
2078
  if (data.kind === 'judgment') {
1585
2079
  const { row, items } = data;
2080
+ setCurrent(null, null);
1586
2081
  scene?.flyTo(new THREE.Vector3(0, 0, 0), 2.2);
1587
2082
  const blocks = [workspaceRailHead(doc, {
1588
2083
  kicker: `判断の種類・${STATE_LABELS[row.state] ?? row.state}`,
@@ -1613,6 +2108,7 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
1613
2108
  return;
1614
2109
  }
1615
2110
  if (data.kind === 'plaza') {
2111
+ setCurrent(null, null);
1616
2112
  scene?.flyTo(new THREE.Vector3(0, 0, 0), 2.2);
1617
2113
  const blocks = [workspaceRailHead(doc, {
1618
2114
  kicker: '広場',
@@ -1708,10 +2204,13 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
1708
2204
  renderFallbackList(doc, wrap, businesses, rows);
1709
2205
  return;
1710
2206
  }
1711
- scene = createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear: showEmptyRail });
2207
+ scene = createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear: clearAll, onEscape: goUp });
1712
2208
  scene.buildPlaza(rows, rowItems, (row) => (rowItems.get(row.key) ?? []).filter((ref) => isWaiting(proofs.get(ref.decision_attempt_id))).length, Date.now());
1713
2209
  scene.buildCities(layoutWorld(businesses), placement.byBusiness);
1714
2210
  showChangesSinceLastVisit(note, businesses, rows);
2211
+ knownBusinesses = businesses;
2212
+ restoreSelection();
2213
+ void loadWorkSummary(note);
1715
2214
  }
1716
2215
 
1717
2216
  /**