@unson/brainbase-mcp 0.23.6 → 0.23.8

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.
@@ -24,7 +24,9 @@
24
24
 
25
25
  import { THREE, MapControls } from './world-vendor.js';
26
26
  import { changesSince, cityMeasures, districtChanges, districtLots, districtSnapshot, districtStage, groupJudgmentPlaces, skyAt, UNPLACED_REASON_TEXT } from './world-placement.js';
27
- import { createDistrictView } from './world-district.js';
27
+ import { createDistrictView, DISTRICT_LEGEND } from './world-district.js';
28
+ import { createWorldCanvasUI } from './world-canvas-ui.js';
29
+ import { workCanvasNotes } from './world-canvas-notes.js';
28
30
  import { canvasTexture, drawFacade, FACADE_SIZES, FACADE_UNITS, gableRoof, hashUnit, muted, paintVertices, roundedPlate, valueNoise } from './world-scenery.js';
29
31
  import { WORK_STATES, workCityBlocks, workGrowthBlock, workPickBlock, workSign, workSiteBlocks } from './world-work-rail.js';
30
32
  import {
@@ -38,6 +40,8 @@ import {
38
40
  } from '../../workspace-kit.js';
39
41
 
40
42
  export const WORLD_VIEW_CONTRACT_VERSION = 'brainbase.world-view.v0';
43
+ /** Optional full-canvas presentation; the standard host contract remains unchanged. */
44
+ export const WORLD_CANVAS_PRESENTATION_VERSION = 'brainbase.world-canvas.v1';
41
45
 
42
46
  const STATE_LABELS = Object.freeze({ delegated: '任せている', verifying: '確かめ中', returned: '戻している' });
43
47
  const STATE_COLORS = Object.freeze({ delegated: 0x087d62, verifying: 0xc99a3a, returned: 0xd92335 });
@@ -1034,7 +1038,7 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear,
1034
1038
  });
1035
1039
  renderer.domElement.addEventListener('keydown', (event) => {
1036
1040
  // Escape goes one level up; the view decides what that level is.
1037
- if (event.key === 'Escape') onEscape();
1041
+ if (event.key === 'Escape' && !event.defaultPrevented) onEscape();
1038
1042
  });
1039
1043
 
1040
1044
  let flight = null;
@@ -1431,12 +1435,32 @@ function addressSelection() {
1431
1435
  * organization web passes its own route.
1432
1436
  * @param {(site: { task_id: string }) => string | null} [options.taskHref] Where a work site's task opens in the
1433
1437
  * host's task screen (to check or correct it). Without one the rail says it cannot be corrected here.
1438
+ * @param {'standard' | 'canvas'} [options.presentation] Canvas owns its accessible overlays and ignores the host rail.
1434
1439
  * @param {{ read(): { business: string | null, site: string | null }, write(selection): void }} [options.selection]
1435
1440
  */
1436
- export function createWorldView({ root, rail, page, document: explicitDocument, fetcher, projectHref = (project) => `#projects?project=${encodeURIComponent(project.id)}`, taskHref = null, selection = addressSelection() }) {
1441
+ export function createWorldView({ root, rail, page, presentation = 'standard', document: explicitDocument, fetcher, projectHref = (project) => `#projects?project=${encodeURIComponent(project.id)}`, taskHref = null, selection = addressSelection() }) {
1437
1442
  const doc = explicitDocument ?? globalThis.document;
1438
1443
  const reducedMotion = Boolean(globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches);
1439
- const wrap = el(doc, 'div', { className: 'bb-world' });
1444
+ const canvasMode = presentation === 'canvas';
1445
+ const wrap = el(doc, 'div', { className: `bb-world${canvasMode ? ' bb-world--canvas' : ''}` });
1446
+ let disposed = false;
1447
+ let canvasUI = null;
1448
+ const canvasNotes = new Map();
1449
+ const renderCanvasNotes = () => {
1450
+ if (!canvasUI || disposed) return;
1451
+ canvasUI.clearNotes();
1452
+ for (const note of canvasNotes.values()) canvasUI.addNote(note);
1453
+ };
1454
+ const setCanvasNote = (key, note) => {
1455
+ if (note) canvasNotes.set(key, note);
1456
+ else canvasNotes.delete(key);
1457
+ renderCanvasNotes();
1458
+ };
1459
+ function updateDistrictNotes(work) {
1460
+ for (const key of canvasNotes.keys()) if (key.startsWith('district:')) canvasNotes.delete(key);
1461
+ if (work) for (const [index, note] of workCanvasNotes(work).entries()) canvasNotes.set(`district:${index}`, note);
1462
+ renderCanvasNotes();
1463
+ }
1440
1464
  root.append(wrap);
1441
1465
  let scene = null;
1442
1466
 
@@ -1481,12 +1505,50 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
1481
1505
  );
1482
1506
  const stage = el(doc, 'div', { className: 'bb-world-stage' });
1483
1507
  const labelsLayer = el(doc, 'div', { className: 'bb-world-labels' });
1484
- stage.append(labelsLayer, notices, legend, workLegend);
1485
- wrap.append(header, stage);
1508
+ stage.append(labelsLayer);
1509
+ if (canvasMode) {
1510
+ wrap.append(stage);
1511
+ canvasUI = createWorldCanvasUI({
1512
+ doc, stage, reducedMotion,
1513
+ onReset: () => { goToWorld(); clearAll(); },
1514
+ onCity: (code) => {
1515
+ if (!code) { goToWorld(); clearAll(); return; }
1516
+ const business = businessOf(code);
1517
+ if (business) openCity(business, scene?.selectKey({ code })?.center ?? null);
1518
+ },
1519
+ onSky: (mode) => {
1520
+ skyIndex = Math.max(0, SKY_MODES.findIndex(([key]) => key === mode));
1521
+ scene?.setSky(SKY_MODES[skyIndex][0]);
1522
+ district?.setSky(SKY_MODES[skyIndex][0]);
1523
+ },
1524
+ onDismissTask: () => dismissTask(),
1525
+ });
1526
+ const help = el(doc, 'div');
1527
+ help.append(el(doc, 'p', { text: 'ドラッグで移動、ホイールで寄る・離れる、右ドラッグで回転。都市を選ぶと区画に入ります。右端から詳細と絞り込みを開けます。Escで詳細を閉じ、その後は区画・全体の順に戻ります。ここから記録は書き換えません。' }));
1528
+ help.append(legend, workLegend);
1529
+ workLegend.hidden = false;
1530
+ const list = el(doc, 'ul');
1531
+ for (const [, text] of DISTRICT_LEGEND) list.append(el(doc, 'li', { text }));
1532
+ help.append(list);
1533
+ if (page?.source) help.append(el(doc, 'p', { text: page.source }));
1534
+ canvasUI.setHelp([help]);
1535
+ } else {
1536
+ stage.append(notices, legend, workLegend);
1537
+ wrap.append(header, stage);
1538
+ }
1539
+
1540
+ const onRootEscape = (event) => {
1541
+ if (!canvasMode || event.key !== 'Escape' || event.defaultPrevented) return;
1542
+ event.preventDefault();
1543
+ event.stopPropagation();
1544
+ if (!canvasUI.dismissOverlay()) goUp();
1545
+ };
1546
+ wrap.addEventListener('keydown', onRootEscape, true);
1486
1547
 
1487
1548
  function showRail(nodes) {
1488
- if (!rail) return;
1489
- rail.replaceChildren(...nodes);
1549
+ if (disposed) return;
1550
+ if (canvasUI) canvasUI.showRail(nodes);
1551
+ else rail?.replaceChildren(...nodes);
1490
1552
  }
1491
1553
  /**
1492
1554
  * With nothing chosen, the rail lists the ways in (AC-19): one button per business, the ones with the
@@ -1503,7 +1565,7 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
1503
1565
  if (!summary) return { text: workConnected === false ? '' : '仕事は区画で読みます', count: -1 };
1504
1566
  if (summary.state !== 'complete' && summary.state !== 'partial') return { text: '仕事を読めない(0件ではありません)', count: -1, muted: true };
1505
1567
  const needs = summary.needs_check?.length ?? 0;
1506
- const parts = [needs ? `要確認 ${needs}` : `未完了 ${summary.open ?? 0}`];
1568
+ const parts = [needs ? `要確認 ${needs}` : Number.isInteger(summary.open) ? `未完了 ${summary.open}` : '未完了の件数は未確認'];
1507
1569
  if (summary.state === 'partial') parts.push('一部だけ読めた');
1508
1570
  return { text: parts.join('・'), count: needs };
1509
1571
  };
@@ -1593,17 +1655,21 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
1593
1655
  /** Whether the host has a task store: true, false (not connected), or null (not known yet / unreadable). */
1594
1656
  let workConnected = null;
1595
1657
  const workCache = new Map();
1596
- const current = { code: null, siteId: null, level: 'world', focus: null, pendingSite: null };
1658
+ const workRequests = new Map();
1659
+ const current = { code: null, siteId: null, level: 'world', focus: null, pendingSite: null, detachedSiteId: null };
1597
1660
 
1598
1661
  /** Inside a city the notices fold away (they would cover it); its rail carries the sources and times. */
1599
1662
  function setLevel(level) {
1600
1663
  current.level = level;
1601
1664
  notices.hidden = level !== 'world';
1665
+ if (canvasMode && level === 'world') updateDistrictNotes(null);
1602
1666
  }
1603
1667
 
1604
1668
  function setCurrent(code, siteId) {
1605
1669
  current.code = code;
1606
1670
  current.siteId = siteId;
1671
+ if (!siteId) current.detachedSiteId = null;
1672
+ canvasUI?.setCity(code);
1607
1673
  selection?.write?.({ business: code, site: siteId });
1608
1674
  }
1609
1675
 
@@ -1680,13 +1746,22 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
1680
1746
  title: `${label} ${sites.length}件`,
1681
1747
  sites,
1682
1748
  note: '該当する仕事を街の中で濃く、ほかを薄く出しています。選ぶと、その仕事の根拠が開きます。',
1683
- onSelectSite: (taskId) => openSiteById(business, taskId),
1749
+ onSelectSite: (taskId) => openSiteById(business, taskId, { focus: true }),
1684
1750
  }));
1685
1751
  blocks.push(workspaceRailBlock(doc, { title: '', content: workspaceButton(doc, { text: 'すべての仕事を表示', onClick: () => showFocus(business, null) }) }));
1686
1752
  }
1687
1753
  const growth = growthFor(business.code);
1688
1754
  if (growth) blocks.push(workGrowthBlock(doc, growth));
1689
- blocks.push(...workBlocksFor(business), ...cityRegistrationBlocks(business));
1755
+ blocks.push(...workBlocksFor(business));
1756
+ if (canvasMode && work?.status === 'ok' && ['complete', 'partial'].includes(work.reads.tasks.state)) {
1757
+ // The canvas objects have an equivalent keyboard/touch entry for every
1758
+ // actual record, including completed/cancelled work outside open-work filters.
1759
+ const directory = el(doc, 'details', { className: 'bb-world-task-directory' });
1760
+ directory.append(el(doc, 'summary', { text: `仕事を選ぶ(${work.reads.tasks.state === 'partial' ? '読めた範囲 ' : ''}${work.sites.length}件)` }));
1761
+ directory.append(workPickBlock(doc, { title: '仕事の記録', sites: work.sites, onSelectSite: (taskId) => openSiteById(business, taskId, { focus: true }) }));
1762
+ blocks.push(directory);
1763
+ }
1764
+ blocks.push(...cityRegistrationBlocks(business));
1690
1765
  showRail(blocks);
1691
1766
  if (revealFocus) reveal(focusBlock ?? blocks[0]);
1692
1767
  }
@@ -1723,7 +1798,7 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
1723
1798
  // Without storage the district shows no changes next time.
1724
1799
  }
1725
1800
  }
1726
- const liveSites = (work) => (work?.status === 'ok' && work.reads.tasks.state !== 'unavailable' ? work.sites : null);
1801
+ const liveSites = (work) => (work?.status === 'ok' && work.reads.tasks.state === 'complete' ? work.sites : null);
1727
1802
  /** Compares against what this viewer last saw, then remembers what they see now. */
1728
1803
  function noteGrowth(code, work, previous) {
1729
1804
  const sites = liveSites(work);
@@ -1742,22 +1817,34 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
1742
1817
 
1743
1818
  // AC-23: coming back from the task screen with the district open, read its work again and answer.
1744
1819
  async function recheckDistrict() {
1745
- const business = districtCode ? businessOf(districtCode) : null;
1746
- if (!business || !district?.isOpen()) return;
1747
- const previous = growthFor(business.code)?.seen ?? readSnapshot(business.code);
1748
- const result = await readJson(fetcher, `/api/extensions/world/businesses/${encodeURIComponent(business.code)}/work`);
1749
- if (!result.ok || districtCode !== business.code) return;
1750
- const work = result.data;
1751
- const before = previous ? districtChanges(previous, liveSites(work) ?? [], new Date().toISOString()) : null;
1752
- if (!before || before.first || !before.items.length) return;
1753
- workCache.set(business.code, work);
1754
- scene?.showWork(business.code, work);
1755
- district.refresh(business, work, { neighbours: new Map(knownBusinesses.map((entry) => [entry.code, entry.name])) });
1756
- const growth = noteGrowth(business.code, work, previous);
1757
- district.celebrate(growth?.changes ?? null);
1758
- district.focus(focusIdsFor(work, current.focus));
1759
- if (current.level === 'city') renderCityRail(business);
1820
+ const code = current.level !== 'world' ? current.code : null;
1821
+ const business = code ? businessOf(code) : null;
1822
+ if (!business || (!canvasMode && !district?.isOpen())) return;
1823
+ const previous = growthFor(code)?.seen ?? readSnapshot(code);
1824
+ const request = Symbol(code);
1825
+ workRequests.set(code, request);
1826
+ const result = await readJson(fetcher, `/api/extensions/world/businesses/${encodeURIComponent(code)}/work`);
1827
+ if (disposed || workRequests.get(code) !== request) return;
1828
+ const work = result.ok ? result.data : { status: 'unavailable', reason: result.error };
1829
+ workCache.set(code, work);
1830
+ if (current.code !== code || current.level === 'world') return;
1831
+ updateDistrictNotes(work);
1832
+ scene?.showWork(code, work);
1833
+ if (districtCode === code && district?.isOpen()) {
1834
+ district.refresh(business, work, { neighbours: new Map(knownBusinesses.map((entry) => [entry.code, entry.name])) });
1835
+ const growth = noteGrowth(code, work, previous);
1836
+ district.celebrate(growth?.changes ?? null);
1837
+ district.focus(focusIdsFor(work, current.focus));
1838
+ }
1839
+ if (current.siteId) {
1840
+ const siteId = current.siteId;
1841
+ if (openSiteById(business, siteId, { select: false })) return;
1842
+ dismissTask();
1843
+ setCanvasNote('district:selection', { label: '選択中の仕事', tone: 'warning', summary: '選択中の仕事を現在の読込で確認できません', text: '選択中の仕事を現在の読み込み結果で確認できません。削除されたとは限りません。詳細から読み込み状態を確認してください。' });
1844
+ }
1845
+ renderCityRail(business);
1760
1846
  }
1847
+
1761
1848
  const onVisible = () => {
1762
1849
  if (doc.visibilityState === 'visible') void recheckDistrict();
1763
1850
  };
@@ -1782,11 +1869,16 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
1782
1869
  if (owner && current.level === 'site') openCity(owner, null);
1783
1870
  },
1784
1871
  onEscape: goUp,
1872
+ focusSelection: !canvasMode,
1873
+ showChrome: !canvasMode,
1874
+ onProjectSelection: (anchor) => {
1875
+ if (anchor.taskId === current.siteId && current.detachedSiteId !== current.siteId) canvasUI?.setTaskAnchor(anchor);
1876
+ else if (!anchor.visible && !current.siteId) canvasUI?.setTaskAnchor({ visible: false });
1877
+ },
1785
1878
  });
1786
1879
  district.setSky(SKY_MODES[skyIndex][0]);
1787
1880
  scene.setPaused(true);
1788
- legend.hidden = true;
1789
- workLegend.hidden = true;
1881
+ if (!canvasMode) { legend.hidden = true; workLegend.hidden = true; }
1790
1882
  if (districtCode !== business.code || !district.isOpen()) {
1791
1883
  district.show(business, work, { neighbours: new Map(knownBusinesses.map((entry) => [entry.code, entry.name])) });
1792
1884
  districtCode = business.code;
@@ -1802,22 +1894,32 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
1802
1894
  district.hide();
1803
1895
  districtCode = null;
1804
1896
  scene?.setPaused(false);
1805
- legend.hidden = false;
1897
+ if (!canvasMode) legend.hidden = false;
1806
1898
  }
1807
1899
 
1808
1900
  async function loadWork(business) {
1809
1901
  if (workConnected === false || workCache.has(business.code)) return workCache.get(business.code) ?? null;
1902
+ const request = Symbol(business.code);
1903
+ workRequests.set(business.code, request);
1810
1904
  const result = await readJson(fetcher, `/api/extensions/world/businesses/${encodeURIComponent(business.code)}/work`);
1905
+ if (disposed || workRequests.get(business.code) !== request) return null;
1811
1906
  const work = result.ok ? result.data : { status: 'unavailable', reason: result.error };
1812
1907
  if (work?.status === 'not_connected') workConnected = false;
1813
1908
  workCache.set(business.code, work);
1814
1909
  if (current.code !== business.code) return work;
1910
+ if (current.level !== 'world') updateDistrictNotes(work);
1815
1911
  scene?.showWork(business.code, work);
1816
- if (current.level !== 'world') enterDistrict(business, work);
1817
- if (current.pendingSite) {
1912
+ if (current.level !== 'world') {
1913
+ if (districtCode === business.code && district?.isOpen()) {
1914
+ district.refresh(business, work, { neighbours: new Map(knownBusinesses.map((entry) => [entry.code, entry.name])) });
1915
+ district.focus(focusIdsFor(work, current.focus));
1916
+ } else enterDistrict(business, work);
1917
+ }
1918
+ if (current.pendingSite && current.level !== 'world') {
1818
1919
  const siteId = current.pendingSite;
1819
1920
  current.pendingSite = null;
1820
1921
  if (openSiteById(business, siteId)) return work;
1922
+ setCanvasNote('district:selection', { label: '選択中の仕事', tone: 'warning', summary: '保存された仕事を現在の読込で確認できません', text: '保存された仕事を現在の読み込みで確認できません。削除とは断定せず、区画の読み込み状態を確認してください。' });
1821
1923
  }
1822
1924
  if (current.level !== 'site') renderCityRail(business);
1823
1925
  return work;
@@ -1825,12 +1927,19 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
1825
1927
 
1826
1928
  /** Opens a city: its rail, and its district once its work is read (the district is where its work is). */
1827
1929
  function openCity(business, center, { zoom = 2.8 } = {}) {
1828
- if (current.code !== business.code) current.focus = null;
1930
+ canvasUI?.closeTask();
1931
+ current.pendingSite = null;
1932
+ if (current.code !== business.code) {
1933
+ current.focus = null;
1934
+ leaveDistrict();
1935
+ }
1829
1936
  setCurrent(business.code, null);
1830
1937
  setLevel('city');
1831
1938
  const work = workCache.get(business.code);
1939
+ updateDistrictNotes(work ?? null);
1832
1940
  if (districtCode === business.code && district?.isOpen()) {
1833
- district.home();
1941
+ if (!canvasMode) district.home();
1942
+ else district.clearSelection();
1834
1943
  district.focus(focusIdsFor(work, current.focus));
1835
1944
  } else {
1836
1945
  if (center) scene?.flyTo(new THREE.Vector3(center.x, 0, center.z), zoom);
@@ -1843,31 +1952,53 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
1843
1952
  if (!work) void loadWork(business);
1844
1953
  }
1845
1954
 
1846
- function openSite(business, site) {
1955
+ function openSite(business, site, { select = true, focus = false } = {}) {
1847
1956
  setCurrent(business.code, site.task_id);
1848
1957
  setLevel('site');
1849
- if (districtCode === business.code) district?.select(site.task_id);
1958
+ const drawable = Boolean(scene && districtCode === business.code && site.work.status !== 'cancelled');
1959
+ const detached = !drawable || focus || (!select && current.detachedSiteId === site.task_id);
1960
+ current.detachedSiteId = detached ? site.task_id : null;
1961
+ if (select && drawable) district?.select(site.task_id);
1850
1962
  const work = workCache.get(business.code);
1851
1963
  const back = workspaceButton(doc, { text: `← ${business.name}の区画に戻る`, onClick: () => goUp() });
1852
1964
  const head = workspaceRailHead(doc, { kicker: `${business.name} の仕事(タスク)`, title: site.title, sub: `記録上:${site.work.label}${site.gaps.length ? `・把握できていないこと${site.gaps.length}種` : ''}` });
1853
- showRail([
1854
- head,
1855
- workspaceRailBlock(doc, { title: '', content: back }),
1856
- ...workSiteBlocks(doc, { business, site, readAt: work?.reads?.tasks?.read_at ?? null, taskHref }),
1857
- ]);
1858
- reveal(head);
1965
+ const nodes = [head, ...(canvasMode && detached ? [el(doc, 'p', { text: drawable ? '一覧から選んだ仕事の記録を、地図の位置とは独立して表示しています。' : 'この仕事は地図上に描いていません。記録の詳細を表示しています。' })] : []), ...workSiteBlocks(doc, { business, site, readAt: work?.reads?.tasks?.read_at ?? null, taskHref })];
1966
+ if (canvasUI) {
1967
+ renderCityRail(business);
1968
+ // WebGL fallback keeps the complete task inspectable in the same nonmodal surface.
1969
+ if (detached) {
1970
+ const rect = stage.getBoundingClientRect();
1971
+ canvasUI.setTaskAnchor({ x: rect.width / 2, y: rect.height / 2, visible: true, unanchored: true });
1972
+ }
1973
+ canvasUI.showTask(nodes, { focus });
1974
+ } else {
1975
+ showRail([head, workspaceRailBlock(doc, { title: '', content: back }), ...nodes.slice(1)]);
1976
+ reveal(head);
1977
+ }
1859
1978
  }
1860
1979
 
1861
- function openSiteById(business, taskId) {
1980
+ function openSiteById(business, taskId, options) {
1862
1981
  const work = workCache.get(business.code);
1863
1982
  const site = work?.status === 'ok' ? work.sites.find((entry) => entry.task_id === taskId) : null;
1864
1983
  if (!site) return false;
1865
- openSite(business, site);
1984
+ openSite(business, site, options);
1866
1985
  return true;
1867
1986
  }
1868
1987
 
1988
+ function dismissTask() {
1989
+ canvasUI?.closeTask();
1990
+ district?.clearSelection();
1991
+ if (current.level !== 'site') return;
1992
+ const business = businessOf(current.code);
1993
+ setCurrent(current.code, null);
1994
+ setLevel('city');
1995
+ if (business) renderCityRail(business);
1996
+ }
1997
+
1869
1998
  /** The whole world again, with the open city kept in hand (ring and rail stay). */
1870
1999
  function goToWorld() {
2000
+ canvasUI?.closeTask();
2001
+ current.pendingSite = null;
1871
2002
  leaveDistrict();
1872
2003
  setLevel('world');
1873
2004
  if (current.code) {
@@ -1883,6 +2014,9 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
1883
2014
  }
1884
2015
 
1885
2016
  function clearAll() {
2017
+ scene?.clearSelection();
2018
+ canvasUI?.closeTask();
2019
+ current.pendingSite = null;
1886
2020
  setCurrent(null, null);
1887
2021
  setLevel('world');
1888
2022
  current.focus = null;
@@ -1893,6 +2027,7 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
1893
2027
  function goUp() {
1894
2028
  const business = current.code ? businessOf(current.code) : null;
1895
2029
  if (business && current.level === 'site') {
2030
+ if (canvasMode) { dismissTask(); return; }
1896
2031
  district?.clearSelection();
1897
2032
  openCity(business, null);
1898
2033
  return;
@@ -1911,34 +2046,42 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
1911
2046
  const business = saved.business ? businessOf(saved.business) : null;
1912
2047
  if (!business) return;
1913
2048
  const picked = scene?.selectKey({ code: business.code });
1914
- current.pendingSite = saved.site ?? null;
1915
2049
  openCity(business, picked?.center ?? null);
2050
+ current.pendingSite = saved.site ?? null;
2051
+ if (current.pendingSite && openSiteById(business, current.pendingSite)) current.pendingSite = null;
1916
2052
  }
1917
2053
 
1918
2054
  async function loadWorkSummary(note) {
1919
2055
  const result = await readJson(fetcher, '/api/extensions/world/work-summary');
2056
+ if (disposed) return;
1920
2057
  if (!result.ok) {
1921
- note('仕事', `仕事の要約を読めません(${result.error})。都市を選ぶと、その事業の仕事を読みにいきます。0件ではありません。`, 'warning');
2058
+ note('仕事', `仕事の要約を読めません(${result.error})。都市を選ぶと、その事業の仕事を読みにいきます。0件ではありません。`, 'warning', '仕事の要約:読込失敗');
1922
2059
  return;
1923
2060
  }
1924
2061
  if (result.data?.status === 'not_connected') {
1925
2062
  workConnected = false;
1926
- note('仕事', 'この画面はタスクの正本に接続していないため、都市の中の仕事は描いていません(0件ではありません)。');
2063
+ note('仕事', 'この画面はタスクの正本に接続していないため、都市の中の仕事は描いていません(0件ではありません)。', 'warning', '仕事:未接続(0件ではありません)');
1927
2064
  return;
1928
2065
  }
1929
2066
  if (result.data?.status !== 'ok') {
1930
- note('仕事', `仕事の要約を読めません(${result.data?.reason ?? result.data?.status ?? '理由不明'})。0件ではありません。`, 'warning');
2067
+ note('仕事', `仕事の要約を読めません(${result.data?.reason ?? result.data?.status ?? '理由不明'})。0件ではありません。`, 'warning', '仕事の要約:読めない');
1931
2068
  return;
1932
2069
  }
1933
2070
  workConnected = true;
1934
- const summaries = result.data.businesses ?? {};
2071
+ const summaries = { ...(result.data.businesses ?? {}) };
2072
+ // An omitted business is missing data, not an unremarkable city with zero work.
2073
+ for (const business of knownBusinesses) {
2074
+ if (!summaries[business.code]) summaries[business.code] = { state: 'unavailable', reason: 'summary_missing', open: null, needs_check: null };
2075
+ }
1935
2076
  workSummaries = summaries;
1936
2077
  if (current.level === 'world' && !current.code) showEmptyRail();
1937
2078
  scene?.setWorkSigns(summaries);
1938
2079
  const entries = Object.values(summaries);
1939
2080
  const readable = entries.filter((entry) => entry.state === 'complete' || entry.state === 'partial');
1940
2081
  const unreadable = entries.length - readable.length;
1941
- const open = readable.reduce((sum, entry) => sum + (entry.open ?? 0), 0);
2082
+ const openKnown = readable.every((entry) => Number.isInteger(entry.open) && entry.open >= 0);
2083
+ const open = openKnown ? readable.reduce((sum, entry) => sum + entry.open, 0) : null;
2084
+ const needsKnown = readable.every((entry) => Array.isArray(entry.needs_check));
1942
2085
  const needs = readable.reduce((sum, entry) => sum + (entry.needs_check?.length ?? 0), 0);
1943
2086
  const cities = readable.filter((entry) => entry.needs_check?.length).length;
1944
2087
  const partial = readable.filter((entry) => entry.state === 'partial').length;
@@ -1947,11 +2090,20 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
1947
2090
  for (const entry of readable) for (const [kind, ids] of Object.entries(entry.gaps ?? {})) kinds.set(kind, (kinds.get(kind) ?? 0) + ids.length);
1948
2091
  const kindText = [...kinds].sort((a, b) => b[1] - a[1]).map(([kind, count]) => `${GAP_SHORT_TEXT[kind] ?? kind}${count}`).join('・');
1949
2092
  const parts = readable.length
1950
- ? [`未完了の仕事${open}件のうち${needs}件(${cities}事業)に、記録から把握できていないことがあります(?の札${kindText ? `。内訳:${kindText}` : ''})`]
2093
+ ? [needsKnown
2094
+ ? `未完了の仕事${open === null ? '(件数未確認)' : `${open}件`}のうち${needs}件(${cities}事業)に、記録から把握できていないことがあります(?の札${kindText ? `。内訳:${kindText}` : ''})`
2095
+ : '仕事の要約に記録の要確認件数がなく、件数は未確認です']
1951
2096
  : [];
1952
2097
  if (unreadable) parts.push(`${readable.length ? '' : 'どの事業の仕事も読めませんでした。'}仕事を読めなかった事業${unreadable}件(灰色の札。0件ではありません)`);
1953
2098
  if (partial) parts.push(`一部だけ読めた事業${partial}件`);
1954
2099
  note('仕事', `${parts.join('。')}。「止まっている」という意味ではありません(${shortTimeText(result.data.as_of)}時点)。`);
2100
+ if (canvasMode) {
2101
+ if (!needsKnown) note('記録の確認件数', '仕事の要約に記録の要確認件数がありません。確認事項が0件とは判断していません。', 'warning', '記録の要確認件数:未確認');
2102
+ if (!openKnown) note('仕事の件数', '読めた仕事の要約に未完了件数の記録がありません。0件とは判断していません。', 'warning', '未完了の件数:未確認');
2103
+ if (unreadable) note('仕事の読込', `仕事を読めなかった事業${unreadable}件。0件ではありません。`, 'warning', `仕事が読めない事業 ${unreadable}件`);
2104
+ if (partial) note('仕事の範囲', `一部だけ読めた事業${partial}件。件数は読めた範囲です。`, 'warning', `仕事が一部だけ読めた事業 ${partial}件`);
2105
+ if (needs) note('仕事の記録', `読めた未完了の仕事${needs}件(${cities}事業)に、記録から把握できていないことがあります。仕事の失敗や停止を意味しません。`, 'attention', `記録の要確認 ${needs}件・${cities}事業`);
2106
+ }
1955
2107
  }
1956
2108
 
1957
2109
  function shortTimeText(value) {
@@ -2055,6 +2207,7 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
2055
2207
 
2056
2208
  async function load() {
2057
2209
  const businessesResult = await readJson(fetcher, '/api/extensions/world/businesses');
2210
+ if (disposed) return;
2058
2211
  // A world drawn from an organization's Graph shows only that organization's judgments: the host keeps
2059
2212
  // the others (another company's work, or unknown) back and sends only how many it kept back.
2060
2213
  const organizationScoped = businessesResult.ok && businessesResult.data?.status === 'ok'
@@ -2073,8 +2226,13 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
2073
2226
  readJson(fetcher, '/api/extensions/world/judgment-places'),
2074
2227
  ]);
2075
2228
  }
2229
+ if (disposed) return;
2076
2230
  notices.replaceChildren();
2077
- const note = (label, textValue, tone = 'info') => {
2231
+ const note = (label, textValue, tone = 'info', summary = null) => {
2232
+ if (canvasUI) {
2233
+ setCanvasNote(`${label}:${summary ?? 'detail'}`, { label, text: textValue, tone, summary });
2234
+ return;
2235
+ }
2078
2236
  const line = el(doc, 'p', { className: `bb-world-hud-line is-${tone}`, attrs: tone === 'info' ? {} : { role: 'alert' } });
2079
2237
  line.append(el(doc, 'strong', { text: label }), el(doc, 'span', { text: textValue }));
2080
2238
  notices.append(line);
@@ -2084,7 +2242,7 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
2084
2242
  if (businessPayload?.status === 'ok' && businessPayload.vocabulary) worldVocabulary = businessPayload.vocabulary;
2085
2243
  if (!businessesResult.ok || businessPayload?.status !== 'ok') {
2086
2244
  const reason = businessesResult.ok ? `${BUSINESS_STATE_TEXT[businessPayload?.status] ?? '状態不明'}(${businessPayload?.reason ?? '理由不明'})` : `取得に失敗しました(${businessesResult.error})`;
2087
- note('事業', `${reason}。事業は0件ではありません。`, 'warning');
2245
+ note('事業', `${reason}。事業は0件ではありません。`, 'warning', '事業:読めない(0件ではありません)');
2088
2246
  } else {
2089
2247
  const extra = [];
2090
2248
  if (businessPayload.unplaced.length) extra.push(`親の事業がGraphに無い案件${businessPayload.unplaced.length}件は描いていません`);
@@ -2092,7 +2250,11 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
2092
2250
  const sourceText = businessPayload.source.authority === 'organization_graph' ? `組織のGraph(${businessPayload.source.server})` : 'このMacのGraph';
2093
2251
  const unclassified = businesses.filter((business) => !business.kind).length;
2094
2252
  if (unclassified) extra.push(`分類の無い事業${unclassified}件は「分類なし」として描いています`);
2095
- if (businessPayload.vocabulary?.terms === 'unavailable') extra.push('組織の用語を読めないため、分類の名前は語彙ファイルか値のままです');
2253
+ if (businessPayload.vocabulary?.terms === 'unavailable') {
2254
+ extra.push('組織の用語を読めないため、分類の名前は語彙ファイルか値のままです');
2255
+ if (canvasMode) note('用語', '組織の用語を読めないため、分類の名前は語彙ファイルか値のままです。', 'warning', '組織の用語:読めない');
2256
+ }
2257
+ if (canvasMode && businessPayload.unplaced.length) note('案件', '親の事業がGraphに無い案件は描いていません。', 'warning', `親の事業が見つからない案件 ${businessPayload.unplaced.length}件`);
2096
2258
  note('事業', `${sourceText}の事業${businesses.length}件・案件${businesses.reduce((sum, business) => sum + business.engagements.length, 0)}件。${extra.join('。')}${extra.length ? '。' : ''}`);
2097
2259
  }
2098
2260
  const home = homeResult.ok ? homeResult.data : null;
@@ -2100,8 +2262,8 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
2100
2262
  const rows = readable ? home.delegation_map.rows : [];
2101
2263
  if (!homeResult.ok || !readable) {
2102
2264
  const reason = homeResult.ok ? home?.reason ?? home?.status ?? '状態不明' : homeResult.error;
2103
- if (JUDGMENT_UNAVAILABLE_TEXT[reason]) note('判断', `${JUDGMENT_UNAVAILABLE_TEXT[reason]}。事業だけを描いています。`);
2104
- else note('判断', `判断の記録を読めません(${reason})。0件ではありません。`, 'warning');
2265
+ if (JUDGMENT_UNAVAILABLE_TEXT[reason]) note('判断', `${JUDGMENT_UNAVAILABLE_TEXT[reason]}。事業だけを描いています。`, 'warning', '判断の記録:未接続');
2266
+ else note('判断', `判断の記録を読めません(${reason})。0件ではありません。`, 'warning', '判断の記録:読めない');
2105
2267
  } else {
2106
2268
  proofs = proofIndex(home);
2107
2269
  judgmentsConnected = true;
@@ -2113,20 +2275,30 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
2113
2275
  placement = groupJudgmentPlaces(known, businesses);
2114
2276
  const placed = known.length - placement.unplaced.length;
2115
2277
  const kept = withheld ? `この組織の事業で作業したと確かめられない判断${withheld}件は、この世界には出していません(「今日」で見られます)。` : '';
2116
- note('判断', `${home.delegation_map.judged}件のうち${placed}件を作業した事業の判断所に、${placement.unplaced.length}件は事業が分からないため広場に。${kept}今あなたを待っている判断${waiting}件${waiting ? '(光の柱)' : ''}。`, waiting ? 'attention' : 'info');
2278
+ note('判断', `${home.delegation_map.judged}件のうち${placed}件を作業した事業の判断所に、${placement.unplaced.length}件は事業が分からないため広場に。${kept}今あなたを待っている判断${waiting}件${waiting ? '(光の柱)' : ''}。`, waiting ? 'attention' : 'info', waiting ? `あなたを待っている判断 ${waiting}件` : null);
2117
2279
  } else {
2118
- note('判断', `${home.delegation_map.judged}件。作業した場所を読めないため、事業には置かず広場の種類別だけで描いています(${placesResult.ok ? placesResult.data?.reason ?? '理由不明' : placesResult.error})。今あなたを待っている判断${waiting}件。`, 'warning');
2280
+ note('判断', `${home.delegation_map.judged}件。作業した場所を読めないため、事業には置かず広場の種類別だけで描いています(${placesResult.ok ? placesResult.data?.reason ?? '理由不明' : placesResult.error})。今あなたを待っている判断${waiting}件。`, 'warning', '判断の作業場所:読めない');
2119
2281
  }
2120
2282
  }
2121
2283
  const rowItems = new Map(rows.map((row) => [row.key, Array.isArray(row.items) ? row.items : []]));
2122
2284
 
2285
+ knownBusinesses = businesses;
2286
+ canvasUI?.setCities(businesses, current.code);
2123
2287
  if (!webglAvailable(doc)) {
2124
- note('表示', 'この環境では3Dを表示できないため、一覧で出しています。', 'warning');
2125
- stage.hidden = true;
2126
- renderFallbackList(doc, wrap, businesses, rows);
2288
+ note('表示', 'この環境では3Dを表示できないため、一覧で出しています。', 'warning', '3D表示を利用できません・一覧表示');
2289
+ if (canvasMode) {
2290
+ renderFallbackList(doc, stage, businesses, rows);
2291
+ showEmptyRail();
2292
+ restoreSelection();
2293
+ void loadWorkSummary(note);
2294
+ } else {
2295
+ stage.hidden = true;
2296
+ renderFallbackList(doc, wrap, businesses, rows);
2297
+ }
2127
2298
  return;
2128
2299
  }
2129
2300
  scene = createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear: clearAll, onEscape: goUp });
2301
+ scene.setSky(SKY_MODES[skyIndex][0]);
2130
2302
  scene.buildPlaza(rows, rowItems, (row) => (rowItems.get(row.key) ?? []).filter((ref) => isWaiting(proofs.get(ref.decision_attempt_id))).length, Date.now());
2131
2303
  scene.buildCities(layoutWorld(businesses), placement.byBusiness);
2132
2304
  showChangesSinceLastVisit(note, businesses, rows);
@@ -2178,7 +2350,11 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
2178
2350
  void load();
2179
2351
  return {
2180
2352
  dispose() {
2353
+ disposed = true;
2181
2354
  doc.removeEventListener?.('visibilitychange', onVisible);
2355
+ wrap.removeEventListener?.('keydown', onRootEscape, true);
2356
+ canvasUI?.dispose();
2357
+ district?.dispose();
2182
2358
  scene?.dispose();
2183
2359
  },
2184
2360
  };