@unson/brainbase-mcp 0.9.0 → 0.10.0

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.
@@ -564,7 +564,13 @@ export function createGraphWebModule(context) {
564
564
  'graph-view-shared.js',
565
565
  'graph-view-shared.css',
566
566
  'graph-projects-view.js',
567
+ 'graph-own-share.js',
567
568
  'graph-projects-view.css',
569
+ 'project-workspace.js',
570
+ 'project-workspace.css',
571
+ 'project-graph.js',
572
+ 'project-graph-entry.js',
573
+ 'project-graph-vendor.js',
568
574
  'graph-registry-view.js',
569
575
  'graph-registry-view.css'
570
576
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unson/brainbase-mcp",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Local-first Brainbase judgment OS that lets supported AI agents reuse a user's approved goals, priorities, decisions, and judgment criteria.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -262,7 +262,11 @@
262
262
  "types": "./dist/graph-foundation-reader.d.ts",
263
263
  "import": "./dist/graph-foundation-reader.js"
264
264
  },
265
- "./contracts/foundation/graph-history.sql": "./contracts/foundation/graph-history.sql"
265
+ "./contracts/foundation/graph-history.sql": "./contracts/foundation/graph-history.sql",
266
+ "./ui/project-workspace": "./ui/project-workspace.js",
267
+ "./ui/project-graph": "./ui/project-graph.js",
268
+ "./ui/project-workspace.css": "./ui/project-workspace.css",
269
+ "./ui/project-graph-vendor.js": "./ui/project-graph-vendor.js"
266
270
  },
267
271
  "bin": {
268
272
  "brainbase-mcp": "dist/index.js",
@@ -308,10 +312,13 @@
308
312
  "release:plan": "node scripts/npm-release.mjs plan",
309
313
  "release:validate": "node scripts/npm-release.mjs validate",
310
314
  "release:publish": "node scripts/npm-release.mjs publish",
311
- "release:verify": "node scripts/npm-release.mjs verify"
315
+ "release:verify": "node scripts/npm-release.mjs verify",
316
+ "build:graph": "node scripts/build-project-graph.mjs"
312
317
  },
313
318
  "dependencies": {
314
319
  "@modelcontextprotocol/sdk": "^1.30.0",
320
+ "graphology": "0.26.0",
321
+ "sigma": "3.0.3",
315
322
  "zod": "^3.25.56"
316
323
  },
317
324
  "overrides": {
@@ -320,6 +327,7 @@
320
327
  "devDependencies": {
321
328
  "@electric-sql/pglite": "^0.5.8",
322
329
  "@types/node": "^22.15.29",
330
+ "esbuild": "0.28.2",
323
331
  "tsx": "^4.19.4",
324
332
  "typescript": "^5.8.3",
325
333
  "vitepress": "^1.6.4",
@@ -328,5 +336,5 @@
328
336
  "engines": {
329
337
  "node": ">=20"
330
338
  },
331
- "gitHead": "323aa422437a796dc76c9f4d2da86a62e51141ce"
339
+ "gitHead": "56a13a2c780fb0390c804bddfb55f00c2d5a06ed"
332
340
  }
@@ -10,6 +10,55 @@
10
10
  grid-template-columns: minmax(0, 1.3fr) minmax(0, 1.2fr) minmax(0, .8fr) minmax(0, .6fr) 64px minmax(0, 1.2fr);
11
11
  }
12
12
 
13
+ /* The selected project's knowledge workspace is the reading surface. Keep the
14
+ registration context compact so the workspace tabs are not pushed below the
15
+ first viewport by the legacy ledger chrome. */
16
+ .bb-gp > .bb-ws-page {
17
+ gap: 0;
18
+ }
19
+
20
+ .bb-gp > .bb-ws-page .bb-ws-breadcrumb {
21
+ margin-bottom: 10px;
22
+ }
23
+
24
+ .bb-gp > .bb-ws-page .bb-ws-page-head {
25
+ gap: 8px 20px;
26
+ }
27
+
28
+ .bb-gp > .bb-ws-page .bb-ws-page-head h1 {
29
+ font-size: clamp(28px, 3vw, 36px);
30
+ }
31
+
32
+ .bb-gp > .bb-ws-page .bb-ws-lead {
33
+ margin-top: 6px;
34
+ line-height: 1.45;
35
+ }
36
+
37
+ .bb-gp > .bb-ws-page .bb-ws-source {
38
+ min-height: 26px;
39
+ padding: 5px 8px;
40
+ }
41
+
42
+ .bb-gp > .bb-ws-notice {
43
+ gap: 8px;
44
+ margin-block: 12px;
45
+ padding: 9px 12px;
46
+ line-height: 1.45;
47
+ }
48
+
49
+ .bb-gp > .bb-ws-summary {
50
+ margin-bottom: 14px;
51
+ }
52
+
53
+ .bb-gp > .bb-ws-summary .bb-ws-metric {
54
+ padding-block: 10px;
55
+ }
56
+
57
+ .bb-gp > .bb-ws-summary .bb-ws-metric strong {
58
+ margin-block: 3px 2px;
59
+ font-size: 24px;
60
+ }
61
+
13
62
  @media (max-width: 760px) {
14
63
  .bb-graph-project-ledger .bb-ws-ledger-row {
15
64
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr) 68px;
@@ -21,3 +70,20 @@
21
70
  display: none;
22
71
  }
23
72
  }
73
+
74
+ /* Author display rules must not override the HTML hidden state. */
75
+ .bb-gp [hidden] { display: none; }
76
+
77
+ .bb-shell:has([data-project-graph-active="true"]) > .bb-shell-workspace {
78
+ grid-column: 2 / -1;
79
+ }
80
+ .bb-shell:has([data-project-graph-active="true"]) > .bb-shell-rail {
81
+ display: none;
82
+ }
83
+ [data-project-graph-active="true"] .bb-pkw { margin-top: 0; }
84
+
85
+ @media (max-width: 760px) {
86
+ .bb-shell:has([data-project-graph-active="true"]) > .bb-shell-workspace {
87
+ grid-column: 1 / -1;
88
+ }
89
+ }
@@ -54,6 +54,8 @@ import {
54
54
  graphHostEmptyNotice,
55
55
  } from './graph-view-shared.js';
56
56
  import { graphOwnShareSource, ownProjectDetail, ownShareSection, ownShareStateNotice } from './graph-own-share.js';
57
+ import { createProjectKnowledgeWorkspace, normalizeProjectContext } from './project-workspace.js';
58
+ import { mountProjectGraph as defaultMountProjectGraph } from './project-graph.js';
57
59
  import {
58
60
  workspaceActions,
59
61
  workspaceButton,
@@ -196,6 +198,13 @@ function failureText(error) {
196
198
  * labelled 「引き継いだ自分の分(組織には未反映)」 unless `label` says otherwise. They are never
197
199
  * added to the ledger, the metrics or the rail, and a failed read shows only in that section.
198
200
  * Without it nothing of the kind is drawn or read.
201
+ * @param {(projectId: string) => Promise<object>} [options.loadProjectContext]
202
+ * Optional read-only supplementary sections (tasks/knowledge). A failed
203
+ * context read is kept separate from the Graph detail read.
204
+ * @param {(container: Element, options: object) => { destroy?: Function }} [options.mountProjectGraph]
205
+ * Host-injected Sigma.js graph mount. When omitted, the shared browser
206
+ * entry is used. The workspace keeps an accessible relation list when
207
+ * mounting is unavailable or fails.
199
208
  * @returns The controller: `state`, `correction`, `render()`, `load()`, `loadDetail(id)`,
200
209
  * `selectProject(id)`, `openProject(id)`, `select(id)` for the host's own navigation, and with
201
210
  * `ownShare` `loadOwn()` and `selectOwnProject(id)`.
@@ -222,10 +231,13 @@ export function createGraphProjectsView({
222
231
  selectedId: initialSelectedId,
223
232
  onSelect,
224
233
  ownShare,
234
+ loadProjectContext,
235
+ mountProjectGraph,
225
236
  } = {}) {
226
237
  if (!root) throw new TypeError('root is required');
227
238
  const doc = getDocument(explicitDocument);
228
239
  const graphClient = createGraphClient({ fetcher, basePath, token, ...(tokenHeader ? { tokenHeader } : {}) });
240
+ const projectGraphMount = typeof mountProjectGraph === 'function' ? mountProjectGraph : defaultMountProjectGraph;
229
241
  const own = graphOwnShareSource(ownShare, { fetcher });
230
242
  const writable = canCorrect !== false;
231
243
  const client = writable ? graphClient : readOnlyGraphClient(graphClient);
@@ -246,11 +258,16 @@ export function createGraphProjectsView({
246
258
  list: { state: 'loading' },
247
259
  selectedId: textOrNull(initialSelectedId),
248
260
  detail: null,
261
+ context: null,
249
262
  panelAt: null,
250
263
  // The owner's own share: its own list and selection, apart from the host's.
251
264
  own: own ? { list: { state: 'loading' }, selectedId: null, detail: null } : null,
252
265
  };
253
266
  let focusRow = null;
267
+ let knowledgeWorkspace = null;
268
+ let contextRequest = 0;
269
+ let detailRequest = 0;
270
+ let viewDestroyed = false;
254
271
 
255
272
  const correction = createGraphCorrection({
256
273
  client,
@@ -317,6 +334,30 @@ export function createGraphProjectsView({
317
334
  }
318
335
  }
319
336
 
337
+ /** Reads supplementary sections independently of the Graph project detail. */
338
+ async function loadProjectContextFor(id, { keep = false } = {}) {
339
+ if (viewDestroyed) return state.context;
340
+ if (typeof loadProjectContext !== 'function') {
341
+ if (state.context?.projectId !== id) state.context = null;
342
+ return null;
343
+ }
344
+ if (keep && state.context?.projectId === id && ['ok', 'failed', 'unavailable'].includes(state.context.state)) return state.context;
345
+ const request = ++contextRequest;
346
+ state.context = normalizeProjectContext(id, { state: 'loading' });
347
+ controller.render();
348
+ let result;
349
+ try {
350
+ result = await loadProjectContext(id);
351
+ } catch (error) {
352
+ result = { state: 'failed', message: `補足情報を読み取れませんでした(${failureText(error)})。` };
353
+ }
354
+ const normalized = normalizeProjectContext(id, result);
355
+ if (viewDestroyed || request !== contextRequest || state.selectedId !== id) return state.context;
356
+ state.context = normalized;
357
+ controller.render();
358
+ return state.context;
359
+ }
360
+
320
361
  // -------------------------------------------------------------------------
321
362
  // Workspace
322
363
 
@@ -366,19 +407,51 @@ export function createGraphProjectsView({
366
407
  }
367
408
 
368
409
  function renderHostWorkspace() {
369
- const children = [workspacePageHeader(doc, {
410
+ const pageHeader = workspacePageHeader(doc, {
370
411
  crumbs: context.crumbs,
371
412
  title: 'プロジェクトと関係者',
372
- lead: writable ? 'どのプロジェクトに誰がどう関わっているかを確かめ、誤りを直します。' : 'どのプロジェクトに誰がどう関わっているかを確かめます。',
413
+ lead: writable ? '目的・関係者・判断・根拠を、記録からたどり、必要な記録を直します。' : '目的・関係者・判断・根拠を、記録からたどります。',
373
414
  source: context.source,
374
415
  actions: workspaceHostActions(pageActions),
375
- })];
416
+ });
417
+ // These blocks belong to the legacy registration surface. The knowledge
418
+ // workspace owns the first viewport while its graph tab is active, so the
419
+ // old framing must not keep pushing the graph below the fold.
420
+ const legacyChrome = [pageHeader];
421
+ const children = [pageHeader];
376
422
  const dir = state.list.state === 'ok' ? textOrNull(state.list.payload.source?.dataDir) : null;
377
- children.push(workspaceHostNotice(doc, sourceNotice) ?? workspaceNotice(doc, {
423
+ const sourceBlock = workspaceHostNotice(doc, sourceNotice) ?? workspaceNotice(doc, {
378
424
  label: '出典',
379
425
  text: `このMacのGraph${dir ? `(${dir})` : ''}から読み、ここで直した内容もそこへ保存します。関係者は、プロジェクトに参加している人や責任を持つ人の記録で、ログインや共有の設定ではありません。`,
380
- }));
381
- if (state.detail?.state === 'ok') children.push(renderGraphIssues(doc, state.detail.payload.issues));
426
+ });
427
+ children.push(sourceBlock);
428
+ legacyChrome.push(sourceBlock);
429
+ if (state.detail?.state === 'ok') {
430
+ const issues = renderGraphIssues(doc, state.detail.payload.issues);
431
+ if (issues) {
432
+ children.push(issues);
433
+ legacyChrome.push(issues);
434
+ }
435
+ }
436
+
437
+ const setKnowledgeTabChrome = (tab) => {
438
+ const graphActive = tab === 'graph';
439
+ root.setAttribute('data-project-graph-active', String(graphActive));
440
+ for (const node of legacyChrome) {
441
+ if (node) node.hidden = graphActive;
442
+ }
443
+ if (!rail) return;
444
+ rail.hidden = graphActive;
445
+ if (graphActive) rail.setAttribute('aria-hidden', 'true');
446
+ else rail.removeAttribute('aria-hidden');
447
+ // In the organization shell the slot's parent is the actual grid
448
+ // column. Hide that column as well; hiding only the slot leaves the
449
+ // empty 380px rail in the layout.
450
+ const parent = rail.parentElement;
451
+ const railColumn = rail.closest?.('.bb-shell-rail')
452
+ ?? (typeof parent?.className === 'string' && parent.className.split(/\s+/u).includes('bb-shell-rail') ? parent : null);
453
+ if (railColumn) railColumn.hidden = graphActive;
454
+ };
382
455
 
383
456
  const stateNotice = graphStateNotice(doc, state.list, { onRetry: () => controller.load(), loadingText: 'プロジェクトを読み込んでいます。' });
384
457
  if (stateNotice) {
@@ -388,13 +461,32 @@ export function createGraphProjectsView({
388
461
  const { payload } = state.list;
389
462
  const summary = summarizeProjects(payload.projects);
390
463
  const extras = hostMetrics(payload);
391
- children.push(workspaceMetrics(doc, [
464
+ const summaryBlock = workspaceMetrics(doc, [
392
465
  { label: 'プロジェクト', value: summary.active, note: `有効なもの(全${summary.total}件)` },
393
466
  { label: '進行中', value: summary.inProgress, note: '状態が「進行中」の有効なもの' },
394
467
  { label: '関係者', value: summary.people, note: summary.people === null ? '人数を確かめられません' : '有効な関わりがある人(重複なし)' },
395
468
  ...extras.metrics,
396
- ], { ariaLabel: 'プロジェクトの集計' }));
397
- if (extras.failure) children.push(extras.failure);
469
+ ], { ariaLabel: 'プロジェクトの集計' });
470
+ children.push(summaryBlock);
471
+ legacyChrome.push(summaryBlock);
472
+ if (extras.failure) {
473
+ children.push(extras.failure);
474
+ legacyChrome.push(extras.failure);
475
+ }
476
+
477
+ // The selected project is the primary reading surface. Keep the ledger
478
+ // below it as a switcher so a large project list cannot bury its context.
479
+ if (state.detail?.id === state.selectedId && state.detail.state === 'ok') {
480
+ knowledgeWorkspace = createProjectKnowledgeWorkspace({
481
+ document: doc,
482
+ detail: state.detail.payload,
483
+ context: state.context,
484
+ mountGraph: projectGraphMount,
485
+ readEntity: (id) => graphClient.read(`/entities/${encodeURIComponent(id)}`),
486
+ onTabChange: setKnowledgeTabChrome,
487
+ });
488
+ children.push(knowledgeWorkspace.element);
489
+ }
398
490
 
399
491
  if (payload.projects.length === 0) {
400
492
  const hostEmpty = graphHostEmptyNotice(doc, emptyNotice);
@@ -615,6 +707,8 @@ export function createGraphProjectsView({
615
707
  if (correction.form) correction.close();
616
708
  state.panelAt = null;
617
709
  state.detail = null;
710
+ state.context = null;
711
+ contextRequest += 1;
618
712
  state.selectedId = projects[0]?.id ?? null;
619
713
  if (state.selectedId) {
620
714
  const reading = controller.loadDetail(state.selectedId);
@@ -635,6 +729,11 @@ export function createGraphProjectsView({
635
729
  get state() { return state; },
636
730
  get correction() { return correction; },
637
731
  render() {
732
+ if (viewDestroyed) return controller;
733
+ if (knowledgeWorkspace) {
734
+ knowledgeWorkspace.destroy();
735
+ knowledgeWorkspace = null;
736
+ }
638
737
  layout.render(renderWorkspace(), renderRail());
639
738
  if (focusRow) {
640
739
  focusLedgerRow(root, focusRow);
@@ -642,6 +741,15 @@ export function createGraphProjectsView({
642
741
  }
643
742
  return controller;
644
743
  },
744
+ destroy() {
745
+ if (viewDestroyed) return;
746
+ viewDestroyed = true;
747
+ detailRequest += 1;
748
+ contextRequest += 1;
749
+ knowledgeWorkspace?.destroy();
750
+ knowledgeWorkspace = null;
751
+ correction.close();
752
+ },
645
753
  /**
646
754
  * Reads the list; the first project is selected when none is. `keep` shows the current list until
647
755
  * the new one has been read. The owner's own share is read alongside, unless `keep` finds it read.
@@ -688,17 +796,23 @@ export function createGraphProjectsView({
688
796
  },
689
797
  /** `keep` shows the current detail until the new one has been read. */
690
798
  async loadDetail(id, { keep = false } = {}) {
799
+ if (viewDestroyed) return state.detail;
800
+ const request = ++detailRequest;
691
801
  const previous = state.detail;
692
802
  if (!(keep && previous?.id === id && previous.state === 'ok')) {
693
803
  state.detail = { id, state: 'loading' };
694
804
  controller.render();
695
805
  }
806
+ const contextReading = loadProjectContextFor(id, { keep });
696
807
  const result = await client.read(`/projects/${encodeURIComponent(id)}`);
697
- if (state.selectedId !== id) return state.detail;
808
+ if (viewDestroyed || request !== detailRequest || state.selectedId !== id) return state.detail;
698
809
  state.detail = result.state === 'error' && result.status === 404
699
810
  ? { id, state: 'not_found', reason: 'このプロジェクトは見つかりません。一覧を読み直してください。' }
700
811
  : { id, ...normalizeProjectDetail(result) };
701
812
  controller.render();
813
+ // The context is intentionally independent. Its completion may trigger
814
+ // one more render, but never changes the Graph detail result.
815
+ void contextReading;
702
816
  return state.detail;
703
817
  },
704
818
  /** Selects a row: the rail shows that project, and an open correction is closed. */
@@ -706,6 +820,8 @@ export function createGraphProjectsView({
706
820
  if (correction.form) correction.close();
707
821
  state.panelAt = null;
708
822
  state.selectedId = id;
823
+ state.context = null;
824
+ contextRequest += 1;
709
825
  return controller.loadDetail(id, { keep: true });
710
826
  },
711
827
  /** The same as selecting the project's row. */
@@ -0,0 +1,290 @@
1
+ /*
2
+ * Browser-only Sigma entry point.
3
+ *
4
+ * Keep all DOM/WebGL work behind this module. The sibling contract module is
5
+ * intentionally safe to import from Node tests and from hosts that render an
6
+ * accessible list instead of a graph.
7
+ */
8
+
9
+ import { MultiDirectedGraph, Sigma } from './project-graph-vendor.js';
10
+ import {
11
+ PROJECT_GRAPH_TYPE_COLORS,
12
+ createProjectGraphModel,
13
+ projectGraphNeighborIds,
14
+ } from './project-graph.js';
15
+
16
+ const DEFAULT_EDGE_COLOR = '#cbd5e1';
17
+ const SELECTED_COLOR = '#312e81';
18
+ const UNRELATED_COLOR = '#cbd5e1';
19
+ const UNRELATED_EDGE_COLOR = '#e2e8f0';
20
+
21
+ function isRecord(value) {
22
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
23
+ }
24
+
25
+ function asDocument(container) {
26
+ return container?.ownerDocument || (typeof document !== 'undefined' ? document : null);
27
+ }
28
+
29
+ function abortError() {
30
+ const error = new Error('project_graph_mount_aborted');
31
+ error.name = 'AbortError';
32
+ return error;
33
+ }
34
+
35
+ function assertMountTarget(container) {
36
+ if (!container || typeof container.appendChild !== 'function') {
37
+ throw new TypeError('mountProjectGraph requires a DOM container');
38
+ }
39
+ const doc = asDocument(container);
40
+ if (!doc || typeof doc.createElement !== 'function') {
41
+ throw new TypeError('mountProjectGraph requires a DOM document');
42
+ }
43
+ return doc;
44
+ }
45
+
46
+ function setText(element, text) {
47
+ if (element) element.textContent = text;
48
+ }
49
+
50
+ function makeButton(doc, label, ariaLabel) {
51
+ const button = doc.createElement('button');
52
+ button.type = 'button';
53
+ button.className = 'bb-project-graph-control';
54
+ button.setAttribute('aria-label', ariaLabel);
55
+ button.textContent = label;
56
+ return button;
57
+ }
58
+
59
+ function makeGraph(model) {
60
+ const graph = new MultiDirectedGraph();
61
+ for (const node of model.nodes) {
62
+ graph.addNode(node.id, {
63
+ x: Number.isFinite(node.x) ? node.x : 0,
64
+ y: Number.isFinite(node.y) ? node.y : 0,
65
+ size: node.size,
66
+ label: node.label,
67
+ color: node.color || PROJECT_GRAPH_TYPE_COLORS.unknown,
68
+ baseColor: node.color || PROJECT_GRAPH_TYPE_COLORS.unknown,
69
+ semanticType: node.type,
70
+ // Sigma's renderer program type is deliberately separate from the
71
+ // ontology type. Unknown ontology types must still draw as circles.
72
+ type: 'circle',
73
+ highlighted: false,
74
+ });
75
+ }
76
+ for (const edge of model.edges) {
77
+ graph.addDirectedEdgeWithKey(edge.id, edge.source, edge.target, {
78
+ label: edge.label || '',
79
+ color: edge.color || DEFAULT_EDGE_COLOR,
80
+ baseColor: edge.color || DEFAULT_EDGE_COLOR,
81
+ size: 1,
82
+ type: 'arrow',
83
+ });
84
+ }
85
+ return graph;
86
+ }
87
+
88
+ function edgeTouches(edge, nodeId, neighborIds) {
89
+ if (!nodeId) return false;
90
+ return edge.source === nodeId || edge.target === nodeId ||
91
+ (neighborIds.has(edge.source) && neighborIds.has(edge.target));
92
+ }
93
+
94
+ /**
95
+ * Mounts one project graph and returns an idempotent resource handle.
96
+ *
97
+ * The graph is built only from the supplied projection. Invalid endpoints have
98
+ * already been removed by createProjectGraphModel, so this renderer never
99
+ * invents a node merely to make an edge drawable.
100
+ */
101
+ export function mountProjectGraph(container, options = {}) {
102
+ const doc = assertMountTarget(container);
103
+ const signal = options?.signal;
104
+ if (signal?.aborted) throw abortError();
105
+
106
+ const model = createProjectGraphModel(
107
+ { nodes: options?.nodes, edges: options?.edges },
108
+ { selectedId: options?.selectedId || null },
109
+ );
110
+ const mountRoot = doc.createElement('div');
111
+ mountRoot.className = 'bb-project-graph';
112
+ mountRoot.setAttribute('data-graph-contract', 'brainbase.project-graph.v1');
113
+
114
+ const toolbar = doc.createElement('div');
115
+ toolbar.className = 'bb-project-graph-controls';
116
+ toolbar.setAttribute('role', 'toolbar');
117
+ toolbar.setAttribute('aria-label', 'グラフ表示の操作');
118
+ const zoomIn = makeButton(doc, '+', 'グラフを拡大');
119
+ const zoomOut = makeButton(doc, '−', 'グラフを縮小');
120
+ const reset = makeButton(doc, '全体', 'グラフ全体を表示');
121
+ toolbar.append(zoomIn, zoomOut, reset);
122
+
123
+ const status = doc.createElement('p');
124
+ status.className = 'bb-project-graph-status';
125
+ status.setAttribute('aria-live', 'polite');
126
+ status.setAttribute('aria-atomic', 'true');
127
+
128
+ const surface = doc.createElement('div');
129
+ surface.className = 'bb-project-graph-surface';
130
+ surface.setAttribute('role', 'img');
131
+ surface.setAttribute('aria-label', 'プロジェクトの関係グラフ');
132
+ surface.style.position = 'relative';
133
+ surface.style.width = '100%';
134
+ surface.style.height = '100%';
135
+
136
+ mountRoot.append(toolbar, status, surface);
137
+ container.appendChild(mountRoot);
138
+
139
+ let renderer;
140
+ let selectedId = null;
141
+ let neighborIds = new Set();
142
+ let destroyed = false;
143
+ let abortListener = null;
144
+ const nodeById = new Map(model.nodes.map((node) => [node.id, node]));
145
+ const edgeById = new Map(model.edges.map((edge) => [edge.id, edge]));
146
+ let onZoomIn;
147
+ let onZoomOut;
148
+ let onReset;
149
+
150
+ const getNeighbors = () => neighborIds;
151
+
152
+ const nodeReducer = (nodeId, data) => {
153
+ const base = { ...data };
154
+ const neighbors = getNeighbors();
155
+ if (!selectedId) return base;
156
+ if (nodeId === selectedId) {
157
+ return {
158
+ ...base,
159
+ color: SELECTED_COLOR,
160
+ size: Math.max(11, (base.size || 9) + 4),
161
+ highlighted: true,
162
+ forceLabel: true,
163
+ };
164
+ }
165
+ if (neighbors.has(nodeId)) {
166
+ return {
167
+ ...base,
168
+ color: base.baseColor || PROJECT_GRAPH_TYPE_COLORS.unknown,
169
+ size: Math.max(9, (base.size || 9) + 1),
170
+ highlighted: true,
171
+ forceLabel: true,
172
+ };
173
+ }
174
+ return {
175
+ ...base,
176
+ color: UNRELATED_COLOR,
177
+ size: Math.max(5, (base.size || 9) * 0.72),
178
+ label: null,
179
+ forceLabel: false,
180
+ highlighted: false,
181
+ };
182
+ };
183
+
184
+ const edgeReducer = (edgeId, data) => {
185
+ const edge = edgeById.get(edgeId);
186
+ const base = { ...data };
187
+ if (!selectedId || !edge) return { ...base, label: null, color: base.baseColor || DEFAULT_EDGE_COLOR };
188
+ const neighbors = getNeighbors();
189
+ if (!edgeTouches(edge, selectedId, neighbors)) {
190
+ return { ...base, label: null, color: UNRELATED_EDGE_COLOR, size: 0.5 };
191
+ }
192
+ return {
193
+ ...base,
194
+ label: edge.label || null,
195
+ forceLabel: Boolean(edge.label),
196
+ color: edge.color || DEFAULT_EDGE_COLOR,
197
+ size: 1.5,
198
+ };
199
+ };
200
+
201
+ const updateStatus = () => {
202
+ if (!selectedId) {
203
+ setText(status, `${model.nodes.length}件の対象、${model.edges.length}件の関係。点を選択すると周辺を強調します。`);
204
+ return;
205
+ }
206
+ const selected = nodeById.get(selectedId);
207
+ const neighbors = getNeighbors();
208
+ setText(status, `${selected?.label || selectedId}を選択中。関連する対象${neighbors.size}件を強調しています。`);
209
+ };
210
+
211
+ const notifySelection = () => {
212
+ if (typeof options?.onSelect === 'function') options.onSelect(selectedId);
213
+ };
214
+
215
+ const setSelected = (nextId, { notify = true } = {}) => {
216
+ if (destroyed) return;
217
+ selectedId = nextId && nodeById.has(nextId) ? nextId : null;
218
+ neighborIds = projectGraphNeighborIds(model.nodes, model.edges, selectedId);
219
+ updateStatus();
220
+ if (renderer) renderer.refresh();
221
+ if (notify) notifySelection();
222
+ };
223
+
224
+ try {
225
+ renderer = new Sigma(makeGraph(model), surface, {
226
+ allowInvalidContainer: true,
227
+ renderLabels: true,
228
+ renderEdgeLabels: true,
229
+ labelDensity: 0.8,
230
+ labelRenderedSizeThreshold: 8,
231
+ labelGridCellSize: 120,
232
+ defaultNodeType: 'circle',
233
+ defaultEdgeType: 'arrow',
234
+ defaultNodeColor: PROJECT_GRAPH_TYPE_COLORS.unknown,
235
+ defaultEdgeColor: DEFAULT_EDGE_COLOR,
236
+ nodeReducer,
237
+ edgeReducer,
238
+ });
239
+ } catch (error) {
240
+ // A missing WebGL context is a renderer capability failure. Let the host
241
+ // choose its accessible list fallback while retaining the original cause.
242
+ mountRoot.remove();
243
+ if (isRecord(error)) error.code = 'project_graph_renderer_unavailable';
244
+ throw error;
245
+ }
246
+
247
+ if (signal?.aborted) {
248
+ renderer.kill();
249
+ mountRoot.remove();
250
+ throw abortError();
251
+ }
252
+
253
+ const onNodeClick = ({ node }) => setSelected(node);
254
+ const onStageClick = () => setSelected(null);
255
+ renderer.on('clickNode', onNodeClick);
256
+ renderer.on('clickStage', onStageClick);
257
+ onZoomIn = () => renderer?.getCamera().animatedZoom({ factor: 1.5 });
258
+ onZoomOut = () => renderer?.getCamera().animatedUnzoom({ factor: 1.5 });
259
+ onReset = () => renderer?.getCamera().animatedReset();
260
+ zoomIn.addEventListener('click', onZoomIn);
261
+ zoomOut.addEventListener('click', onZoomOut);
262
+ reset.addEventListener('click', onReset);
263
+ setSelected(options?.selectedId || null, { notify: false });
264
+
265
+ const destroy = () => {
266
+ if (destroyed) return;
267
+ destroyed = true;
268
+ if (signal && abortListener) signal.removeEventListener('abort', abortListener);
269
+ renderer?.off('clickNode', onNodeClick);
270
+ renderer?.off('clickStage', onStageClick);
271
+ zoomIn.removeEventListener('click', onZoomIn);
272
+ zoomOut.removeEventListener('click', onZoomOut);
273
+ reset.removeEventListener('click', onReset);
274
+ renderer?.kill();
275
+ renderer = null;
276
+ mountRoot.remove();
277
+ };
278
+ abortListener = () => destroy();
279
+ signal?.addEventListener('abort', abortListener, { once: true });
280
+ updateStatus();
281
+
282
+ return {
283
+ destroy,
284
+ select: (nodeId) => setSelected(nodeId),
285
+ getSelectedId: () => selectedId,
286
+ getModel: () => model,
287
+ };
288
+ }
289
+
290
+ export default mountProjectGraph;