memorix 1.0.8 → 1.0.9
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/CHANGELOG.md +39 -4
- package/README.md +16 -16
- package/README.zh-CN.md +24 -8
- package/dist/cli/index.js +3424 -1056
- package/dist/cli/index.js.map +1 -1
- package/dist/dashboard/static/app.js +934 -10
- package/dist/dashboard/static/index.html +24 -4
- package/dist/dashboard/static/style.css +663 -0
- package/dist/index.js +635 -17
- package/dist/index.js.map +1 -1
- package/dist/sdk.js +635 -17
- package/dist/sdk.js.map +1 -1
- package/dist/types.d.ts +2 -1
- package/dist/types.js +2 -1
- package/dist/types.js.map +1 -1
- package/docs/SETUP.md +97 -1
- package/package.json +1 -1
|
@@ -147,6 +147,28 @@ const i18n = {
|
|
|
147
147
|
recentGitMemories: 'Recent Git Memories',
|
|
148
148
|
commit: 'Commit',
|
|
149
149
|
created: 'Created',
|
|
150
|
+
knowledgeTitle: 'Knowledge Base',
|
|
151
|
+
knowledgeSubtitle: 'Project-scoped engineering knowledge distilled from durable memory',
|
|
152
|
+
knowledgeObservationsUsed: 'Observations Used',
|
|
153
|
+
knowledgeMiniSkillsUsed: 'Mini-skills Used',
|
|
154
|
+
knowledgeRefs: 'Refs',
|
|
155
|
+
knowledgeGenerated: 'Generated',
|
|
156
|
+
knowledgeQuickJump: 'Sections',
|
|
157
|
+
knowledgeNoItems: 'No entries in this section yet',
|
|
158
|
+
knowledgeNoItemsDesc: 'This section will fill in as durable project knowledge is stored.',
|
|
159
|
+
knowledgeUnavailable: 'Knowledge Base Unavailable',
|
|
160
|
+
knowledgeUnavailableDesc: 'Could not load /api/knowledge for the selected project.',
|
|
161
|
+
knowledgeEmpty: 'No Knowledge Base Entries',
|
|
162
|
+
knowledgeEmptyDesc: 'Store durable observations or promote mini-skills to populate this read-only project wiki.',
|
|
163
|
+
knowledgeProvenance: 'Provenance',
|
|
164
|
+
knowledgeEntry: 'entry',
|
|
165
|
+
knowledgeEntries: 'entries',
|
|
166
|
+
knowledgeSectionProjectOverview: 'Project Overview',
|
|
167
|
+
knowledgeSectionCoreDecisions: 'Core Decisions',
|
|
168
|
+
knowledgeSectionOperationalKnowledge: 'Operational Knowledge',
|
|
169
|
+
knowledgeSectionKnownGotchas: 'Known Gotchas',
|
|
170
|
+
knowledgeSectionGitBackedFacts: 'Git-backed Facts',
|
|
171
|
+
knowledgeSectionPromotedSkills: 'Promoted Skills',
|
|
150
172
|
|
|
151
173
|
// Config
|
|
152
174
|
configTitle: 'Config Provenance',
|
|
@@ -314,6 +336,34 @@ const i18n = {
|
|
|
314
336
|
graphTopToBottom: 'Top → Bottom',
|
|
315
337
|
graphMore: 'more',
|
|
316
338
|
|
|
339
|
+
// Knowledge Graph (semantic)
|
|
340
|
+
kgTitle: 'Knowledge Graph',
|
|
341
|
+
kgSubtitle: 'Semantic knowledge topology — section clusters, evidence links, provenance',
|
|
342
|
+
kgNodes: 'nodes',
|
|
343
|
+
kgEdges: 'edges',
|
|
344
|
+
kgClusters: 'clusters',
|
|
345
|
+
kgNoData: 'No Knowledge Graph Data',
|
|
346
|
+
kgNoDataDesc: 'Store durable observations or promote mini-skills to generate a semantic knowledge graph.',
|
|
347
|
+
kgClusterFilter: 'Section Cluster',
|
|
348
|
+
kgEdgeTypeFilter: 'Edge Type',
|
|
349
|
+
kgEdgeSupports: 'supports',
|
|
350
|
+
kgEdgeRelatesTo: 'relates to',
|
|
351
|
+
kgEdgeMentions: 'mentions',
|
|
352
|
+
kgEdgeDerivedFrom: 'derived from',
|
|
353
|
+
kgInspectorSummary: 'Summary',
|
|
354
|
+
kgInspectorProvenance: 'Provenance',
|
|
355
|
+
kgInspectorSection: 'Section',
|
|
356
|
+
kgInspectorEntity: 'Entity',
|
|
357
|
+
kgInspectorEvidence: 'Evidence',
|
|
358
|
+
kgInspectorRelatedEdges: 'Related Edges',
|
|
359
|
+
kgInspectorNoEdges: 'No edges',
|
|
360
|
+
kgDataMode: 'Data Source',
|
|
361
|
+
kgModeSemantic: 'Semantic KG',
|
|
362
|
+
kgModeEntity: 'Entity Graph',
|
|
363
|
+
kgViewMode: 'View Mode',
|
|
364
|
+
kgFocused: 'Focused',
|
|
365
|
+
kgFullGraph: 'Full Graph',
|
|
366
|
+
|
|
317
367
|
// Identity (additional)
|
|
318
368
|
identityCurrentProject: 'Current Project',
|
|
319
369
|
identityHistoricalProjects: 'Historical Projects',
|
|
@@ -333,6 +383,7 @@ const i18n = {
|
|
|
333
383
|
// Nav tooltips + labels
|
|
334
384
|
navDashboard: 'Dashboard',
|
|
335
385
|
navGitMemory: 'Git Memory',
|
|
386
|
+
navKnowledge: 'Knowledge Base',
|
|
336
387
|
navGraph: 'Knowledge Graph',
|
|
337
388
|
navObservations: 'Observations',
|
|
338
389
|
navRetention: 'Retention',
|
|
@@ -342,6 +393,7 @@ const i18n = {
|
|
|
342
393
|
navTeam: 'Agent Team',
|
|
343
394
|
navLabelDashboard: 'Overview',
|
|
344
395
|
navLabelGitMemory: 'Git Memory',
|
|
396
|
+
navLabelKnowledge: 'Knowledge',
|
|
345
397
|
navLabelGraph: 'Graph',
|
|
346
398
|
navLabelObservations: 'Observations',
|
|
347
399
|
navLabelRetention: 'Retention',
|
|
@@ -506,17 +558,39 @@ const i18n = {
|
|
|
506
558
|
|
|
507
559
|
// Git Memory
|
|
508
560
|
gitMemoryTitle: 'Git 记忆',
|
|
509
|
-
gitMemorySubtitle: '来自 git 提交的记忆 —
|
|
561
|
+
gitMemorySubtitle: '来自 git 提交的记忆 — 真实来源且不可变',
|
|
510
562
|
totalGitMemories: 'Git 记忆总数',
|
|
511
|
-
uniqueCommits: '
|
|
563
|
+
uniqueCommits: '唯一提交',
|
|
512
564
|
typeCoverage: '类型覆盖',
|
|
513
565
|
noGitMemory: '暂无 Git 记忆',
|
|
514
566
|
noGitMemoryDesc: '使用以下命令安装 post-commit hook: memorix git-hook-install',
|
|
515
567
|
noGitMemoriesYet: '暂无 Git 记忆',
|
|
516
568
|
noGitMemoriesHint: '安装 post-commit hook 以自动捕获 git 记忆:',
|
|
517
|
-
recentGitMemories: '
|
|
569
|
+
recentGitMemories: '最近 Git 记忆',
|
|
518
570
|
commit: '提交',
|
|
519
571
|
created: '创建时间',
|
|
572
|
+
knowledgeTitle: '知识库',
|
|
573
|
+
knowledgeSubtitle: '从持久项目记忆中提炼出的工程知识',
|
|
574
|
+
knowledgeObservationsUsed: '使用的观察',
|
|
575
|
+
knowledgeMiniSkillsUsed: '使用的 Mini-skills',
|
|
576
|
+
knowledgeRefs: '引用',
|
|
577
|
+
knowledgeGenerated: '生成于',
|
|
578
|
+
knowledgeQuickJump: '章节',
|
|
579
|
+
knowledgeNoItems: '本章节暂无条目',
|
|
580
|
+
knowledgeNoItemsDesc: '当项目中出现持久知识后,本章节会自动填充。',
|
|
581
|
+
knowledgeUnavailable: '知识库不可用',
|
|
582
|
+
knowledgeUnavailableDesc: '无法为所选项目加载 /api/knowledge。',
|
|
583
|
+
knowledgeEmpty: '暂无知识库条目',
|
|
584
|
+
knowledgeEmptyDesc: '存储持久观察或提升 mini-skill 后,此只读项目 wiki 会自动填充。',
|
|
585
|
+
knowledgeProvenance: '来源',
|
|
586
|
+
knowledgeEntry: '条',
|
|
587
|
+
knowledgeEntries: '条',
|
|
588
|
+
knowledgeSectionProjectOverview: '项目概览',
|
|
589
|
+
knowledgeSectionCoreDecisions: '核心决策',
|
|
590
|
+
knowledgeSectionOperationalKnowledge: '操作知识',
|
|
591
|
+
knowledgeSectionKnownGotchas: '已知陷阱',
|
|
592
|
+
knowledgeSectionGitBackedFacts: 'Git 事实',
|
|
593
|
+
knowledgeSectionPromotedSkills: '提升技能',
|
|
520
594
|
|
|
521
595
|
// Config
|
|
522
596
|
configTitle: '配置溯源',
|
|
@@ -689,6 +763,34 @@ const i18n = {
|
|
|
689
763
|
graphTopToBottom: '从上到下',
|
|
690
764
|
graphMore: '更多',
|
|
691
765
|
|
|
766
|
+
// Knowledge Graph (semantic)
|
|
767
|
+
kgTitle: '知识图谱',
|
|
768
|
+
kgSubtitle: '语义知识拓扑 — 分区聚类、证据关联、溯源',
|
|
769
|
+
kgNodes: '个节点',
|
|
770
|
+
kgEdges: '条边',
|
|
771
|
+
kgClusters: '个聚类',
|
|
772
|
+
kgNoData: '暂无知识图谱数据',
|
|
773
|
+
kgNoDataDesc: '存储持久观察或提升迷你技能以生成语义知识图谱。',
|
|
774
|
+
kgClusterFilter: '分区聚类',
|
|
775
|
+
kgEdgeTypeFilter: '边类型',
|
|
776
|
+
kgEdgeSupports: '支撑',
|
|
777
|
+
kgEdgeRelatesTo: '关联',
|
|
778
|
+
kgEdgeMentions: '提及',
|
|
779
|
+
kgEdgeDerivedFrom: '派生自',
|
|
780
|
+
kgInspectorSummary: '摘要',
|
|
781
|
+
kgInspectorProvenance: '溯源',
|
|
782
|
+
kgInspectorSection: '分区',
|
|
783
|
+
kgInspectorEntity: '实体',
|
|
784
|
+
kgInspectorEvidence: '证据',
|
|
785
|
+
kgInspectorRelatedEdges: '相关边',
|
|
786
|
+
kgInspectorNoEdges: '无边',
|
|
787
|
+
kgDataMode: '数据源',
|
|
788
|
+
kgModeSemantic: '语义图谱',
|
|
789
|
+
kgModeEntity: '实体图谱',
|
|
790
|
+
kgViewMode: '视图模式',
|
|
791
|
+
kgFocused: '聚焦',
|
|
792
|
+
kgFullGraph: '全量',
|
|
793
|
+
|
|
692
794
|
// Identity (additional)
|
|
693
795
|
identityCurrentProject: '当前项目',
|
|
694
796
|
identityHistoricalProjects: '历史项目',
|
|
@@ -708,6 +810,7 @@ const i18n = {
|
|
|
708
810
|
// Nav tooltips
|
|
709
811
|
navDashboard: '仪表盘',
|
|
710
812
|
navGitMemory: 'Git 记忆',
|
|
813
|
+
navKnowledge: '知识库',
|
|
711
814
|
navGraph: '知识图谱',
|
|
712
815
|
navObservations: '观察记录',
|
|
713
816
|
navRetention: '记忆衰减',
|
|
@@ -717,6 +820,7 @@ const i18n = {
|
|
|
717
820
|
navTeam: 'Agent 团队',
|
|
718
821
|
navLabelDashboard: '概览',
|
|
719
822
|
navLabelGitMemory: 'Git 记忆',
|
|
823
|
+
navLabelKnowledge: '知识库',
|
|
720
824
|
navLabelGraph: '图谱',
|
|
721
825
|
navLabelObservations: '观察',
|
|
722
826
|
navLabelRetention: '衰减',
|
|
@@ -764,6 +868,14 @@ function t(key) {
|
|
|
764
868
|
return (i18n[currentLang] && i18n[currentLang][key]) || i18n.en[key] || key;
|
|
765
869
|
}
|
|
766
870
|
|
|
871
|
+
function onDomReady(fn) {
|
|
872
|
+
if (document.readyState === 'loading') {
|
|
873
|
+
document.addEventListener('DOMContentLoaded', fn, { once: true });
|
|
874
|
+
} else {
|
|
875
|
+
fn();
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
767
879
|
function setLang(lang) {
|
|
768
880
|
currentLang = lang;
|
|
769
881
|
localStorage.setItem('memorix-lang', lang);
|
|
@@ -774,8 +886,8 @@ function setLang(lang) {
|
|
|
774
886
|
if (label) label.textContent = lang === 'en' ? '中文' : 'EN';
|
|
775
887
|
|
|
776
888
|
// Update nav tooltips + labels
|
|
777
|
-
const tooltipMap = { dashboard: 'navDashboard', 'git-memory': 'navGitMemory', graph: 'navGraph', observations: 'navObservations', retention: 'navRetention', config: 'navConfig', identity: 'navIdentity', sessions: 'navSessions', team: 'navTeam' };
|
|
778
|
-
const labelMap = { dashboard: 'navLabelDashboard', 'git-memory': 'navLabelGitMemory', graph: 'navLabelGraph', observations: 'navLabelObservations', retention: 'navLabelRetention', config: 'navLabelConfig', identity: 'navLabelIdentity', sessions: 'navLabelSessions', team: 'navLabelTeam' };
|
|
889
|
+
const tooltipMap = { dashboard: 'navDashboard', 'git-memory': 'navGitMemory', knowledge: 'navKnowledge', graph: 'navGraph', observations: 'navObservations', retention: 'navRetention', config: 'navConfig', identity: 'navIdentity', sessions: 'navSessions', team: 'navTeam' };
|
|
890
|
+
const labelMap = { dashboard: 'navLabelDashboard', 'git-memory': 'navLabelGitMemory', knowledge: 'navLabelKnowledge', graph: 'navLabelGraph', observations: 'navLabelObservations', retention: 'navLabelRetention', config: 'navLabelConfig', identity: 'navLabelIdentity', sessions: 'navLabelSessions', team: 'navLabelTeam' };
|
|
779
891
|
document.querySelectorAll('.nav-btn').forEach(b => {
|
|
780
892
|
const page = b.dataset.page;
|
|
781
893
|
if (page && tooltipMap[page]) b.title = t(tooltipMap[page]);
|
|
@@ -810,7 +922,7 @@ function setLang(lang) {
|
|
|
810
922
|
}
|
|
811
923
|
|
|
812
924
|
// Init lang toggle button
|
|
813
|
-
|
|
925
|
+
onDomReady(() => {
|
|
814
926
|
const btn = document.getElementById('lang-toggle');
|
|
815
927
|
const label = document.getElementById('lang-label');
|
|
816
928
|
if (label) label.textContent = currentLang === 'en' ? '中文' : 'EN';
|
|
@@ -853,7 +965,7 @@ function applyTheme(theme) {
|
|
|
853
965
|
// Apply saved theme immediately
|
|
854
966
|
applyTheme(currentTheme);
|
|
855
967
|
|
|
856
|
-
|
|
968
|
+
onDomReady(() => {
|
|
857
969
|
const themeBtn = document.getElementById('theme-toggle');
|
|
858
970
|
if (themeBtn) {
|
|
859
971
|
themeBtn.addEventListener('click', () => {
|
|
@@ -866,7 +978,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
866
978
|
// Router & Navigation
|
|
867
979
|
// ============================================================
|
|
868
980
|
|
|
869
|
-
const pages = ['dashboard', 'git-memory', 'graph', 'observations', 'retention', 'config', 'identity', 'sessions', 'team'];
|
|
981
|
+
const pages = ['dashboard', 'git-memory', 'knowledge', 'graph', 'observations', 'retention', 'config', 'identity', 'sessions', 'team'];
|
|
870
982
|
let currentPage = 'dashboard';
|
|
871
983
|
|
|
872
984
|
function navigate(page) {
|
|
@@ -1151,7 +1263,7 @@ async function initProjectSwitcher() {
|
|
|
1151
1263
|
}
|
|
1152
1264
|
}
|
|
1153
1265
|
|
|
1154
|
-
|
|
1266
|
+
onDomReady(() => {
|
|
1155
1267
|
initProjectSwitcher();
|
|
1156
1268
|
});
|
|
1157
1269
|
|
|
@@ -1167,6 +1279,7 @@ async function loadPage(page) {
|
|
|
1167
1279
|
switch (page) {
|
|
1168
1280
|
case 'dashboard': await loadDashboard(); break;
|
|
1169
1281
|
case 'git-memory': await loadGitMemory(); break;
|
|
1282
|
+
case 'knowledge': await loadKnowledge(); break;
|
|
1170
1283
|
case 'graph': await loadGraph(); break;
|
|
1171
1284
|
case 'observations': await loadObservations(); break;
|
|
1172
1285
|
case 'retention': await loadRetention(); break;
|
|
@@ -1417,6 +1530,201 @@ function renderPieChart(canvasId, entries, icons) {
|
|
|
1417
1530
|
ctx.fillText('total', cx, cy + 10);
|
|
1418
1531
|
}
|
|
1419
1532
|
|
|
1533
|
+
async function loadKnowledge() {
|
|
1534
|
+
const container = document.getElementById('page-knowledge');
|
|
1535
|
+
container.innerHTML = '<div class="loading knowledge-loading"><div class="spinner"></div><div class="knowledge-loading-text">' + t('loading') + '</div></div>';
|
|
1536
|
+
|
|
1537
|
+
const kb = await api('knowledge');
|
|
1538
|
+
if (!kb) {
|
|
1539
|
+
container.innerHTML = `
|
|
1540
|
+
<div class="page-header">
|
|
1541
|
+
<h1 class="page-title">${t('knowledgeTitle')}</h1>
|
|
1542
|
+
<p class="page-subtitle">${t('knowledgeSubtitle')}</p>
|
|
1543
|
+
</div>
|
|
1544
|
+
<div class="panel knowledge-error-panel">
|
|
1545
|
+
<div class="panel-body">
|
|
1546
|
+
<div class="knowledge-state-icon"><span class="iconify" data-icon="lucide:book-x"></span></div>
|
|
1547
|
+
<div class="empty-state-title">${t('knowledgeUnavailable')}</div>
|
|
1548
|
+
<div class="empty-state-desc">${t('knowledgeUnavailableDesc')}</div>
|
|
1549
|
+
<button class="team-refresh-btn" id="knowledge-retry-btn" style="margin:16px auto 0;">${t('teamRefresh')}</button>
|
|
1550
|
+
</div>
|
|
1551
|
+
</div>
|
|
1552
|
+
`;
|
|
1553
|
+
const retry = document.getElementById('knowledge-retry-btn');
|
|
1554
|
+
if (retry) retry.addEventListener('click', () => {
|
|
1555
|
+
delete loaded.knowledge;
|
|
1556
|
+
loadPage('knowledge');
|
|
1557
|
+
});
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
const sections = normalizeKnowledgeSections(kb.sections || []);
|
|
1562
|
+
const totalItems = sections.reduce((sum, section) => sum + (section.items || []).length, 0);
|
|
1563
|
+
const stats = kb.stats || { observationsUsed: 0, miniSkillsUsed: 0, refs: 0 };
|
|
1564
|
+
const overviewSection = sections.find(s => s.id === 'project-overview');
|
|
1565
|
+
const mainSections = sections.filter(s => s.id !== 'project-overview');
|
|
1566
|
+
|
|
1567
|
+
container.innerHTML = `
|
|
1568
|
+
<div class="knowledge-page-shell">
|
|
1569
|
+
<div class="knowledge-topbar">
|
|
1570
|
+
<div class="page-header knowledge-header">
|
|
1571
|
+
<div class="knowledge-title-block">
|
|
1572
|
+
<h1 class="page-title">${escapeHtml(t('knowledgeTitle'))}</h1>
|
|
1573
|
+
<p class="page-subtitle">${t('knowledgeSubtitle')}</p>
|
|
1574
|
+
<div class="knowledge-project-line" title="${escapeHtml(kb.projectId || '')}">${escapeHtml(kb.projectId || '')}</div>
|
|
1575
|
+
</div>
|
|
1576
|
+
<div class="knowledge-generated">
|
|
1577
|
+
<span>${t('knowledgeGenerated')}</span>
|
|
1578
|
+
<strong>${escapeHtml(formatTime(kb.generatedAt))}</strong>
|
|
1579
|
+
</div>
|
|
1580
|
+
</div>
|
|
1581
|
+
|
|
1582
|
+
<div class="knowledge-stats-grid">
|
|
1583
|
+
<div class="stat-card" data-accent="purple">
|
|
1584
|
+
<div class="stat-label">${t('knowledgeObservationsUsed')}</div>
|
|
1585
|
+
<div class="stat-value">${stats.observationsUsed || 0}</div>
|
|
1586
|
+
</div>
|
|
1587
|
+
<div class="stat-card" data-accent="cyan">
|
|
1588
|
+
<div class="stat-label">${t('knowledgeMiniSkillsUsed')}</div>
|
|
1589
|
+
<div class="stat-value">${stats.miniSkillsUsed || 0}</div>
|
|
1590
|
+
</div>
|
|
1591
|
+
<div class="stat-card" data-accent="green">
|
|
1592
|
+
<div class="stat-label">${t('knowledgeRefs')}</div>
|
|
1593
|
+
<div class="stat-value">${stats.refs || 0}</div>
|
|
1594
|
+
</div>
|
|
1595
|
+
</div>
|
|
1596
|
+
|
|
1597
|
+
<nav class="knowledge-jump" aria-label="${t('knowledgeQuickJump')}">
|
|
1598
|
+
<span class="knowledge-jump-label">${t('knowledgeQuickJump')}</span>
|
|
1599
|
+
${sections.map(section => `<a href="#${knowledgeAnchor(section.id)}" class="knowledge-jump-chip">${escapeHtml(section.title)} <span>${(section.items || []).length}</span></a>`).join('')}
|
|
1600
|
+
</nav>
|
|
1601
|
+
|
|
1602
|
+
${overviewSection ? renderKnowledgeOverviewCard(overviewSection) : ''}
|
|
1603
|
+
</div>
|
|
1604
|
+
|
|
1605
|
+
${totalItems === 0 ? `
|
|
1606
|
+
<div class="panel knowledge-empty-overview">
|
|
1607
|
+
<div class="panel-body">
|
|
1608
|
+
<div class="knowledge-state-icon"><span class="iconify" data-icon="lucide:book-open"></span></div>
|
|
1609
|
+
<div class="empty-state-title">${t('knowledgeEmpty')}</div>
|
|
1610
|
+
<div class="empty-state-desc">${t('knowledgeEmptyDesc')}</div>
|
|
1611
|
+
</div>
|
|
1612
|
+
</div>
|
|
1613
|
+
` : `
|
|
1614
|
+
<div class="knowledge-sections-region">
|
|
1615
|
+
${mainSections.map(renderKnowledgeSection).join('')}
|
|
1616
|
+
</div>
|
|
1617
|
+
`}
|
|
1618
|
+
</div>
|
|
1619
|
+
`;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
function normalizeKnowledgeSections(sections) {
|
|
1623
|
+
const byId = new Map(sections.map(section => [section.id, section]));
|
|
1624
|
+
const order = [
|
|
1625
|
+
['project-overview', 'knowledgeSectionProjectOverview'],
|
|
1626
|
+
['core-decisions', 'knowledgeSectionCoreDecisions'],
|
|
1627
|
+
['operational-knowledge', 'knowledgeSectionOperationalKnowledge'],
|
|
1628
|
+
['known-gotchas', 'knowledgeSectionKnownGotchas'],
|
|
1629
|
+
['git-backed-facts', 'knowledgeSectionGitBackedFacts'],
|
|
1630
|
+
['promoted-skills', 'knowledgeSectionPromotedSkills'],
|
|
1631
|
+
];
|
|
1632
|
+
return order.map(([id, titleKey]) => {
|
|
1633
|
+
const section = byId.get(id);
|
|
1634
|
+
const localizedTitle = t(titleKey);
|
|
1635
|
+
if (section) return { ...section, title: localizedTitle };
|
|
1636
|
+
return { id, title: localizedTitle, items: [], empty: true };
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
function knowledgeEntryLabel(count) {
|
|
1641
|
+
return count === 1 ? t('knowledgeEntry') : t('knowledgeEntries');
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
function renderKnowledgeOverviewCard(section) {
|
|
1645
|
+
const item = (section.items || [])[0];
|
|
1646
|
+
if (!item) {
|
|
1647
|
+
return `
|
|
1648
|
+
<section class="knowledge-overview-card knowledge-overview-card--empty" id="${knowledgeAnchor(section.id)}">
|
|
1649
|
+
<div class="knowledge-overview-meta">
|
|
1650
|
+
<span class="knowledge-overview-label">${escapeHtml(section.title)}</span>
|
|
1651
|
+
<span class="knowledge-section-meta">0 ${t('knowledgeEntries')}</span>
|
|
1652
|
+
</div>
|
|
1653
|
+
<div class="knowledge-overview-body">
|
|
1654
|
+
<p class="knowledge-summary">${t('knowledgeNoItemsDesc')}</p>
|
|
1655
|
+
</div>
|
|
1656
|
+
</section>
|
|
1657
|
+
`;
|
|
1658
|
+
}
|
|
1659
|
+
return `
|
|
1660
|
+
<section class="knowledge-overview-card" id="${knowledgeAnchor(section.id)}">
|
|
1661
|
+
<div class="knowledge-overview-meta">
|
|
1662
|
+
<span class="knowledge-overview-label">${escapeHtml(section.title)}</span>
|
|
1663
|
+
<span class="knowledge-overview-title" title="${escapeHtml(item.title || '')}">${escapeHtml(item.title || t('untitled'))}</span>
|
|
1664
|
+
</div>
|
|
1665
|
+
<div class="knowledge-overview-body">
|
|
1666
|
+
<p class="knowledge-summary knowledge-summary--overview">${escapeHtml(item.summary || '')}</p>
|
|
1667
|
+
<div class="knowledge-ref-list">${renderKnowledgeRefs(item.refs || [])}</div>
|
|
1668
|
+
</div>
|
|
1669
|
+
</section>
|
|
1670
|
+
`;
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
function renderKnowledgeSection(section) {
|
|
1674
|
+
const items = section.items || [];
|
|
1675
|
+
return `
|
|
1676
|
+
<section class="panel knowledge-section" id="${knowledgeAnchor(section.id)}">
|
|
1677
|
+
<div class="panel-header knowledge-section-header">
|
|
1678
|
+
<span class="panel-title">${escapeHtml(section.title)}</span>
|
|
1679
|
+
<span class="knowledge-section-meta">${items.length} ${knowledgeEntryLabel(items.length)}</span>
|
|
1680
|
+
</div>
|
|
1681
|
+
<div class="panel-body knowledge-section-body">
|
|
1682
|
+
${items.length === 0 ? renderKnowledgeEmptySection() : items.map(renderKnowledgeItem).join('')}
|
|
1683
|
+
</div>
|
|
1684
|
+
</section>
|
|
1685
|
+
`;
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
function renderKnowledgeItem(item) {
|
|
1689
|
+
const refs = item.refs || [];
|
|
1690
|
+
return `
|
|
1691
|
+
<article class="knowledge-item">
|
|
1692
|
+
<div class="knowledge-item-head">
|
|
1693
|
+
<h3 title="${escapeHtml(item.title || t('untitled'))}">${escapeHtml(item.title || t('untitled'))}</h3>
|
|
1694
|
+
<span class="type-badge" data-type="${escapeHtml(item.type || 'unknown')}">${escapeHtml(item.type || t('unknown'))}</span>
|
|
1695
|
+
</div>
|
|
1696
|
+
${item.entityName ? `<div class="knowledge-entity" title="${escapeHtml(item.entityName)}">${escapeHtml(item.entityName)}</div>` : ''}
|
|
1697
|
+
<p class="knowledge-summary">${escapeHtml(item.summary || '')}</p>
|
|
1698
|
+
${refs.length > 0 ? `<div class="knowledge-ref-list">${renderKnowledgeRefs(refs)}</div>` : ''}
|
|
1699
|
+
</article>
|
|
1700
|
+
`;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
function renderKnowledgeRefs(refs) {
|
|
1704
|
+
if (refs.length === 0) return '<span class="knowledge-ref-empty">—</span>';
|
|
1705
|
+
return refs.map(ref => {
|
|
1706
|
+
const kind = ref.kind || 'observation';
|
|
1707
|
+
const title = ref.title ? ` title="${escapeHtml(ref.title)}"` : '';
|
|
1708
|
+
return `<span class="knowledge-ref-chip" data-kind="${escapeHtml(kind)}"${title}>${escapeHtml(ref.id || kind)}</span>`;
|
|
1709
|
+
}).join('');
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
function renderKnowledgeEmptySection() {
|
|
1713
|
+
return `
|
|
1714
|
+
<div class="knowledge-section-empty">
|
|
1715
|
+
<span class="iconify" data-icon="lucide:circle-dashed"></span>
|
|
1716
|
+
<div>
|
|
1717
|
+
<strong>${t('knowledgeNoItems')}</strong>
|
|
1718
|
+
<span>${t('knowledgeNoItemsDesc')}</span>
|
|
1719
|
+
</div>
|
|
1720
|
+
</div>
|
|
1721
|
+
`;
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
function knowledgeAnchor(id) {
|
|
1725
|
+
return `knowledge-${String(id || 'section').replace(/[^a-z0-9_-]/gi, '-')}`;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1420
1728
|
// ============================================================
|
|
1421
1729
|
// Memory Topology Explorer — Cytoscape.js + Dagre
|
|
1422
1730
|
// Focused topology default, not full graph dump
|
|
@@ -1428,9 +1736,21 @@ async function loadGraph() {
|
|
|
1428
1736
|
const container = document.getElementById('page-graph');
|
|
1429
1737
|
container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
|
|
1430
1738
|
|
|
1739
|
+
// Try semantic Knowledge Graph first
|
|
1740
|
+
try {
|
|
1741
|
+
const kg = await api('knowledge-graph');
|
|
1742
|
+
if (kg && kg.nodes && kg.nodes.length > 0) {
|
|
1743
|
+
renderSemanticGraph(kg);
|
|
1744
|
+
return;
|
|
1745
|
+
}
|
|
1746
|
+
} catch (_e) {
|
|
1747
|
+
// Fallback to entity graph
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
// Fallback: entity/relation graph
|
|
1431
1751
|
const graph = await api('graph');
|
|
1432
1752
|
if (!graph || (graph.entities.length === 0 && graph.relations.length === 0)) {
|
|
1433
|
-
container.innerHTML = emptyState('<span class="iconify" data-icon="lucide:network" style="font-size:36px;"></span>', t('
|
|
1753
|
+
container.innerHTML = emptyState('<span class="iconify" data-icon="lucide:network" style="font-size:36px;"></span>', t('kgNoData'), t('kgNoDataDesc'));
|
|
1434
1754
|
return;
|
|
1435
1755
|
}
|
|
1436
1756
|
|
|
@@ -1482,6 +1802,610 @@ async function loadGraph() {
|
|
|
1482
1802
|
renderGraph(graph);
|
|
1483
1803
|
}
|
|
1484
1804
|
|
|
1805
|
+
// ============================================================
|
|
1806
|
+
// Semantic Knowledge Graph Renderer — Apache ECharts 5
|
|
1807
|
+
// Force layout, section categories, evidence-based edges
|
|
1808
|
+
// ============================================================
|
|
1809
|
+
|
|
1810
|
+
function renderSemanticGraph(kg) {
|
|
1811
|
+
const container = document.getElementById('page-graph');
|
|
1812
|
+
|
|
1813
|
+
// Section color palette (vivid for both light/dark themes)
|
|
1814
|
+
const sectionPalette = {
|
|
1815
|
+
'core-decisions': '#7B9FD9',
|
|
1816
|
+
'operational-knowledge': '#7CC598',
|
|
1817
|
+
'known-gotchas': '#E08585',
|
|
1818
|
+
'git-backed-facts': '#56D6A6',
|
|
1819
|
+
'promoted-skills': '#C9A8FF',
|
|
1820
|
+
};
|
|
1821
|
+
const defaultSectionColor = '#A893C2';
|
|
1822
|
+
|
|
1823
|
+
function getSectionColor(sectionId) {
|
|
1824
|
+
return sectionPalette[sectionId] || defaultSectionColor;
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
// Edge type styles — solid hex + opacity controlled by G6 strokeOpacity
|
|
1828
|
+
const edgeStyleMap = {
|
|
1829
|
+
'supports': { color: '#69F0AE', arrow: true, dash: false, label: t('kgEdgeSupports') },
|
|
1830
|
+
'relates_to': { color: '#80D8FF', arrow: false, dash: [4, 4], label: t('kgEdgeRelatesTo') },
|
|
1831
|
+
'mentions': { color: '#D0BCFF', arrow: true, dash: false, label: t('kgEdgeMentions') },
|
|
1832
|
+
'derived_from': { color: '#FFB74D', arrow: true, dash: [6, 3], label: t('kgEdgeDerivedFrom') },
|
|
1833
|
+
};
|
|
1834
|
+
|
|
1835
|
+
// Section label i18n map (matches knowledgeSection* keys)
|
|
1836
|
+
const sectionI18nKeys = {
|
|
1837
|
+
'core-decisions': 'knowledgeSectionCoreDecisions',
|
|
1838
|
+
'operational-knowledge': 'knowledgeSectionOperationalKnowledge',
|
|
1839
|
+
'known-gotchas': 'knowledgeSectionKnownGotchas',
|
|
1840
|
+
'git-backed-facts': 'knowledgeSectionGitBackedFacts',
|
|
1841
|
+
'promoted-skills': 'knowledgeSectionPromotedSkills',
|
|
1842
|
+
};
|
|
1843
|
+
function sectionLabel(sectionId) {
|
|
1844
|
+
const key = sectionI18nKeys[sectionId];
|
|
1845
|
+
return key ? t(key) : sectionId;
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
// State
|
|
1849
|
+
let activeSections = new Set((kg.clusters || []).map(c => c.sectionId));
|
|
1850
|
+
let activeEdgeTypes = new Set(Object.keys(edgeStyleMap).filter(type => type !== 'relates_to'));
|
|
1851
|
+
let selectedNodeId = null;
|
|
1852
|
+
let echartsInstance = null;
|
|
1853
|
+
let echartsResizeObserver = null;
|
|
1854
|
+
let focusedMode = true; // default: show top-N nodes only
|
|
1855
|
+
const FOCUSED_TOP_N = 40; // max nodes in focused view
|
|
1856
|
+
const MAX_EDGES_PER_NODE_FOCUSED = 4; // cap per-node edges (top-K by priority) to keep graph readable
|
|
1857
|
+
const FOCUSED_EDGE_BUDGET = 120; // hard visual budget for first-render readability
|
|
1858
|
+
|
|
1859
|
+
function isLight() { return document.documentElement.getAttribute('data-theme') === 'light'; }
|
|
1860
|
+
|
|
1861
|
+
// Compute degree (edge count) for each node
|
|
1862
|
+
const nodeDegreeMap = new Map();
|
|
1863
|
+
for (const edge of kg.edges) {
|
|
1864
|
+
nodeDegreeMap.set(edge.source, (nodeDegreeMap.get(edge.source) || 0) + 1);
|
|
1865
|
+
nodeDegreeMap.set(edge.target, (nodeDegreeMap.get(edge.target) || 0) + 1);
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
// Section ordering for stable category index
|
|
1869
|
+
const SECTION_ORDER = ['core-decisions', 'operational-knowledge', 'known-gotchas', 'git-backed-facts', 'promoted-skills'];
|
|
1870
|
+
|
|
1871
|
+
// Build ECharts graph data: { categories, nodes, links }
|
|
1872
|
+
function buildEChartsData() {
|
|
1873
|
+
// Determine which nodes to show
|
|
1874
|
+
let visibleNodes = kg.nodes.filter(n => activeSections.has(n.sectionId));
|
|
1875
|
+
if (focusedMode && visibleNodes.length > FOCUSED_TOP_N) {
|
|
1876
|
+
visibleNodes.sort((a, b) => {
|
|
1877
|
+
const scoreA = (a.evidenceCount || 0) + (nodeDegreeMap.get(a.id) || 0) * 2;
|
|
1878
|
+
const scoreB = (b.evidenceCount || 0) + (nodeDegreeMap.get(b.id) || 0) * 2;
|
|
1879
|
+
return scoreB - scoreA;
|
|
1880
|
+
});
|
|
1881
|
+
visibleNodes = visibleNodes.slice(0, FOCUSED_TOP_N);
|
|
1882
|
+
}
|
|
1883
|
+
const visibleNodeIds = new Set(visibleNodes.map(n => n.id));
|
|
1884
|
+
|
|
1885
|
+
// Categories (one per section, ECharts uses index reference from nodes)
|
|
1886
|
+
const categories = SECTION_ORDER.map(sectionId => ({
|
|
1887
|
+
name: sectionLabel(sectionId),
|
|
1888
|
+
sectionId,
|
|
1889
|
+
itemStyle: { color: getSectionColor(sectionId) },
|
|
1890
|
+
}));
|
|
1891
|
+
const categoryIndexBySection = Object.fromEntries(SECTION_ORDER.map((s, i) => [s, i]));
|
|
1892
|
+
const visibleBySection = new Map(SECTION_ORDER.map(sectionId => [sectionId, []]));
|
|
1893
|
+
visibleNodes.forEach(node => {
|
|
1894
|
+
const group = visibleBySection.get(node.sectionId) || [];
|
|
1895
|
+
group.push(node);
|
|
1896
|
+
visibleBySection.set(node.sectionId, group);
|
|
1897
|
+
});
|
|
1898
|
+
|
|
1899
|
+
const sectionCenters = {
|
|
1900
|
+
'core-decisions': { x: -360, y: -170 },
|
|
1901
|
+
'operational-knowledge': { x: 0, y: 0 },
|
|
1902
|
+
'known-gotchas': { x: -340, y: 190 },
|
|
1903
|
+
'git-backed-facts': { x: 360, y: -150 },
|
|
1904
|
+
'promoted-skills': { x: 350, y: 190 },
|
|
1905
|
+
};
|
|
1906
|
+
|
|
1907
|
+
function stableNodePosition(node, index) {
|
|
1908
|
+
const group = visibleBySection.get(node.sectionId) || [];
|
|
1909
|
+
const total = Math.max(1, group.length);
|
|
1910
|
+
const center = sectionCenters[node.sectionId] || { x: 0, y: 0 };
|
|
1911
|
+
const ring = Math.floor(index / 10);
|
|
1912
|
+
const ringIndex = index % 10;
|
|
1913
|
+
const radius = total === 1 ? 0 : 34 + ring * 42;
|
|
1914
|
+
const angleStep = (Math.PI * 2) / Math.min(10, total);
|
|
1915
|
+
const angleOffset = SECTION_ORDER.indexOf(node.sectionId) * 0.55;
|
|
1916
|
+
const angle = ringIndex * angleStep + angleOffset;
|
|
1917
|
+
return {
|
|
1918
|
+
x: Math.round(center.x + Math.cos(angle) * radius),
|
|
1919
|
+
y: Math.round(center.y + Math.sin(angle) * radius),
|
|
1920
|
+
};
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
// Nodes (stable coordinates; dragging a node must not trigger global force rotation)
|
|
1924
|
+
const nodes = visibleNodes.map(node => {
|
|
1925
|
+
const symbolSize = Math.max(14, Math.min(10 + Math.sqrt(node.evidenceCount || 1) * 4, 32));
|
|
1926
|
+
const sectionIndex = (visibleBySection.get(node.sectionId) || []).findIndex(n => n.id === node.id);
|
|
1927
|
+
const pos = stableNodePosition(node, Math.max(0, sectionIndex));
|
|
1928
|
+
return {
|
|
1929
|
+
id: node.id,
|
|
1930
|
+
name: node.label.length > 28 ? node.label.slice(0, 26) + '\u2026' : node.label,
|
|
1931
|
+
x: pos.x,
|
|
1932
|
+
y: pos.y,
|
|
1933
|
+
category: categoryIndexBySection[node.sectionId] ?? 0,
|
|
1934
|
+
symbolSize,
|
|
1935
|
+
value: node.evidenceCount || 0,
|
|
1936
|
+
sectionId: node.sectionId,
|
|
1937
|
+
nodeType: node.nodeType,
|
|
1938
|
+
entityName: node.entityName || '',
|
|
1939
|
+
evidenceCount: node.evidenceCount || 0,
|
|
1940
|
+
summary: node.summary || '',
|
|
1941
|
+
refs: node.refs || [],
|
|
1942
|
+
fullLabel: node.label,
|
|
1943
|
+
};
|
|
1944
|
+
});
|
|
1945
|
+
|
|
1946
|
+
// Collect candidate edges (both endpoints visible + active type)
|
|
1947
|
+
const edgePriority = { supports: 3, derived_from: 2, mentions: 1, relates_to: 0 };
|
|
1948
|
+
const candidateEdges = kg.edges.filter(e =>
|
|
1949
|
+
activeEdgeTypes.has(e.edgeType) &&
|
|
1950
|
+
visibleNodeIds.has(e.source) &&
|
|
1951
|
+
visibleNodeIds.has(e.target),
|
|
1952
|
+
);
|
|
1953
|
+
|
|
1954
|
+
// In focused mode, cap each node's edges to top-K strongest by type priority
|
|
1955
|
+
let finalEdges = candidateEdges;
|
|
1956
|
+
if (focusedMode && candidateEdges.length > 0) {
|
|
1957
|
+
// Sort all candidates by priority descending (stronger types first)
|
|
1958
|
+
const sorted = [...candidateEdges].sort(
|
|
1959
|
+
(a, b) => (edgePriority[b.edgeType] ?? 0) - (edgePriority[a.edgeType] ?? 0),
|
|
1960
|
+
);
|
|
1961
|
+
const perNodeCount = new Map();
|
|
1962
|
+
finalEdges = [];
|
|
1963
|
+
for (const e of sorted) {
|
|
1964
|
+
if (finalEdges.length >= FOCUSED_EDGE_BUDGET) break;
|
|
1965
|
+
const sCount = perNodeCount.get(e.source) || 0;
|
|
1966
|
+
const tCount = perNodeCount.get(e.target) || 0;
|
|
1967
|
+
if (sCount >= MAX_EDGES_PER_NODE_FOCUSED || tCount >= MAX_EDGES_PER_NODE_FOCUSED) continue;
|
|
1968
|
+
perNodeCount.set(e.source, sCount + 1);
|
|
1969
|
+
perNodeCount.set(e.target, tCount + 1);
|
|
1970
|
+
finalEdges.push(e);
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
// Build ECharts link objects
|
|
1975
|
+
const links = finalEdges.map(edge => {
|
|
1976
|
+
const style = edgeStyleMap[edge.edgeType] || {};
|
|
1977
|
+
return {
|
|
1978
|
+
id: edge.id,
|
|
1979
|
+
source: edge.source,
|
|
1980
|
+
target: edge.target,
|
|
1981
|
+
edgeType: edge.edgeType,
|
|
1982
|
+
edgeLabel: style.label || edge.edgeType,
|
|
1983
|
+
lineStyle: {
|
|
1984
|
+
color: style.color || '#999',
|
|
1985
|
+
opacity: 0.5,
|
|
1986
|
+
width: 1.1,
|
|
1987
|
+
curveness: 0.12,
|
|
1988
|
+
type: Array.isArray(style.dash) ? 'dashed' : 'solid',
|
|
1989
|
+
},
|
|
1990
|
+
symbol: style.arrow ? ['none', 'arrow'] : ['none', 'none'],
|
|
1991
|
+
symbolSize: [4, 7],
|
|
1992
|
+
};
|
|
1993
|
+
});
|
|
1994
|
+
|
|
1995
|
+
return { categories, nodes, links };
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
let lastRenderedCounts = { nodes: 0, edges: 0 };
|
|
1999
|
+
|
|
2000
|
+
// Render the page shell
|
|
2001
|
+
container.innerHTML = `
|
|
2002
|
+
<div class="kg-page-shell">
|
|
2003
|
+
<div class="page-header">
|
|
2004
|
+
<h1 class="page-title">${t('kgTitle')}</h1>
|
|
2005
|
+
<p class="page-subtitle">${t('kgSubtitle')}</p>
|
|
2006
|
+
</div>
|
|
2007
|
+
<div class="graph-layout">
|
|
2008
|
+
<div class="graph-filter-panel" id="kg-filter-panel"></div>
|
|
2009
|
+
<div id="graph-container">
|
|
2010
|
+
<div id="echarts-mount" style="width:100%;height:100%;"></div>
|
|
2011
|
+
<div class="graph-status-bar">
|
|
2012
|
+
<span class="graph-status-item" id="gs-nodes"></span>
|
|
2013
|
+
<span class="graph-status-item" id="gs-edges"></span>
|
|
2014
|
+
<span class="graph-status-item" id="gs-clusters"></span>
|
|
2015
|
+
<div class="graph-zoom-controls">
|
|
2016
|
+
<button class="graph-zoom-btn" id="gz-out">\u2212</button>
|
|
2017
|
+
<button class="graph-zoom-btn" id="gz-fit">\u2B21</button>
|
|
2018
|
+
<button class="graph-zoom-btn" id="gz-in">+</button>
|
|
2019
|
+
</div>
|
|
2020
|
+
</div>
|
|
2021
|
+
</div>
|
|
2022
|
+
<div class="graph-inspector" id="graph-inspector">
|
|
2023
|
+
<div class="gi-empty"><div class="gi-empty-icon">\u2B21</div>${t('graphSelectNode')}</div>
|
|
2024
|
+
</div>
|
|
2025
|
+
</div>
|
|
2026
|
+
</div>
|
|
2027
|
+
`;
|
|
2028
|
+
|
|
2029
|
+
function initEChartsGraph() {
|
|
2030
|
+
const mountEl = document.getElementById('echarts-mount');
|
|
2031
|
+
if (!mountEl || typeof echarts === 'undefined') return;
|
|
2032
|
+
|
|
2033
|
+
// Ensure container has dimensions before init
|
|
2034
|
+
if (mountEl.clientWidth === 0 || mountEl.clientHeight === 0) {
|
|
2035
|
+
// Defer until layout finishes
|
|
2036
|
+
requestAnimationFrame(() => initEChartsGraph());
|
|
2037
|
+
return;
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
// Dispose previous instance if any
|
|
2041
|
+
if (echartsInstance) {
|
|
2042
|
+
try { echartsInstance.dispose(); } catch (_) { /* noop */ }
|
|
2043
|
+
echartsInstance = null;
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
const data = buildEChartsData();
|
|
2047
|
+
lastRenderedCounts = { nodes: data.nodes.length, edges: data.links.length };
|
|
2048
|
+
const light = isLight();
|
|
2049
|
+
const labelColor = light ? '#1C1B1F' : '#E6E1E5';
|
|
2050
|
+
const labelMutedColor = light ? '#666' : '#999';
|
|
2051
|
+
const tooltipBg = light ? 'rgba(255,255,255,0.96)' : 'rgba(20,20,28,0.96)';
|
|
2052
|
+
const tooltipBorder = light ? 'rgba(0,0,0,0.1)' : 'rgba(255,255,255,0.12)';
|
|
2053
|
+
|
|
2054
|
+
echartsInstance = echarts.init(mountEl, null, { renderer: 'canvas' });
|
|
2055
|
+
|
|
2056
|
+
const wrap = (txt) => String(txt ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
2057
|
+
|
|
2058
|
+
const option = {
|
|
2059
|
+
backgroundColor: 'transparent',
|
|
2060
|
+
tooltip: {
|
|
2061
|
+
trigger: 'item',
|
|
2062
|
+
backgroundColor: tooltipBg,
|
|
2063
|
+
borderColor: tooltipBorder,
|
|
2064
|
+
borderWidth: 1,
|
|
2065
|
+
textStyle: { color: labelColor, fontSize: 12, fontFamily: 'Inter, system-ui, sans-serif' },
|
|
2066
|
+
extraCssText: 'box-shadow: 0 4px 16px rgba(0,0,0,0.18); border-radius: 8px; padding: 10px 12px; max-width: 320px;',
|
|
2067
|
+
formatter: (params) => {
|
|
2068
|
+
if (params.dataType === 'node') {
|
|
2069
|
+
const d = params.data;
|
|
2070
|
+
const summary = d.summary ? String(d.summary).slice(0, 160) : '';
|
|
2071
|
+
return (
|
|
2072
|
+
`<div style="font-weight:600;font-size:13px;margin-bottom:4px;line-height:1.35;">${wrap(d.fullLabel || d.name)}</div>` +
|
|
2073
|
+
`<div style="font-size:11px;color:${labelMutedColor};margin-bottom:6px;">${wrap(d.nodeType || '')}${d.entityName ? ' \u00b7 ' + wrap(d.entityName) : ''}</div>` +
|
|
2074
|
+
`<div style="font-size:11px;color:${labelMutedColor};">${d.evidenceCount || 0} ${wrap(t('kgInspectorEvidence'))}</div>` +
|
|
2075
|
+
(summary ? `<div style="font-size:11.5px;line-height:1.5;margin-top:8px;color:${labelColor};opacity:0.85;">${wrap(summary)}\u2026</div>` : '')
|
|
2076
|
+
);
|
|
2077
|
+
}
|
|
2078
|
+
if (params.dataType === 'edge') {
|
|
2079
|
+
return `<div style="font-size:12px;">${wrap(params.data.edgeLabel || params.data.edgeType)}</div>`;
|
|
2080
|
+
}
|
|
2081
|
+
return '';
|
|
2082
|
+
},
|
|
2083
|
+
},
|
|
2084
|
+
legend: [{
|
|
2085
|
+
data: data.categories.map(c => c.name),
|
|
2086
|
+
textStyle: { color: labelColor, fontSize: 11 },
|
|
2087
|
+
top: 8,
|
|
2088
|
+
right: 12,
|
|
2089
|
+
itemWidth: 10,
|
|
2090
|
+
itemHeight: 10,
|
|
2091
|
+
itemGap: 12,
|
|
2092
|
+
icon: 'circle',
|
|
2093
|
+
selectedMode: false,
|
|
2094
|
+
}],
|
|
2095
|
+
animationDuration: 500,
|
|
2096
|
+
animationEasingUpdate: 'quinticInOut',
|
|
2097
|
+
series: [{
|
|
2098
|
+
type: 'graph',
|
|
2099
|
+
layout: 'none',
|
|
2100
|
+
roam: true,
|
|
2101
|
+
draggable: true,
|
|
2102
|
+
zoom: 1,
|
|
2103
|
+
scaleLimit: { min: 0.2, max: 4 },
|
|
2104
|
+
edgeSymbol: ['none', 'arrow'],
|
|
2105
|
+
edgeSymbolSize: [0, 8],
|
|
2106
|
+
categories: data.categories,
|
|
2107
|
+
data: data.nodes,
|
|
2108
|
+
edges: data.links,
|
|
2109
|
+
label: {
|
|
2110
|
+
show: true,
|
|
2111
|
+
position: 'right',
|
|
2112
|
+
formatter: '{b}',
|
|
2113
|
+
color: labelColor,
|
|
2114
|
+
fontSize: 11,
|
|
2115
|
+
fontFamily: 'Inter, system-ui, sans-serif',
|
|
2116
|
+
backgroundColor: light ? 'rgba(255,255,255,0.82)' : 'rgba(15,15,23,0.72)',
|
|
2117
|
+
padding: [2, 5],
|
|
2118
|
+
borderRadius: 3,
|
|
2119
|
+
},
|
|
2120
|
+
labelLayout: { hideOverlap: true, moveOverlap: 'shiftY' },
|
|
2121
|
+
itemStyle: {
|
|
2122
|
+
opacity: 0.92,
|
|
2123
|
+
borderColor: light ? 'rgba(0,0,0,0.22)' : 'rgba(255,255,255,0.2)',
|
|
2124
|
+
borderWidth: 1,
|
|
2125
|
+
shadowColor: light ? 'rgba(0,0,0,0.18)' : 'rgba(0,0,0,0.45)',
|
|
2126
|
+
shadowBlur: 8,
|
|
2127
|
+
},
|
|
2128
|
+
emphasis: {
|
|
2129
|
+
focus: 'adjacency',
|
|
2130
|
+
label: { show: true, fontWeight: 700, fontSize: 12 },
|
|
2131
|
+
itemStyle: { borderColor: light ? '#6750A4' : '#D0BCFF', borderWidth: 2 },
|
|
2132
|
+
lineStyle: { width: 2.5, opacity: 1 },
|
|
2133
|
+
},
|
|
2134
|
+
blur: {
|
|
2135
|
+
itemStyle: { opacity: 0.18 },
|
|
2136
|
+
label: { opacity: 0.25 },
|
|
2137
|
+
lineStyle: { opacity: 0.06 },
|
|
2138
|
+
},
|
|
2139
|
+
lineStyle: { opacity: 0.55, curveness: 0.15, width: 1.2 },
|
|
2140
|
+
}],
|
|
2141
|
+
};
|
|
2142
|
+
|
|
2143
|
+
echartsInstance.setOption(option);
|
|
2144
|
+
|
|
2145
|
+
// Click handlers — use ECharts event API (auto-cleaned on dispose) instead of zrender
|
|
2146
|
+
echartsInstance.on('click', (params) => {
|
|
2147
|
+
if (params.dataType === 'node') {
|
|
2148
|
+
const id = params.data?.id;
|
|
2149
|
+
if (id) {
|
|
2150
|
+
selectedNodeId = id;
|
|
2151
|
+
showKGInspector(id);
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
});
|
|
2155
|
+
// Click on blank canvas deselects (ECharts emits click with empty target at chart level).
|
|
2156
|
+
// Use `click` event at series level only; do NOT intercept zrender events so roam/pan works.
|
|
2157
|
+
// User reported pan-on-empty-canvas not working when zr click was intercepted.
|
|
2158
|
+
|
|
2159
|
+
// Resize observer
|
|
2160
|
+
if (echartsResizeObserver) {
|
|
2161
|
+
try { echartsResizeObserver.disconnect(); } catch (_) { /* noop */ }
|
|
2162
|
+
}
|
|
2163
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
2164
|
+
echartsResizeObserver = new ResizeObserver(() => {
|
|
2165
|
+
if (echartsInstance) echartsInstance.resize();
|
|
2166
|
+
});
|
|
2167
|
+
echartsResizeObserver.observe(mountEl);
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
window._kgEChart = echartsInstance;
|
|
2171
|
+
window._kgShowInspector = showKGInspector;
|
|
2172
|
+
|
|
2173
|
+
updateStatusBar();
|
|
2174
|
+
bindZoomControls();
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
function bindZoomControls() {
|
|
2178
|
+
const outBtn = document.getElementById('gz-out');
|
|
2179
|
+
const fitBtn = document.getElementById('gz-fit');
|
|
2180
|
+
const inBtn = document.getElementById('gz-in');
|
|
2181
|
+
const setZoom = (factor) => {
|
|
2182
|
+
if (!echartsInstance) return;
|
|
2183
|
+
const opt = echartsInstance.getOption();
|
|
2184
|
+
const cur = (opt.series && opt.series[0] && opt.series[0].zoom) || 1;
|
|
2185
|
+
echartsInstance.setOption({ series: [{ zoom: Math.min(4, Math.max(0.2, cur * factor)) }] });
|
|
2186
|
+
};
|
|
2187
|
+
if (outBtn) outBtn.onclick = () => setZoom(0.75);
|
|
2188
|
+
if (fitBtn) fitBtn.onclick = () => {
|
|
2189
|
+
if (echartsInstance) echartsInstance.setOption({ series: [{ zoom: 1, center: null }] });
|
|
2190
|
+
};
|
|
2191
|
+
if (inBtn) inBtn.onclick = () => setZoom(1.35);
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
function updateStatusBar() {
|
|
2195
|
+
const gsNodes = document.getElementById('gs-nodes');
|
|
2196
|
+
const gsEdges = document.getElementById('gs-edges');
|
|
2197
|
+
const gsClusters = document.getElementById('gs-clusters');
|
|
2198
|
+
const shownNodes = focusedMode ? Math.min(kg.nodes.length, FOCUSED_TOP_N) : kg.nodes.length;
|
|
2199
|
+
const shownLabel = focusedMode && kg.nodes.length > FOCUSED_TOP_N ? `${shownNodes}/${kg.nodes.length}` : `${kg.nodes.length}`;
|
|
2200
|
+
if (gsNodes) gsNodes.textContent = `${shownLabel} ${t('kgNodes')}`;
|
|
2201
|
+
const edgeLabel = lastRenderedCounts.edges < kg.edges.length
|
|
2202
|
+
? `${lastRenderedCounts.edges}/${kg.edges.length}`
|
|
2203
|
+
: `${kg.edges.length}`;
|
|
2204
|
+
if (gsEdges) gsEdges.textContent = `${edgeLabel} ${t('kgEdges')}`;
|
|
2205
|
+
if (gsClusters) gsClusters.textContent = `${(kg.clusters || []).length} ${t('kgClusters')}`;
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
// Inspector
|
|
2209
|
+
function showKGInspector(nodeId) {
|
|
2210
|
+
const inspector = document.getElementById('graph-inspector');
|
|
2211
|
+
if (!inspector) return;
|
|
2212
|
+
if (!nodeId) {
|
|
2213
|
+
inspector.innerHTML = '<div class="gi-empty"><div class="gi-empty-icon">\u2B21</div>' + t('graphSelectNode') + '</div>';
|
|
2214
|
+
return;
|
|
2215
|
+
}
|
|
2216
|
+
const node = kg.nodes.find(n => n.id === nodeId);
|
|
2217
|
+
if (!node) {
|
|
2218
|
+
inspector.innerHTML = '<div class="gi-empty"><div class="gi-empty-icon">\u2B21</div>' + t('graphSelectNode') + '</div>';
|
|
2219
|
+
return;
|
|
2220
|
+
}
|
|
2221
|
+
const color = getSectionColor(node.sectionId);
|
|
2222
|
+
const relatedEdges = kg.edges.filter(e => e.source === nodeId || e.target === nodeId);
|
|
2223
|
+
|
|
2224
|
+
const refsHtml = (node.refs || []).length > 0
|
|
2225
|
+
? node.refs.map(r => `<span class="knowledge-ref-chip" data-kind="${escapeHtml(r.kind)}">${escapeHtml(r.id)}</span>`).join('')
|
|
2226
|
+
: '<span style="font-size:12px;color:var(--text-muted);">—</span>';
|
|
2227
|
+
|
|
2228
|
+
const edgeHtml = relatedEdges.length > 0
|
|
2229
|
+
? relatedEdges.map(e => {
|
|
2230
|
+
const dir = e.source === nodeId;
|
|
2231
|
+
const other = dir ? e.target : e.source;
|
|
2232
|
+
const otherNode = kg.nodes.find(n => n.id === other);
|
|
2233
|
+
const style = edgeStyleMap[e.edgeType] || {};
|
|
2234
|
+
return `<div class="gi-rel-item">
|
|
2235
|
+
<span class="gi-rel-arrow">${dir ? '\u2192' : '\u2190'}</span>
|
|
2236
|
+
<span class="gi-rel-type" style="color:${style.color || 'var(--text-muted)'}">${escapeHtml(style.label || e.edgeType)}</span>
|
|
2237
|
+
<span class="gi-rel-target" data-kg-nav="${escapeHtml(other)}">${escapeHtml(otherNode?.label || other)}</span>
|
|
2238
|
+
</div>`;
|
|
2239
|
+
}).join('')
|
|
2240
|
+
: '<div style="font-size:12px;color:var(--text-muted);font-style:italic;">' + t('kgInspectorNoEdges') + '</div>';
|
|
2241
|
+
|
|
2242
|
+
inspector.innerHTML = `
|
|
2243
|
+
<div class="gi-header">
|
|
2244
|
+
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
|
|
2245
|
+
<span style="width:10px;height:10px;border-radius:50%;background:${color};flex-shrink:0;"></span>
|
|
2246
|
+
<div class="gi-name">${escapeHtml(node.label)}</div>
|
|
2247
|
+
</div>
|
|
2248
|
+
<div class="gi-type">${escapeHtml(node.nodeType)}</div>
|
|
2249
|
+
</div>
|
|
2250
|
+
<div class="gi-stats">
|
|
2251
|
+
<div class="gi-stat"><div class="gi-stat-value">${node.evidenceCount}</div><div class="gi-stat-label">${t('kgInspectorEvidence')}</div></div>
|
|
2252
|
+
<div class="gi-stat"><div class="gi-stat-value">${relatedEdges.length}</div><div class="gi-stat-label">${t('kgInspectorRelatedEdges')}</div></div>
|
|
2253
|
+
</div>
|
|
2254
|
+
<div class="gi-section">
|
|
2255
|
+
<div class="gi-section-title">${t('kgInspectorSection')}</div>
|
|
2256
|
+
<div style="font-size:12px;color:${color};font-weight:500;">${escapeHtml(sectionLabel(node.sectionId))}</div>
|
|
2257
|
+
</div>
|
|
2258
|
+
${node.entityName ? `<div class="gi-section"><div class="gi-section-title">${t('kgInspectorEntity')}</div><div style="font-size:12px;color:var(--accent-cyan);font-family:var(--font-mono);">${escapeHtml(node.entityName)}</div></div>` : ''}
|
|
2259
|
+
<div class="gi-section">
|
|
2260
|
+
<div class="gi-section-title">${t('kgInspectorSummary')}</div>
|
|
2261
|
+
<div style="font-size:12px;color:var(--text-secondary);line-height:1.5;">${escapeHtml(node.summary || '—')}</div>
|
|
2262
|
+
</div>
|
|
2263
|
+
<div class="gi-section">
|
|
2264
|
+
<div class="gi-section-title">${t('kgInspectorProvenance')}</div>
|
|
2265
|
+
<div class="knowledge-ref-list">${refsHtml}</div>
|
|
2266
|
+
</div>
|
|
2267
|
+
<div class="gi-section">
|
|
2268
|
+
<div class="gi-section-title">${t('kgInspectorRelatedEdges')} <span class="gi-section-count">${relatedEdges.length}</span></div>
|
|
2269
|
+
${edgeHtml}
|
|
2270
|
+
</div>
|
|
2271
|
+
`;
|
|
2272
|
+
|
|
2273
|
+
inspector.querySelectorAll('[data-kg-nav]').forEach(el => {
|
|
2274
|
+
el.addEventListener('click', () => {
|
|
2275
|
+
const targetId = el.dataset.kgNav;
|
|
2276
|
+
if (echartsInstance) {
|
|
2277
|
+
try {
|
|
2278
|
+
echartsInstance.dispatchAction({ type: 'highlight', seriesIndex: 0, dataType: 'node', name: kg.nodes.find(n => n.id === targetId)?.label });
|
|
2279
|
+
} catch (_) { /* noop */ }
|
|
2280
|
+
}
|
|
2281
|
+
showKGInspector(targetId);
|
|
2282
|
+
});
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
// Filter panel
|
|
2287
|
+
function renderKGFilterPanel() {
|
|
2288
|
+
const panel = document.getElementById('kg-filter-panel');
|
|
2289
|
+
if (!panel) return;
|
|
2290
|
+
|
|
2291
|
+
const clusterEntries = (kg.clusters || []).map(c => [c.sectionId, c]);
|
|
2292
|
+
const clusterHtml = `
|
|
2293
|
+
<div class="gfp-section">
|
|
2294
|
+
<div class="gfp-label">${t('kgClusterFilter')}</div>
|
|
2295
|
+
<div class="gfp-radio-group">
|
|
2296
|
+
${clusterEntries.map(([id, cluster]) => `
|
|
2297
|
+
<button class="gfp-check${activeSections.has(id) ? ' active' : ''}" data-section-filter="${escapeHtml(id)}">
|
|
2298
|
+
<span class="gfp-check-box">\u2713</span>
|
|
2299
|
+
<span class="gfp-type-dot" style="background:${getSectionColor(id)}"></span>
|
|
2300
|
+
${escapeHtml(sectionLabel(cluster.sectionId))}
|
|
2301
|
+
<span class="gfp-check-count">${cluster.nodeCount}</span>
|
|
2302
|
+
</button>
|
|
2303
|
+
`).join('')}
|
|
2304
|
+
</div>
|
|
2305
|
+
</div>
|
|
2306
|
+
`;
|
|
2307
|
+
|
|
2308
|
+
const edgeTypeEntries = Object.entries(edgeStyleMap);
|
|
2309
|
+
const edgeTypeHtml = `
|
|
2310
|
+
<div class="gfp-section">
|
|
2311
|
+
<div class="gfp-label">${t('kgEdgeTypeFilter')}</div>
|
|
2312
|
+
<div class="gfp-radio-group">
|
|
2313
|
+
${edgeTypeEntries.map(([type, style]) => `
|
|
2314
|
+
<button class="gfp-check${activeEdgeTypes.has(type) ? ' active' : ''}" data-edge-type-filter="${escapeHtml(type)}">
|
|
2315
|
+
<span class="gfp-check-box">\u2713</span>
|
|
2316
|
+
<span style="color:${style.color};font-size:11px;">\u2192</span>
|
|
2317
|
+
${escapeHtml(style.label)}
|
|
2318
|
+
<span class="gfp-check-count">${kg.edges.filter(e => e.edgeType === type).length}</span>
|
|
2319
|
+
</button>
|
|
2320
|
+
`).join('')}
|
|
2321
|
+
</div>
|
|
2322
|
+
</div>
|
|
2323
|
+
`;
|
|
2324
|
+
|
|
2325
|
+
const searchHtml = `
|
|
2326
|
+
<div class="gfp-section">
|
|
2327
|
+
<div class="gfp-label">${t('graphSearch')}</div>
|
|
2328
|
+
<input type="text" class="gfp-search" id="kg-search" placeholder="${t('graphFindEntity')}" autocomplete="off" />
|
|
2329
|
+
</div>
|
|
2330
|
+
`;
|
|
2331
|
+
|
|
2332
|
+
const viewModeHtml = `
|
|
2333
|
+
<div class="gfp-section">
|
|
2334
|
+
<div class="gfp-label">${t('kgViewMode')}</div>
|
|
2335
|
+
<div class="gfp-depth-row">
|
|
2336
|
+
<button class="gfp-depth-btn${focusedMode ? ' active' : ''}" data-view-mode="focused">${t('kgFocused')}</button>
|
|
2337
|
+
<button class="gfp-depth-btn${!focusedMode ? ' active' : ''}" data-view-mode="full">${t('kgFullGraph')}</button>
|
|
2338
|
+
</div>
|
|
2339
|
+
</div>
|
|
2340
|
+
`;
|
|
2341
|
+
|
|
2342
|
+
panel.innerHTML = searchHtml + viewModeHtml + clusterHtml + edgeTypeHtml;
|
|
2343
|
+
|
|
2344
|
+
// Bind section filters
|
|
2345
|
+
panel.querySelectorAll('[data-section-filter]').forEach(btn => {
|
|
2346
|
+
btn.addEventListener('click', () => {
|
|
2347
|
+
const id = btn.dataset.sectionFilter;
|
|
2348
|
+
if (activeSections.has(id)) activeSections.delete(id);
|
|
2349
|
+
else activeSections.add(id);
|
|
2350
|
+
initEChartsGraph();
|
|
2351
|
+
renderKGFilterPanel();
|
|
2352
|
+
});
|
|
2353
|
+
});
|
|
2354
|
+
|
|
2355
|
+
// Bind edge type filters
|
|
2356
|
+
panel.querySelectorAll('[data-edge-type-filter]').forEach(btn => {
|
|
2357
|
+
btn.addEventListener('click', () => {
|
|
2358
|
+
const type = btn.dataset.edgeTypeFilter;
|
|
2359
|
+
if (activeEdgeTypes.has(type)) activeEdgeTypes.delete(type);
|
|
2360
|
+
else activeEdgeTypes.add(type);
|
|
2361
|
+
initEChartsGraph();
|
|
2362
|
+
renderKGFilterPanel();
|
|
2363
|
+
});
|
|
2364
|
+
});
|
|
2365
|
+
|
|
2366
|
+
// Bind view mode toggle
|
|
2367
|
+
panel.querySelectorAll('[data-view-mode]').forEach(btn => {
|
|
2368
|
+
btn.addEventListener('click', () => {
|
|
2369
|
+
const mode = btn.dataset.viewMode;
|
|
2370
|
+
focusedMode = mode === 'focused';
|
|
2371
|
+
initEChartsGraph();
|
|
2372
|
+
renderKGFilterPanel();
|
|
2373
|
+
});
|
|
2374
|
+
});
|
|
2375
|
+
|
|
2376
|
+
// Bind search
|
|
2377
|
+
const searchInput = document.getElementById('kg-search');
|
|
2378
|
+
if (searchInput) {
|
|
2379
|
+
searchInput.addEventListener('input', () => {
|
|
2380
|
+
const q = searchInput.value.toLowerCase();
|
|
2381
|
+
if (!echartsInstance) return;
|
|
2382
|
+
try {
|
|
2383
|
+
echartsInstance.dispatchAction({ type: 'downplay', seriesIndex: 0 });
|
|
2384
|
+
} catch (_) { /* noop */ }
|
|
2385
|
+
if (!q) return;
|
|
2386
|
+
// Highlight matched nodes (which auto-blurs others via emphasis.focus 'adjacency' isn't ideal here;
|
|
2387
|
+
// instead, use 'highlight' on matching dataIndex array for visual emphasis)
|
|
2388
|
+
const data = buildEChartsData();
|
|
2389
|
+
const matchIndexes = [];
|
|
2390
|
+
data.nodes.forEach((n, idx) => {
|
|
2391
|
+
const match = (n.fullLabel || '').toLowerCase().includes(q) ||
|
|
2392
|
+
(n.nodeType || '').toLowerCase().includes(q) ||
|
|
2393
|
+
(n.entityName || '').toLowerCase().includes(q);
|
|
2394
|
+
if (match) matchIndexes.push(idx);
|
|
2395
|
+
});
|
|
2396
|
+
if (matchIndexes.length > 0) {
|
|
2397
|
+
try {
|
|
2398
|
+
echartsInstance.dispatchAction({ type: 'highlight', seriesIndex: 0, dataIndex: matchIndexes });
|
|
2399
|
+
} catch (_) { /* noop */ }
|
|
2400
|
+
}
|
|
2401
|
+
});
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
initEChartsGraph();
|
|
2406
|
+
renderKGFilterPanel();
|
|
2407
|
+
}
|
|
2408
|
+
|
|
1485
2409
|
// ============================================================
|
|
1486
2410
|
// Cytoscape.js + Dagre — Focused Topology Renderer
|
|
1487
2411
|
// Default: 1-hop neighborhood of top entity, dagre LR layout
|