@unson/brainbase-mcp 0.9.0 → 0.10.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.
- package/dist/local-web-host.js +6 -0
- package/package.json +12 -4
- package/ui/graph-projects-view.css +66 -0
- package/ui/graph-projects-view.js +126 -10
- package/ui/project-graph-entry.js +293 -0
- package/ui/project-graph-vendor.LICENSE.txt +109 -0
- package/ui/project-graph-vendor.js +312 -0
- package/ui/project-graph.js +358 -0
- package/ui/project-workspace.css +491 -0
- package/ui/project-workspace.js +727 -0
package/dist/local-web-host.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.10.1",
|
|
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": "
|
|
339
|
+
"gitHead": "3fab56acebc9266f2bd256cc1795a3d7f550c311"
|
|
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
|
|
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
|
-
|
|
423
|
+
const sourceBlock = workspaceHostNotice(doc, sourceNotice) ?? workspaceNotice(doc, {
|
|
378
424
|
label: '出典',
|
|
379
425
|
text: `このMacのGraph${dir ? `(${dir})` : ''}から読み、ここで直した内容もそこへ保存します。関係者は、プロジェクトに参加している人や責任を持つ人の記録で、ログインや共有の設定ではありません。`,
|
|
380
|
-
})
|
|
381
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,293 @@
|
|
|
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
|
+
projectGraphEdgePresentation,
|
|
14
|
+
projectGraphNeighborIds,
|
|
15
|
+
projectGraphNodePresentation,
|
|
16
|
+
} from './project-graph.js';
|
|
17
|
+
|
|
18
|
+
const DEFAULT_EDGE_COLOR = '#cbd5e1';
|
|
19
|
+
const SELECTED_COLOR = '#312e81';
|
|
20
|
+
const UNRELATED_COLOR = '#cbd5e1';
|
|
21
|
+
const UNRELATED_EDGE_COLOR = '#e2e8f0';
|
|
22
|
+
|
|
23
|
+
function isRecord(value) {
|
|
24
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function asDocument(container) {
|
|
28
|
+
return container?.ownerDocument || (typeof document !== 'undefined' ? document : null);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function abortError() {
|
|
32
|
+
const error = new Error('project_graph_mount_aborted');
|
|
33
|
+
error.name = 'AbortError';
|
|
34
|
+
return error;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function assertMountTarget(container) {
|
|
38
|
+
if (!container || typeof container.appendChild !== 'function') {
|
|
39
|
+
throw new TypeError('mountProjectGraph requires a DOM container');
|
|
40
|
+
}
|
|
41
|
+
const doc = asDocument(container);
|
|
42
|
+
if (!doc || typeof doc.createElement !== 'function') {
|
|
43
|
+
throw new TypeError('mountProjectGraph requires a DOM document');
|
|
44
|
+
}
|
|
45
|
+
return doc;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function setText(element, text) {
|
|
49
|
+
if (element) element.textContent = text;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeButton(doc, label, ariaLabel) {
|
|
53
|
+
const button = doc.createElement('button');
|
|
54
|
+
button.type = 'button';
|
|
55
|
+
button.className = 'bb-project-graph-control';
|
|
56
|
+
button.setAttribute('aria-label', ariaLabel);
|
|
57
|
+
button.textContent = label;
|
|
58
|
+
return button;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function makeGraph(model) {
|
|
62
|
+
const graph = new MultiDirectedGraph();
|
|
63
|
+
for (const node of model.nodes) {
|
|
64
|
+
graph.addNode(node.id, {
|
|
65
|
+
x: Number.isFinite(node.x) ? node.x : 0,
|
|
66
|
+
y: Number.isFinite(node.y) ? node.y : 0,
|
|
67
|
+
size: node.size,
|
|
68
|
+
label: node.label,
|
|
69
|
+
color: node.color || PROJECT_GRAPH_TYPE_COLORS.unknown,
|
|
70
|
+
baseColor: node.color || PROJECT_GRAPH_TYPE_COLORS.unknown,
|
|
71
|
+
semanticType: node.type,
|
|
72
|
+
// Sigma's renderer program type is deliberately separate from the
|
|
73
|
+
// ontology type. Unknown ontology types must still draw as circles.
|
|
74
|
+
type: 'circle',
|
|
75
|
+
highlighted: false,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
for (const edge of model.edges) {
|
|
79
|
+
graph.addDirectedEdgeWithKey(edge.id, edge.source, edge.target, {
|
|
80
|
+
label: edge.label || '',
|
|
81
|
+
color: edge.color || DEFAULT_EDGE_COLOR,
|
|
82
|
+
baseColor: edge.color || DEFAULT_EDGE_COLOR,
|
|
83
|
+
size: 1,
|
|
84
|
+
type: 'arrow',
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return graph;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Mounts one project graph and returns an idempotent resource handle.
|
|
92
|
+
*
|
|
93
|
+
* The graph is built only from the supplied projection. Invalid endpoints have
|
|
94
|
+
* already been removed by createProjectGraphModel, so this renderer never
|
|
95
|
+
* invents a node merely to make an edge drawable.
|
|
96
|
+
*/
|
|
97
|
+
export function mountProjectGraph(container, options = {}) {
|
|
98
|
+
const doc = assertMountTarget(container);
|
|
99
|
+
const signal = options?.signal;
|
|
100
|
+
if (signal?.aborted) throw abortError();
|
|
101
|
+
|
|
102
|
+
const model = createProjectGraphModel(
|
|
103
|
+
{ nodes: options?.nodes, edges: options?.edges },
|
|
104
|
+
{ selectedId: options?.selectedId || null },
|
|
105
|
+
);
|
|
106
|
+
const mountRoot = doc.createElement('div');
|
|
107
|
+
mountRoot.className = 'bb-project-graph';
|
|
108
|
+
mountRoot.setAttribute('data-graph-contract', 'brainbase.project-graph.v1');
|
|
109
|
+
|
|
110
|
+
const toolbar = doc.createElement('div');
|
|
111
|
+
toolbar.className = 'bb-project-graph-controls';
|
|
112
|
+
toolbar.setAttribute('role', 'toolbar');
|
|
113
|
+
toolbar.setAttribute('aria-label', 'グラフ表示の操作');
|
|
114
|
+
const zoomIn = makeButton(doc, '+', 'グラフを拡大');
|
|
115
|
+
const zoomOut = makeButton(doc, '−', 'グラフを縮小');
|
|
116
|
+
const reset = makeButton(doc, '全体', 'グラフ全体を表示');
|
|
117
|
+
toolbar.append(zoomIn, zoomOut, reset);
|
|
118
|
+
|
|
119
|
+
const status = doc.createElement('p');
|
|
120
|
+
status.className = 'bb-project-graph-status';
|
|
121
|
+
status.setAttribute('aria-live', 'polite');
|
|
122
|
+
status.setAttribute('aria-atomic', 'true');
|
|
123
|
+
|
|
124
|
+
const surface = doc.createElement('div');
|
|
125
|
+
surface.className = 'bb-project-graph-surface';
|
|
126
|
+
surface.setAttribute('role', 'img');
|
|
127
|
+
surface.setAttribute('aria-label', 'プロジェクトの関係グラフ');
|
|
128
|
+
surface.style.position = 'relative';
|
|
129
|
+
surface.style.width = '100%';
|
|
130
|
+
surface.style.height = '100%';
|
|
131
|
+
|
|
132
|
+
mountRoot.append(toolbar, status, surface);
|
|
133
|
+
container.appendChild(mountRoot);
|
|
134
|
+
|
|
135
|
+
let renderer;
|
|
136
|
+
let selectedId = null;
|
|
137
|
+
let neighborIds = new Set();
|
|
138
|
+
let destroyed = false;
|
|
139
|
+
let abortListener = null;
|
|
140
|
+
const nodeById = new Map(model.nodes.map((node) => [node.id, node]));
|
|
141
|
+
const edgeById = new Map(model.edges.map((edge) => [edge.id, edge]));
|
|
142
|
+
let onZoomIn;
|
|
143
|
+
let onZoomOut;
|
|
144
|
+
let onReset;
|
|
145
|
+
|
|
146
|
+
const getNeighbors = () => neighborIds;
|
|
147
|
+
|
|
148
|
+
const nodeReducer = (nodeId, data) => {
|
|
149
|
+
const base = { ...data };
|
|
150
|
+
const neighbors = getNeighbors();
|
|
151
|
+
if (!selectedId) return base;
|
|
152
|
+
const presentation = projectGraphNodePresentation(
|
|
153
|
+
{ id: nodeId, size: base.size },
|
|
154
|
+
{ selectedId, neighborIds: neighbors },
|
|
155
|
+
);
|
|
156
|
+
const { state, ...visuals } = presentation;
|
|
157
|
+
if (state === 'selected') return { ...base, ...visuals, color: SELECTED_COLOR };
|
|
158
|
+
if (state === 'neighbor') return {
|
|
159
|
+
...base,
|
|
160
|
+
...visuals,
|
|
161
|
+
color: base.baseColor || PROJECT_GRAPH_TYPE_COLORS.unknown,
|
|
162
|
+
};
|
|
163
|
+
return {
|
|
164
|
+
...base,
|
|
165
|
+
...visuals,
|
|
166
|
+
color: UNRELATED_COLOR,
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const edgeReducer = (edgeId, data) => {
|
|
171
|
+
const edge = edgeById.get(edgeId);
|
|
172
|
+
const base = { ...data };
|
|
173
|
+
if (!selectedId || !edge) {
|
|
174
|
+
return {
|
|
175
|
+
...base,
|
|
176
|
+
label: null,
|
|
177
|
+
forceLabel: false,
|
|
178
|
+
color: base.baseColor || DEFAULT_EDGE_COLOR,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const neighbors = getNeighbors();
|
|
182
|
+
const presentation = projectGraphEdgePresentation(edge, {
|
|
183
|
+
selectedId,
|
|
184
|
+
neighborIds: neighbors,
|
|
185
|
+
});
|
|
186
|
+
if (!presentation.relevant) {
|
|
187
|
+
return {
|
|
188
|
+
...base,
|
|
189
|
+
label: null,
|
|
190
|
+
forceLabel: false,
|
|
191
|
+
color: UNRELATED_EDGE_COLOR,
|
|
192
|
+
size: 0.5,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
...base,
|
|
197
|
+
label: presentation.showLabel ? edge.label : null,
|
|
198
|
+
forceLabel: presentation.forceLabel,
|
|
199
|
+
color: edge.color || DEFAULT_EDGE_COLOR,
|
|
200
|
+
size: 1.5,
|
|
201
|
+
};
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const updateStatus = () => {
|
|
205
|
+
if (!selectedId) {
|
|
206
|
+
setText(status, `${model.nodes.length}件の対象、${model.edges.length}件の関係。点を選択すると周辺を強調します。`);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const selected = nodeById.get(selectedId);
|
|
210
|
+
const neighbors = getNeighbors();
|
|
211
|
+
setText(status, `${selected?.label || selectedId}を選択中。関連する対象${neighbors.size}件を強調しています。`);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const notifySelection = () => {
|
|
215
|
+
if (typeof options?.onSelect === 'function') options.onSelect(selectedId);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const setSelected = (nextId, { notify = true } = {}) => {
|
|
219
|
+
if (destroyed) return;
|
|
220
|
+
selectedId = nextId && nodeById.has(nextId) ? nextId : null;
|
|
221
|
+
neighborIds = projectGraphNeighborIds(model.nodes, model.edges, selectedId);
|
|
222
|
+
updateStatus();
|
|
223
|
+
if (renderer) renderer.refresh();
|
|
224
|
+
if (notify) notifySelection();
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
renderer = new Sigma(makeGraph(model), surface, {
|
|
229
|
+
allowInvalidContainer: true,
|
|
230
|
+
renderLabels: true,
|
|
231
|
+
renderEdgeLabels: true,
|
|
232
|
+
labelDensity: 0.8,
|
|
233
|
+
labelRenderedSizeThreshold: 8,
|
|
234
|
+
labelGridCellSize: 120,
|
|
235
|
+
defaultNodeType: 'circle',
|
|
236
|
+
defaultEdgeType: 'arrow',
|
|
237
|
+
defaultNodeColor: PROJECT_GRAPH_TYPE_COLORS.unknown,
|
|
238
|
+
defaultEdgeColor: DEFAULT_EDGE_COLOR,
|
|
239
|
+
nodeReducer,
|
|
240
|
+
edgeReducer,
|
|
241
|
+
});
|
|
242
|
+
} catch (error) {
|
|
243
|
+
// A missing WebGL context is a renderer capability failure. Let the host
|
|
244
|
+
// choose its accessible list fallback while retaining the original cause.
|
|
245
|
+
mountRoot.remove();
|
|
246
|
+
if (isRecord(error)) error.code = 'project_graph_renderer_unavailable';
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (signal?.aborted) {
|
|
251
|
+
renderer.kill();
|
|
252
|
+
mountRoot.remove();
|
|
253
|
+
throw abortError();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const onNodeClick = ({ node }) => setSelected(node);
|
|
257
|
+
const onStageClick = () => setSelected(null);
|
|
258
|
+
renderer.on('clickNode', onNodeClick);
|
|
259
|
+
renderer.on('clickStage', onStageClick);
|
|
260
|
+
onZoomIn = () => renderer?.getCamera().animatedZoom({ factor: 1.5 });
|
|
261
|
+
onZoomOut = () => renderer?.getCamera().animatedUnzoom({ factor: 1.5 });
|
|
262
|
+
onReset = () => renderer?.getCamera().animatedReset();
|
|
263
|
+
zoomIn.addEventListener('click', onZoomIn);
|
|
264
|
+
zoomOut.addEventListener('click', onZoomOut);
|
|
265
|
+
reset.addEventListener('click', onReset);
|
|
266
|
+
setSelected(options?.selectedId || null, { notify: false });
|
|
267
|
+
|
|
268
|
+
const destroy = () => {
|
|
269
|
+
if (destroyed) return;
|
|
270
|
+
destroyed = true;
|
|
271
|
+
if (signal && abortListener) signal.removeEventListener('abort', abortListener);
|
|
272
|
+
renderer?.off('clickNode', onNodeClick);
|
|
273
|
+
renderer?.off('clickStage', onStageClick);
|
|
274
|
+
zoomIn.removeEventListener('click', onZoomIn);
|
|
275
|
+
zoomOut.removeEventListener('click', onZoomOut);
|
|
276
|
+
reset.removeEventListener('click', onReset);
|
|
277
|
+
renderer?.kill();
|
|
278
|
+
renderer = null;
|
|
279
|
+
mountRoot.remove();
|
|
280
|
+
};
|
|
281
|
+
abortListener = () => destroy();
|
|
282
|
+
signal?.addEventListener('abort', abortListener, { once: true });
|
|
283
|
+
updateStatus();
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
destroy,
|
|
287
|
+
select: (nodeId) => setSelected(nodeId),
|
|
288
|
+
getSelectedId: () => selectedId,
|
|
289
|
+
getModel: () => model,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export default mountProjectGraph;
|