memorix 1.8.3 → 1.8.5
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 +53 -0
- package/CLAUDE.md +3 -0
- package/README.md +4 -4
- package/README.zh-CN.md +5 -3
- package/dist/cli/index.js +1544 -635
- package/dist/dashboard/static/app.js +29 -885
- package/dist/dashboard/static/index.html +1 -5
- package/dist/dashboard/static/style.css +36 -503
- package/dist/index.d.ts +4 -4
- package/dist/index.js +214 -73
- package/dist/memcode/index.js +28035 -169
- package/dist/memcode-runtime/CHANGELOG.md +53 -0
- package/dist/sdk.js +30 -14
- package/docs/1.8.0-RELEASE-CANDIDATE-SPEC.md +22 -16
- package/docs/AGENT_OPERATOR_PLAYBOOK.md +2 -2
- package/docs/API_REFERENCE.md +10 -8
- package/docs/DASHBOARD.md +23 -0
- package/docs/DEVELOPMENT.md +2 -2
- package/docs/DOCKER.md +6 -3
- package/docs/INTEGRATIONS.md +1 -1
- package/docs/MODULES.md +46 -20
- package/docs/PERFORMANCE.md +1 -1
- package/docs/README.md +8 -7
- package/docs/SETUP.md +3 -3
- package/package.json +11 -11
- package/plugins/antigravity/memorix/plugin.json +1 -1
- package/plugins/antigravity/memorix/rules/memorix.md +5 -0
- package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
- package/plugins/claude/memorix/skills/memorix-memory/SKILL.md +3 -0
- package/plugins/claude/memorix/skills/memorix-mini-skills/SKILL.md +2 -0
- package/plugins/claude/memorix/skills/memorix-orchestrate/SKILL.md +2 -0
- package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
- package/plugins/codebuddy/memorix/skills/memorix-memory/SKILL.md +3 -0
- package/plugins/codebuddy/memorix/skills/memorix-mini-skills/SKILL.md +2 -0
- package/plugins/codebuddy/memorix/skills/memorix-orchestrate/SKILL.md +2 -0
- package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/codex/memorix/skills/memorix-memory/SKILL.md +3 -0
- package/plugins/codex/memorix/skills/memorix-mini-skills/SKILL.md +2 -0
- package/plugins/codex/memorix/skills/memorix-orchestrate/SKILL.md +2 -0
- package/plugins/copilot/memorix/plugin.json +1 -1
- package/plugins/copilot/memorix/skills/memorix-memory/SKILL.md +3 -0
- package/plugins/copilot/memorix/skills/memorix-mini-skills/SKILL.md +2 -0
- package/plugins/copilot/memorix/skills/memorix-orchestrate/SKILL.md +2 -0
- package/plugins/cursor/memorix/skills/memorix-memory/SKILL.md +3 -0
- package/plugins/cursor/memorix/skills/memorix-mini-skills/SKILL.md +2 -0
- package/plugins/cursor/memorix/skills/memorix-orchestrate/SKILL.md +2 -0
- package/plugins/gemini/memorix/GEMINI.md +5 -0
- package/plugins/gemini/memorix/gemini-extension.json +1 -1
- package/plugins/hermes/memorix/plugin.yaml +7 -1
- package/plugins/omp/memorix/package.json +1 -1
- package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/pi/memorix/package.json +1 -1
- package/plugins/pi/memorix/skills/memorix-memory/SKILL.md +3 -0
- package/plugins/pi/memorix/skills/memorix-mini-skills/SKILL.md +2 -0
- package/plugins/pi/memorix/skills/memorix-orchestrate/SKILL.md +2 -0
- package/server.json +2 -2
- package/src/cli/command-guide.ts +52 -1
- package/src/cli/commands/background.ts +166 -6
- package/src/cli/commands/serve-http.ts +74 -86
- package/src/cli/commands/serve.ts +95 -93
- package/src/cli/index.ts +1 -1
- package/src/dashboard/static/index.html +1 -5
- package/src/dashboard/static/style.css +36 -503
- package/src/hooks/installers/index.ts +3 -0
- package/src/hooks/official-skills.ts +13 -6
- package/src/memory/graph.ts +2 -2
- package/src/server/mcp-diagnostics.ts +9 -7
- package/src/server/mcp-discovery.ts +0 -1
- package/src/server/modern-mcp-bridge.ts +108 -0
- package/src/server/stdio-startup-gate.ts +41 -2
- package/src/server/tool-profile.ts +7 -6
- package/src/server.ts +1 -1
- package/src/store/orama-store.ts +2 -2
- package/src/wiki/types.ts +2 -2
|
@@ -25,16 +25,6 @@ const i18n = {
|
|
|
25
25
|
noDataDesc: 'Start using Memorix to see your dashboard',
|
|
26
26
|
|
|
27
27
|
// Graph
|
|
28
|
-
knowledgeGraph: 'Memory Entity Map',
|
|
29
|
-
noGraphData: 'No Graph Data',
|
|
30
|
-
noGraphDataDesc: 'Create entities and relations to see your memory entity map',
|
|
31
|
-
observation_s: 'observation(s)',
|
|
32
|
-
nodes: 'nodes',
|
|
33
|
-
edges: 'edges',
|
|
34
|
-
clickNodeToView: 'Click a node to view details',
|
|
35
|
-
legend: 'Legend',
|
|
36
|
-
noObservations: 'No observations',
|
|
37
|
-
noRelations: 'No relations',
|
|
38
28
|
|
|
39
29
|
// Observations
|
|
40
30
|
observationsStored: 'observations stored',
|
|
@@ -367,33 +357,9 @@ const i18n = {
|
|
|
367
357
|
taskFailed: 'Failed',
|
|
368
358
|
|
|
369
359
|
// Graph (additional)
|
|
370
|
-
graphIsolatedEntities: 'Isolated Entities',
|
|
371
|
-
graphIsolatedDesc: 'entities with no relations — shown separately for readability',
|
|
372
|
-
graphConnected: 'Connected',
|
|
373
|
-
graphNeighborhood: 'Neighborhood',
|
|
374
|
-
graphFullGraph: 'Full Graph',
|
|
375
|
-
graphTopology: 'Topology',
|
|
376
|
-
graphTable: 'Table',
|
|
377
360
|
graphSearch: 'Search',
|
|
378
|
-
graphScope: 'Scope',
|
|
379
|
-
graphView: 'View',
|
|
380
|
-
graphLayout: 'Layout',
|
|
381
|
-
graphEntityType: 'Entity Type',
|
|
382
|
-
graphDepth: 'Depth',
|
|
383
|
-
graphConnections: 'Connections',
|
|
384
|
-
graphEvidence: 'Evidence',
|
|
385
|
-
graphObservations: 'Observations',
|
|
386
|
-
graphRelations: 'Relations',
|
|
387
|
-
graphSparseWarning: 'Sparse graph: %isolated% of %total% entities have no relations. Isolated entities shown in inventory below.',
|
|
388
|
-
graphNoRelations: 'No relations — isolated entities only',
|
|
389
|
-
graphNoRelationsDesc: 'This project has entities but no relations between them. All entities are shown in the inventory panel below.',
|
|
390
|
-
graphEmptyState: 'No Graph Data',
|
|
391
|
-
graphEmptyStateDesc: 'Create entities and relations to see your knowledge graph',
|
|
392
361
|
graphSelectNode: 'Select a node to inspect',
|
|
393
362
|
graphFindEntity: 'Find entity...',
|
|
394
|
-
graphLeftToRight: 'Left → Right',
|
|
395
|
-
graphTopToBottom: 'Top → Bottom',
|
|
396
|
-
graphMore: 'more',
|
|
397
363
|
|
|
398
364
|
// Deterministic memory-map projection
|
|
399
365
|
kgTitle: 'Memory Map',
|
|
@@ -403,6 +369,8 @@ const i18n = {
|
|
|
403
369
|
kgClusters: 'clusters',
|
|
404
370
|
kgNoData: 'No Memory Map Data',
|
|
405
371
|
kgNoDataDesc: 'Store durable observations or promote mini-skills to build a deterministic memory map.',
|
|
372
|
+
kgUnavailable: 'Memory Map Unavailable',
|
|
373
|
+
kgUnavailableDesc: 'The memory-map service did not return a valid response. Check the Dashboard service and try again.',
|
|
406
374
|
kgClusterFilter: 'Section Cluster',
|
|
407
375
|
kgEdgeTypeFilter: 'Edge Type',
|
|
408
376
|
kgEdgeSupports: 'supports',
|
|
@@ -416,9 +384,6 @@ const i18n = {
|
|
|
416
384
|
kgInspectorEvidence: 'Evidence',
|
|
417
385
|
kgInspectorRelatedEdges: 'Related Edges',
|
|
418
386
|
kgInspectorNoEdges: 'No edges',
|
|
419
|
-
kgDataMode: 'Data Source',
|
|
420
|
-
kgModeSemantic: 'Inferred Memory Map',
|
|
421
|
-
kgModeEntity: 'Entity Graph',
|
|
422
387
|
kgViewMode: 'View Mode',
|
|
423
388
|
kgFocused: 'Focused',
|
|
424
389
|
kgFullGraph: 'Full Graph',
|
|
@@ -508,16 +473,6 @@ const i18n = {
|
|
|
508
473
|
noDataDesc: '开始使用 Memorix 来查看仪表盘',
|
|
509
474
|
|
|
510
475
|
// Graph
|
|
511
|
-
knowledgeGraph: '知识图谱',
|
|
512
|
-
noGraphData: '暂无图谱数据',
|
|
513
|
-
noGraphDataDesc: '创建实体和关系来查看知识图谱',
|
|
514
|
-
observation_s: '条观察',
|
|
515
|
-
nodes: '个节点',
|
|
516
|
-
edges: '条边',
|
|
517
|
-
clickNodeToView: '点击节点查看详情',
|
|
518
|
-
legend: '图例',
|
|
519
|
-
noObservations: '暂无观察',
|
|
520
|
-
noRelations: '暂无关系',
|
|
521
476
|
|
|
522
477
|
// Observations
|
|
523
478
|
observationsStored: '条观察已存储',
|
|
@@ -855,33 +810,9 @@ const i18n = {
|
|
|
855
810
|
sessionsTotalLower: '总计',
|
|
856
811
|
|
|
857
812
|
// Graph (additional)
|
|
858
|
-
graphIsolatedEntities: '孤立实体',
|
|
859
|
-
graphIsolatedDesc: '无关系的实体 — 单独显示以提高可读性',
|
|
860
|
-
graphConnected: '已连接',
|
|
861
|
-
graphNeighborhood: '邻域',
|
|
862
|
-
graphFullGraph: '完整图谱',
|
|
863
|
-
graphTopology: '拓扑',
|
|
864
|
-
graphTable: '表格',
|
|
865
813
|
graphSearch: '搜索',
|
|
866
|
-
graphScope: '范围',
|
|
867
|
-
graphView: '视图',
|
|
868
|
-
graphLayout: '布局',
|
|
869
|
-
graphEntityType: '实体类型',
|
|
870
|
-
graphDepth: '深度',
|
|
871
|
-
graphConnections: '连接数',
|
|
872
|
-
graphEvidence: '证据',
|
|
873
|
-
graphObservations: '观察',
|
|
874
|
-
graphRelations: '关系',
|
|
875
|
-
graphSparseWarning: '稀疏图谱:%isolated% / %total% 个实体无关系。孤立实体显示在下方清单中。',
|
|
876
|
-
graphNoRelations: '无关系 — 仅有孤立实体',
|
|
877
|
-
graphNoRelationsDesc: '此项目有实体但无关系。所有实体显示在下方清单面板中。',
|
|
878
|
-
graphEmptyState: '暂无图谱数据',
|
|
879
|
-
graphEmptyStateDesc: '创建实体和关系来查看知识图谱',
|
|
880
814
|
graphSelectNode: '选择节点以查看详情',
|
|
881
815
|
graphFindEntity: '查找实体...',
|
|
882
|
-
graphLeftToRight: '从左到右',
|
|
883
|
-
graphTopToBottom: '从上到下',
|
|
884
|
-
graphMore: '更多',
|
|
885
816
|
|
|
886
817
|
// Deterministic memory-map projection
|
|
887
818
|
kgTitle: '记忆关系图',
|
|
@@ -891,6 +822,8 @@ const i18n = {
|
|
|
891
822
|
kgClusters: '个聚类',
|
|
892
823
|
kgNoData: '暂无记忆关系图数据',
|
|
893
824
|
kgNoDataDesc: '存储持久观察或提升迷你技能以构建确定性记忆关系图。',
|
|
825
|
+
kgUnavailable: '记忆关系图暂时不可用',
|
|
826
|
+
kgUnavailableDesc: '记忆关系图服务没有返回有效数据,请检查 Dashboard 服务后重试。',
|
|
894
827
|
kgClusterFilter: '分区聚类',
|
|
895
828
|
kgEdgeTypeFilter: '边类型',
|
|
896
829
|
kgEdgeSupports: '支撑',
|
|
@@ -904,9 +837,6 @@ const i18n = {
|
|
|
904
837
|
kgInspectorEvidence: '证据',
|
|
905
838
|
kgInspectorRelatedEdges: '相关边',
|
|
906
839
|
kgInspectorNoEdges: '无边',
|
|
907
|
-
kgDataMode: '数据源',
|
|
908
|
-
kgModeSemantic: '推断记忆关系图',
|
|
909
|
-
kgModeEntity: '实体图谱',
|
|
910
840
|
kgViewMode: '视图模式',
|
|
911
841
|
kgFocused: '聚焦',
|
|
912
842
|
kgFullGraph: '全量',
|
|
@@ -2087,80 +2017,34 @@ function knowledgeAnchor(id) {
|
|
|
2087
2017
|
}
|
|
2088
2018
|
|
|
2089
2019
|
// ============================================================
|
|
2090
|
-
// Memory
|
|
2091
|
-
//
|
|
2020
|
+
// Canonical Memory Map
|
|
2021
|
+
// The Dashboard uses the evidence-backed projection only.
|
|
2092
2022
|
// ============================================================
|
|
2093
2023
|
|
|
2094
|
-
let _graphState = null;
|
|
2095
|
-
|
|
2096
2024
|
async function loadGraph() {
|
|
2097
2025
|
const container = document.getElementById('page-graph');
|
|
2098
2026
|
container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
|
|
2099
2027
|
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
// Fallback to entity graph
|
|
2028
|
+
const kg = await api('knowledge-graph');
|
|
2029
|
+
if (!kg || !Array.isArray(kg.nodes) || !Array.isArray(kg.edges)) {
|
|
2030
|
+
container.innerHTML = emptyState(
|
|
2031
|
+
'<span class="iconify" data-icon="lucide:network" style="font-size:36px;"></span>',
|
|
2032
|
+
t('kgUnavailable'),
|
|
2033
|
+
t('kgUnavailableDesc'),
|
|
2034
|
+
);
|
|
2035
|
+
return;
|
|
2109
2036
|
}
|
|
2110
2037
|
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2038
|
+
if (kg.nodes.length === 0) {
|
|
2039
|
+
container.innerHTML = emptyState(
|
|
2040
|
+
'<span class="iconify" data-icon="lucide:network" style="font-size:36px;"></span>',
|
|
2041
|
+
t('kgNoData'),
|
|
2042
|
+
t('kgNoDataDesc'),
|
|
2043
|
+
);
|
|
2115
2044
|
return;
|
|
2116
2045
|
}
|
|
2117
2046
|
|
|
2118
|
-
|
|
2119
|
-
const hasRelations = graph.relations.length > 0;
|
|
2120
|
-
|
|
2121
|
-
container.innerHTML = `
|
|
2122
|
-
<div class="page-header">
|
|
2123
|
-
<h1 class="page-title">${t('knowledgeGraph')}</h1>
|
|
2124
|
-
<p class="page-subtitle">${graph.entities.length} ${t('entities').toLowerCase()} · ${graph.relations.length} ${t('relations').toLowerCase()}</p>
|
|
2125
|
-
</div>
|
|
2126
|
-
${!hasRelations && graph.entities.length > 0 ? `
|
|
2127
|
-
<div class="panel" style="margin-bottom:16px;border-color:var(--accent-amber);">
|
|
2128
|
-
<div class="panel-body" style="display:flex;align-items:center;gap:12px;padding:12px 16px;">
|
|
2129
|
-
<span class="iconify" data-icon="lucide:network" style="font-size:20px;color:var(--accent-amber);"></span>
|
|
2130
|
-
<div>
|
|
2131
|
-
<div style="font-weight:600;color:var(--accent-amber);">${t('graphNoRelations')}</div>
|
|
2132
|
-
<div style="font-size:12px;color:var(--text-muted);margin-top:2px;">${t('graphNoRelationsDesc')}</div>
|
|
2133
|
-
</div>
|
|
2134
|
-
</div>
|
|
2135
|
-
</div>
|
|
2136
|
-
` : ''}
|
|
2137
|
-
${hasRelations ? `
|
|
2138
|
-
<div class="graph-layout">
|
|
2139
|
-
<div class="graph-filter-panel" id="graph-filter-panel"></div>
|
|
2140
|
-
<div id="graph-container">
|
|
2141
|
-
<div id="cytoscape-mount"></div>
|
|
2142
|
-
<div class="graph-status-bar">
|
|
2143
|
-
<span class="graph-status-item" id="gs-nodes"></span>
|
|
2144
|
-
<span class="graph-status-item" id="gs-edges"></span>
|
|
2145
|
-
<span class="graph-status-item" id="gs-layout"></span>
|
|
2146
|
-
<span class="graph-status-item" id="gs-scope"></span>
|
|
2147
|
-
<div class="graph-zoom-controls">
|
|
2148
|
-
<button class="graph-zoom-btn" id="gz-out">\u2212</button>
|
|
2149
|
-
<button class="graph-zoom-btn" id="gz-fit">\u2B21</button>
|
|
2150
|
-
<button class="graph-zoom-btn" id="gz-in">+</button>
|
|
2151
|
-
</div>
|
|
2152
|
-
</div>
|
|
2153
|
-
</div>
|
|
2154
|
-
<div class="graph-table-container" id="graph-table-container" style="display:none;"></div>
|
|
2155
|
-
<div class="graph-inspector" id="graph-inspector">
|
|
2156
|
-
<div class="gi-empty"><div class="gi-empty-icon">\u2B21</div>${t('graphSelectNode')}</div>
|
|
2157
|
-
</div>
|
|
2158
|
-
</div>
|
|
2159
|
-
` : ''}
|
|
2160
|
-
<div id="graph-isolated-panel" style="display:none;"></div>
|
|
2161
|
-
`;
|
|
2162
|
-
|
|
2163
|
-
renderGraph(graph);
|
|
2047
|
+
renderSemanticGraph(kg);
|
|
2164
2048
|
}
|
|
2165
2049
|
|
|
2166
2050
|
// ============================================================
|
|
@@ -2185,7 +2069,7 @@ function renderSemanticGraph(kg) {
|
|
|
2185
2069
|
return sectionPalette[sectionId] || defaultSectionColor;
|
|
2186
2070
|
}
|
|
2187
2071
|
|
|
2188
|
-
// Edge type styles
|
|
2072
|
+
// Edge type styles are rendered by ECharts.
|
|
2189
2073
|
const edgeStyleMap = {
|
|
2190
2074
|
'supports': { color: '#69F0AE', arrow: true, dash: false, label: t('kgEdgeSupports') },
|
|
2191
2075
|
'relates_to': { color: '#80D8FF', arrow: false, dash: [4, 4], label: t('kgEdgeRelatesTo') },
|
|
@@ -2231,6 +2115,9 @@ function renderSemanticGraph(kg) {
|
|
|
2231
2115
|
|
|
2232
2116
|
// Build ECharts graph data: { categories, nodes, links }
|
|
2233
2117
|
function buildEChartsData() {
|
|
2118
|
+
const chartWidth = document.getElementById('echarts-mount')?.clientWidth || 600;
|
|
2119
|
+
const labelLimit = chartWidth < 360 ? 16 : chartWidth < 520 ? 22 : 28;
|
|
2120
|
+
|
|
2234
2121
|
// Determine which nodes to show
|
|
2235
2122
|
let visibleNodes = kg.nodes.filter(n => activeSections.has(n.sectionId));
|
|
2236
2123
|
if (focusedMode && visibleNodes.length > FOCUSED_TOP_N) {
|
|
@@ -2288,7 +2175,9 @@ function renderSemanticGraph(kg) {
|
|
|
2288
2175
|
const pos = stableNodePosition(node, Math.max(0, sectionIndex));
|
|
2289
2176
|
return {
|
|
2290
2177
|
id: node.id,
|
|
2291
|
-
name: node.label.length >
|
|
2178
|
+
name: node.label.length > labelLimit
|
|
2179
|
+
? node.label.slice(0, Math.max(1, labelLimit - 1)) + '\u2026'
|
|
2180
|
+
: node.label,
|
|
2292
2181
|
x: pos.x,
|
|
2293
2182
|
y: pos.y,
|
|
2294
2183
|
category: categoryIndexBySection[node.sectionId] ?? 0,
|
|
@@ -2301,6 +2190,7 @@ function renderSemanticGraph(kg) {
|
|
|
2301
2190
|
summary: node.summary || '',
|
|
2302
2191
|
refs: node.refs || [],
|
|
2303
2192
|
fullLabel: node.label,
|
|
2193
|
+
label: { position: pos.x > 180 ? 'left' : 'right' },
|
|
2304
2194
|
};
|
|
2305
2195
|
});
|
|
2306
2196
|
|
|
@@ -2768,752 +2658,6 @@ function renderSemanticGraph(kg) {
|
|
|
2768
2658
|
renderKGFilterPanel();
|
|
2769
2659
|
}
|
|
2770
2660
|
|
|
2771
|
-
// ============================================================
|
|
2772
|
-
// Cytoscape.js + Dagre — Focused Topology Renderer
|
|
2773
|
-
// Default: 1-hop neighborhood of top entity, dagre LR layout
|
|
2774
|
-
// ============================================================
|
|
2775
|
-
|
|
2776
|
-
function renderGraph(graph) {
|
|
2777
|
-
// Register dagre layout if not already registered
|
|
2778
|
-
if (typeof cytoscape !== 'undefined' && typeof cytoscapeDagre !== 'undefined' && !cytoscape._dagreRegistered) {
|
|
2779
|
-
cytoscapeDagre(cytoscape);
|
|
2780
|
-
cytoscape._dagreRegistered = true;
|
|
2781
|
-
}
|
|
2782
|
-
|
|
2783
|
-
// --- Muted enterprise palette ---
|
|
2784
|
-
const palette = [
|
|
2785
|
-
'#7C9CBF', '#8FB996', '#C4956A', '#A893C2',
|
|
2786
|
-
'#6BA3A0', '#B8A44C', '#C27878', '#7B8EB8',
|
|
2787
|
-
];
|
|
2788
|
-
const typeColors = {};
|
|
2789
|
-
let colorIdx = 0;
|
|
2790
|
-
function getTypeColor(type) {
|
|
2791
|
-
if (!typeColors[type]) { typeColors[type] = palette[colorIdx % palette.length]; colorIdx++; }
|
|
2792
|
-
return typeColors[type];
|
|
2793
|
-
}
|
|
2794
|
-
|
|
2795
|
-
const typeCounts = {};
|
|
2796
|
-
graph.entities.forEach(e => { typeCounts[e.entityType] = (typeCounts[e.entityType] || 0) + 1; });
|
|
2797
|
-
Object.keys(typeCounts).forEach(t2 => getTypeColor(t2));
|
|
2798
|
-
|
|
2799
|
-
function isLight() { return document.documentElement.getAttribute('data-theme') === 'light'; }
|
|
2800
|
-
|
|
2801
|
-
// --- Build data structures ---
|
|
2802
|
-
const entityMap = {};
|
|
2803
|
-
graph.entities.forEach(e => {
|
|
2804
|
-
entityMap[e.name] = e;
|
|
2805
|
-
});
|
|
2806
|
-
|
|
2807
|
-
// Compute degree for each entity
|
|
2808
|
-
const degreeMap = {};
|
|
2809
|
-
graph.entities.forEach(e => { degreeMap[e.name] = 0; });
|
|
2810
|
-
graph.relations.forEach(r => {
|
|
2811
|
-
if (degreeMap[r.from] !== undefined) degreeMap[r.from]++;
|
|
2812
|
-
if (degreeMap[r.to] !== undefined) degreeMap[r.to]++;
|
|
2813
|
-
});
|
|
2814
|
-
|
|
2815
|
-
// Find top entity by degree (for default focus)
|
|
2816
|
-
const topEntity = graph.entities.reduce((best, e) =>
|
|
2817
|
-
(degreeMap[e.name] || 0) > (degreeMap[best.name] || 0) ? e : best,
|
|
2818
|
-
graph.entities[0]
|
|
2819
|
-
);
|
|
2820
|
-
|
|
2821
|
-
// --- Computed stats ---
|
|
2822
|
-
const isolatedCount = graph.entities.filter(e => (degreeMap[e.name] || 0) === 0).length;
|
|
2823
|
-
const connectedCount = graph.entities.length - isolatedCount;
|
|
2824
|
-
const isSparse = isolatedCount > connectedCount;
|
|
2825
|
-
|
|
2826
|
-
// --- State ---
|
|
2827
|
-
let activeTypes = new Set(Object.keys(typeCounts));
|
|
2828
|
-
let currentView = 'topology'; // 'topology' | 'table'
|
|
2829
|
-
let currentLayout = 'dagre-lr'; // 'dagre-lr' | 'dagre-tb'
|
|
2830
|
-
let focusEntity = topEntity.name;
|
|
2831
|
-
let depth = 1;
|
|
2832
|
-
let scope = 'connected'; // 'connected' | 'neighborhood' | 'full'
|
|
2833
|
-
let selectedNodeId = null;
|
|
2834
|
-
let cy = null; // Cytoscape instance
|
|
2835
|
-
|
|
2836
|
-
// --- Subgraph extraction (BFS n-hop neighborhood) ---
|
|
2837
|
-
function getNeighborhood(centerName, maxDepth) {
|
|
2838
|
-
const visited = new Set();
|
|
2839
|
-
const edgeSet = new Set();
|
|
2840
|
-
const queue = [{ name: centerName, d: 0 }];
|
|
2841
|
-
visited.add(centerName);
|
|
2842
|
-
|
|
2843
|
-
while (queue.length > 0) {
|
|
2844
|
-
const { name, d } = queue.shift();
|
|
2845
|
-
if (d >= maxDepth) continue;
|
|
2846
|
-
for (const r of graph.relations) {
|
|
2847
|
-
if (r.from === name && entityMap[r.to] && !visited.has(r.to)) {
|
|
2848
|
-
visited.add(r.to);
|
|
2849
|
-
edgeSet.add(r);
|
|
2850
|
-
queue.push({ name: r.to, d: d + 1 });
|
|
2851
|
-
} else if (r.from === name && entityMap[r.to]) {
|
|
2852
|
-
edgeSet.add(r);
|
|
2853
|
-
}
|
|
2854
|
-
if (r.to === name && entityMap[r.from] && !visited.has(r.from)) {
|
|
2855
|
-
visited.add(r.from);
|
|
2856
|
-
edgeSet.add(r);
|
|
2857
|
-
queue.push({ name: r.from, d: d + 1 });
|
|
2858
|
-
} else if (r.to === name && entityMap[r.from]) {
|
|
2859
|
-
edgeSet.add(r);
|
|
2860
|
-
}
|
|
2861
|
-
}
|
|
2862
|
-
}
|
|
2863
|
-
return {
|
|
2864
|
-
nodeNames: visited,
|
|
2865
|
-
edges: [...edgeSet].filter(r => visited.has(r.from) && visited.has(r.to)),
|
|
2866
|
-
};
|
|
2867
|
-
}
|
|
2868
|
-
|
|
2869
|
-
// --- Build Cytoscape elements from current state ---
|
|
2870
|
-
function buildElements() {
|
|
2871
|
-
let nodeNames, visibleEdges;
|
|
2872
|
-
|
|
2873
|
-
if (scope === 'full') {
|
|
2874
|
-
// Full graph canvas: only connected nodes (isolated go to inventory panel below)
|
|
2875
|
-
nodeNames = new Set(
|
|
2876
|
-
graph.entities.filter(e => activeTypes.has(e.entityType) && (degreeMap[e.name] || 0) > 0).map(e => e.name)
|
|
2877
|
-
);
|
|
2878
|
-
visibleEdges = graph.relations.filter(r => nodeNames.has(r.from) && nodeNames.has(r.to));
|
|
2879
|
-
} else if (scope === 'neighborhood') {
|
|
2880
|
-
// Focused neighborhood: BFS from focusEntity
|
|
2881
|
-
const sub = getNeighborhood(focusEntity, depth);
|
|
2882
|
-
nodeNames = new Set([...sub.nodeNames].filter(n => activeTypes.has(entityMap[n]?.entityType)));
|
|
2883
|
-
if (entityMap[focusEntity]) nodeNames.add(focusEntity);
|
|
2884
|
-
visibleEdges = sub.edges.filter(r => nodeNames.has(r.from) && nodeNames.has(r.to));
|
|
2885
|
-
} else {
|
|
2886
|
-
// DEFAULT: 'connected' — only nodes with degree > 0 (no isolated nodes)
|
|
2887
|
-
nodeNames = new Set(
|
|
2888
|
-
graph.entities
|
|
2889
|
-
.filter(e => activeTypes.has(e.entityType) && (degreeMap[e.name] || 0) > 0)
|
|
2890
|
-
.map(e => e.name)
|
|
2891
|
-
);
|
|
2892
|
-
visibleEdges = graph.relations.filter(r => nodeNames.has(r.from) && nodeNames.has(r.to));
|
|
2893
|
-
}
|
|
2894
|
-
|
|
2895
|
-
// Top centrality: only top 3 show labels by default (not 10)
|
|
2896
|
-
const visibleDegrees = {};
|
|
2897
|
-
nodeNames.forEach(n => { visibleDegrees[n] = 0; });
|
|
2898
|
-
visibleEdges.forEach(r => {
|
|
2899
|
-
if (visibleDegrees[r.from] !== undefined) visibleDegrees[r.from]++;
|
|
2900
|
-
if (visibleDegrees[r.to] !== undefined) visibleDegrees[r.to]++;
|
|
2901
|
-
});
|
|
2902
|
-
const topCentrality = new Set(
|
|
2903
|
-
[...nodeNames].sort((a, b) => (visibleDegrees[b] || 0) - (visibleDegrees[a] || 0)).slice(0, 3)
|
|
2904
|
-
);
|
|
2905
|
-
|
|
2906
|
-
const nodes = [...nodeNames].map(name => {
|
|
2907
|
-
const e = entityMap[name];
|
|
2908
|
-
const deg = visibleDegrees[name] || 0;
|
|
2909
|
-
const isFocus = scope === 'neighborhood' && name === focusEntity;
|
|
2910
|
-
const isTop = topCentrality.has(name);
|
|
2911
|
-
// Labels: only top 3 centrality nodes show labels by default
|
|
2912
|
-
const showLabel = isFocus || isTop;
|
|
2913
|
-
return {
|
|
2914
|
-
data: {
|
|
2915
|
-
id: name,
|
|
2916
|
-
label: showLabel ? (name.length > 24 ? name.slice(0, 22) + '\u2026' : name) : '',
|
|
2917
|
-
fullLabel: name,
|
|
2918
|
-
type: e.entityType,
|
|
2919
|
-
obsCount: e.observations.length,
|
|
2920
|
-
degree: deg,
|
|
2921
|
-
color: getTypeColor(e.entityType),
|
|
2922
|
-
isFocus: isFocus,
|
|
2923
|
-
nodeSize: Math.max(16, Math.min(12 + Math.sqrt(deg) * 6, 40)),
|
|
2924
|
-
},
|
|
2925
|
-
};
|
|
2926
|
-
});
|
|
2927
|
-
|
|
2928
|
-
const edges = visibleEdges.map((r, i) => ({
|
|
2929
|
-
data: {
|
|
2930
|
-
id: 'e' + i + '_' + r.from + '_' + r.to,
|
|
2931
|
-
source: r.from,
|
|
2932
|
-
target: r.to,
|
|
2933
|
-
relationType: r.relationType,
|
|
2934
|
-
},
|
|
2935
|
-
}));
|
|
2936
|
-
|
|
2937
|
-
return { nodes, edges, visibleCount: nodeNames.size, edgeCount: visibleEdges.length };
|
|
2938
|
-
}
|
|
2939
|
-
|
|
2940
|
-
// --- Cytoscape style ---
|
|
2941
|
-
function getCyStyle() {
|
|
2942
|
-
const light = isLight();
|
|
2943
|
-
return [
|
|
2944
|
-
{
|
|
2945
|
-
selector: 'node',
|
|
2946
|
-
style: {
|
|
2947
|
-
'width': 'data(nodeSize)',
|
|
2948
|
-
'height': 'data(nodeSize)',
|
|
2949
|
-
'background-color': 'data(color)',
|
|
2950
|
-
'background-opacity': 0.85,
|
|
2951
|
-
'border-width': 1,
|
|
2952
|
-
'border-color': light ? 'rgba(0,0,0,0.12)' : 'rgba(255,255,255,0.12)',
|
|
2953
|
-
'label': 'data(label)',
|
|
2954
|
-
'font-size': 10,
|
|
2955
|
-
'font-family': 'Inter, system-ui, sans-serif',
|
|
2956
|
-
'font-weight': 400,
|
|
2957
|
-
'color': light ? '#1C1B1F' : '#E6E1E5',
|
|
2958
|
-
'text-valign': 'bottom',
|
|
2959
|
-
'text-halign': 'center',
|
|
2960
|
-
'text-margin-y': 4,
|
|
2961
|
-
'text-max-width': 120,
|
|
2962
|
-
'text-wrap': 'ellipsis',
|
|
2963
|
-
'text-background-color': light ? '#F7F2FA' : '#0F0F17',
|
|
2964
|
-
'text-background-opacity': 0.7,
|
|
2965
|
-
'text-background-padding': '2px',
|
|
2966
|
-
'text-background-shape': 'roundrectangle',
|
|
2967
|
-
'min-zoomed-font-size': 8,
|
|
2968
|
-
},
|
|
2969
|
-
},
|
|
2970
|
-
{
|
|
2971
|
-
selector: 'node[?isFocus]',
|
|
2972
|
-
style: {
|
|
2973
|
-
'border-width': 3,
|
|
2974
|
-
'border-color': light ? '#6750A4' : '#D0BCFF',
|
|
2975
|
-
'font-weight': 600,
|
|
2976
|
-
'font-size': 12,
|
|
2977
|
-
},
|
|
2978
|
-
},
|
|
2979
|
-
{
|
|
2980
|
-
selector: 'node:selected',
|
|
2981
|
-
style: {
|
|
2982
|
-
'border-width': 3,
|
|
2983
|
-
'border-color': light ? '#6750A4' : '#D0BCFF',
|
|
2984
|
-
'border-style': 'dashed',
|
|
2985
|
-
'font-weight': 600,
|
|
2986
|
-
'label': 'data(fullLabel)',
|
|
2987
|
-
},
|
|
2988
|
-
},
|
|
2989
|
-
{
|
|
2990
|
-
selector: 'node.hover',
|
|
2991
|
-
style: {
|
|
2992
|
-
'border-width': 2,
|
|
2993
|
-
'border-color': light ? '#6750A4' : '#D0BCFF',
|
|
2994
|
-
'label': 'data(fullLabel)',
|
|
2995
|
-
'font-weight': 500,
|
|
2996
|
-
'z-index': 999,
|
|
2997
|
-
},
|
|
2998
|
-
},
|
|
2999
|
-
{
|
|
3000
|
-
selector: 'edge',
|
|
3001
|
-
style: {
|
|
3002
|
-
'width': 1,
|
|
3003
|
-
'line-color': light ? 'rgba(0,0,0,0.1)' : 'rgba(255,255,255,0.1)',
|
|
3004
|
-
'target-arrow-color': light ? 'rgba(0,0,0,0.15)' : 'rgba(255,255,255,0.15)',
|
|
3005
|
-
'target-arrow-shape': 'triangle',
|
|
3006
|
-
'arrow-scale': 0.7,
|
|
3007
|
-
'curve-style': 'bezier',
|
|
3008
|
-
'label': '',
|
|
3009
|
-
},
|
|
3010
|
-
},
|
|
3011
|
-
{
|
|
3012
|
-
selector: 'edge:selected, edge.hover',
|
|
3013
|
-
style: {
|
|
3014
|
-
'width': 2,
|
|
3015
|
-
'line-color': light ? 'rgba(103,80,164,0.5)' : 'rgba(208,188,255,0.4)',
|
|
3016
|
-
'target-arrow-color': light ? 'rgba(103,80,164,0.6)' : 'rgba(208,188,255,0.5)',
|
|
3017
|
-
'label': 'data(relationType)',
|
|
3018
|
-
'font-size': 9,
|
|
3019
|
-
'font-family': 'JetBrains Mono, monospace',
|
|
3020
|
-
'color': light ? '#6750A4' : '#D0BCFF',
|
|
3021
|
-
'text-background-color': light ? '#F7F2FA' : '#0F0F17',
|
|
3022
|
-
'text-background-opacity': 0.8,
|
|
3023
|
-
'text-background-padding': '2px',
|
|
3024
|
-
'text-background-shape': 'roundrectangle',
|
|
3025
|
-
'text-rotation': 'autorotate',
|
|
3026
|
-
},
|
|
3027
|
-
},
|
|
3028
|
-
{
|
|
3029
|
-
selector: '.dimmed',
|
|
3030
|
-
style: {
|
|
3031
|
-
'opacity': 0.15,
|
|
3032
|
-
},
|
|
3033
|
-
},
|
|
3034
|
-
];
|
|
3035
|
-
}
|
|
3036
|
-
|
|
3037
|
-
// --- Layout config ---
|
|
3038
|
-
function getLayoutConfig() {
|
|
3039
|
-
if (currentLayout === 'dagre-tb') {
|
|
3040
|
-
return { name: 'dagre', rankDir: 'TB', nodeSep: 40, rankSep: 60, edgeSep: 20, padding: 30 };
|
|
3041
|
-
}
|
|
3042
|
-
// Default: dagre LR
|
|
3043
|
-
return { name: 'dagre', rankDir: 'LR', nodeSep: 40, rankSep: 80, edgeSep: 20, padding: 30 };
|
|
3044
|
-
}
|
|
3045
|
-
|
|
3046
|
-
// --- Initialize / rebuild Cytoscape ---
|
|
3047
|
-
function initCytoscape() {
|
|
3048
|
-
const { nodes, edges, visibleCount, edgeCount } = buildElements();
|
|
3049
|
-
|
|
3050
|
-
if (cy) cy.destroy();
|
|
3051
|
-
|
|
3052
|
-
const light = isLight();
|
|
3053
|
-
const mountEl = document.getElementById('cytoscape-mount');
|
|
3054
|
-
if (!mountEl) return;
|
|
3055
|
-
|
|
3056
|
-
cy = cytoscape({
|
|
3057
|
-
container: mountEl,
|
|
3058
|
-
elements: [...nodes, ...edges],
|
|
3059
|
-
style: getCyStyle(),
|
|
3060
|
-
layout: getLayoutConfig(),
|
|
3061
|
-
wheelSensitivity: 0.3,
|
|
3062
|
-
minZoom: 0.1,
|
|
3063
|
-
maxZoom: 4,
|
|
3064
|
-
boxSelectionEnabled: false,
|
|
3065
|
-
});
|
|
3066
|
-
|
|
3067
|
-
// --- Event handlers ---
|
|
3068
|
-
cy.on('tap', 'node', function (evt) {
|
|
3069
|
-
const node = evt.target;
|
|
3070
|
-
selectedNodeId = node.id();
|
|
3071
|
-
showInspector(node.id());
|
|
3072
|
-
});
|
|
3073
|
-
|
|
3074
|
-
cy.on('tap', function (evt) {
|
|
3075
|
-
if (evt.target === cy) {
|
|
3076
|
-
selectedNodeId = null;
|
|
3077
|
-
showInspector(null);
|
|
3078
|
-
}
|
|
3079
|
-
});
|
|
3080
|
-
|
|
3081
|
-
let hoverNode = null;
|
|
3082
|
-
cy.on('mouseover', 'node', function (evt) {
|
|
3083
|
-
const node = evt.target;
|
|
3084
|
-
hoverNode = node;
|
|
3085
|
-
node.addClass('hover');
|
|
3086
|
-
// Show label on hover for all connected edges
|
|
3087
|
-
node.connectedEdges().addClass('hover');
|
|
3088
|
-
});
|
|
3089
|
-
cy.on('mouseout', 'node', function (evt) {
|
|
3090
|
-
const node = evt.target;
|
|
3091
|
-
if (hoverNode === node) hoverNode = null;
|
|
3092
|
-
node.removeClass('hover');
|
|
3093
|
-
node.connectedEdges().removeClass('hover');
|
|
3094
|
-
});
|
|
3095
|
-
|
|
3096
|
-
// Double-click to refocus
|
|
3097
|
-
cy.on('dbltap', 'node', function (evt) {
|
|
3098
|
-
focusEntity = evt.target.id();
|
|
3099
|
-
scope = 'neighborhood';
|
|
3100
|
-
rebuildGraph();
|
|
3101
|
-
});
|
|
3102
|
-
|
|
3103
|
-
updateStatusBar(visibleCount, edgeCount);
|
|
3104
|
-
}
|
|
3105
|
-
|
|
3106
|
-
function rebuildGraph() {
|
|
3107
|
-
initCytoscape();
|
|
3108
|
-
renderFilterPanel();
|
|
3109
|
-
renderIsolatedPanel();
|
|
3110
|
-
}
|
|
3111
|
-
|
|
3112
|
-
// --- Isolated Entities Inventory (not in graph canvas) ---
|
|
3113
|
-
function renderIsolatedPanel() {
|
|
3114
|
-
const panel = document.getElementById('graph-isolated-panel');
|
|
3115
|
-
if (!panel) return;
|
|
3116
|
-
|
|
3117
|
-
// Only show when scope is connected or full (not neighborhood)
|
|
3118
|
-
if (scope === 'neighborhood') {
|
|
3119
|
-
panel.style.display = 'none';
|
|
3120
|
-
return;
|
|
3121
|
-
}
|
|
3122
|
-
|
|
3123
|
-
const isolated = graph.entities.filter(e =>
|
|
3124
|
-
activeTypes.has(e.entityType) && (degreeMap[e.name] || 0) === 0
|
|
3125
|
-
);
|
|
3126
|
-
|
|
3127
|
-
if (isolated.length === 0) {
|
|
3128
|
-
panel.style.display = 'none';
|
|
3129
|
-
return;
|
|
3130
|
-
}
|
|
3131
|
-
|
|
3132
|
-
// Group by entityType
|
|
3133
|
-
const groups = {};
|
|
3134
|
-
isolated.forEach(e => {
|
|
3135
|
-
(groups[e.entityType] = groups[e.entityType] || []).push(e);
|
|
3136
|
-
});
|
|
3137
|
-
|
|
3138
|
-
const groupEntries = Object.entries(groups).sort((a, b) => b[1].length - a[1].length);
|
|
3139
|
-
|
|
3140
|
-
panel.style.display = 'block';
|
|
3141
|
-
panel.innerHTML = `
|
|
3142
|
-
<div class="panel" style="margin-top:16px;">
|
|
3143
|
-
<div class="panel-header">
|
|
3144
|
-
<span class="panel-title">${t('graphIsolatedEntities')}</span>
|
|
3145
|
-
<span style="font-size:11px;color:var(--text-muted);">${isolated.length} ${t('graphIsolatedDesc')}</span>
|
|
3146
|
-
</div>
|
|
3147
|
-
<div class="panel-body" style="padding:12px 16px;">
|
|
3148
|
-
${groupEntries.map(([type, entities]) => {
|
|
3149
|
-
const color = getTypeColor(type);
|
|
3150
|
-
const collapsed = entities.length > 8;
|
|
3151
|
-
const shown = collapsed ? entities.slice(0, 8) : entities;
|
|
3152
|
-
return `
|
|
3153
|
-
<div style="margin-bottom:12px;">
|
|
3154
|
-
<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;">
|
|
3155
|
-
<span style="width:8px;height:8px;border-radius:50%;background:${color};flex-shrink:0;"></span>
|
|
3156
|
-
<span style="font-size:11px;font-weight:600;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.5px;">${escapeHtml(type)}</span>
|
|
3157
|
-
<span style="font-size:10px;color:var(--text-muted);font-family:var(--font-mono);">${entities.length}</span>
|
|
3158
|
-
</div>
|
|
3159
|
-
<div style="display:flex;flex-wrap:wrap;gap:4px;">
|
|
3160
|
-
${shown.map(e => `
|
|
3161
|
-
<span class="iso-entity-tag" data-iso-entity="${escapeHtml(e.name)}" style="display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:4px;font-size:11px;color:var(--text-secondary);background:var(--bg-surface);border:1px solid var(--border-subtle);cursor:pointer;transition:all 150ms;">
|
|
3162
|
-
${escapeHtml(e.name.length > 28 ? e.name.slice(0, 26) + '\u2026' : e.name)}
|
|
3163
|
-
${e.observations.length > 0 ? '<span style="font-size:9px;color:var(--text-muted);">' + e.observations.length + '</span>' : ''}
|
|
3164
|
-
</span>
|
|
3165
|
-
`).join('')}
|
|
3166
|
-
${collapsed ? '<span style="font-size:11px;color:var(--text-muted);padding:3px 8px;">+' + (entities.length - 8) + ' ' + t('graphMore') + '</span>' : ''}
|
|
3167
|
-
</div>
|
|
3168
|
-
</div>
|
|
3169
|
-
`;
|
|
3170
|
-
}).join('')}
|
|
3171
|
-
</div>
|
|
3172
|
-
</div>
|
|
3173
|
-
`;
|
|
3174
|
-
|
|
3175
|
-
// Bind clicks: inspect isolated entity
|
|
3176
|
-
panel.querySelectorAll('[data-iso-entity]').forEach(el => {
|
|
3177
|
-
el.addEventListener('click', () => {
|
|
3178
|
-
const name = el.dataset.isoEntity;
|
|
3179
|
-
if (entityMap[name]) {
|
|
3180
|
-
selectedNodeId = name;
|
|
3181
|
-
showInspector(name);
|
|
3182
|
-
}
|
|
3183
|
-
});
|
|
3184
|
-
});
|
|
3185
|
-
}
|
|
3186
|
-
|
|
3187
|
-
// --- Inspector ---
|
|
3188
|
-
function showInspector(nodeId) {
|
|
3189
|
-
const inspector = document.getElementById('graph-inspector');
|
|
3190
|
-
if (!inspector) return;
|
|
3191
|
-
if (!nodeId || !entityMap[nodeId]) {
|
|
3192
|
-
inspector.innerHTML = '<div class="gi-empty"><div class="gi-empty-icon">\u2B21</div>' + t('graphSelectNode') + '</div>';
|
|
3193
|
-
return;
|
|
3194
|
-
}
|
|
3195
|
-
const entity = entityMap[nodeId];
|
|
3196
|
-
const related = graph.relations.filter(r => r.from === nodeId || r.to === nodeId);
|
|
3197
|
-
const deg = degreeMap[nodeId] || 0;
|
|
3198
|
-
const color = getTypeColor(entity.entityType);
|
|
3199
|
-
|
|
3200
|
-
const obsHtml = entity.observations.length > 0
|
|
3201
|
-
? entity.observations.map(o => `<div class="gi-obs-item">${escapeHtml(o)}</div>`).join('')
|
|
3202
|
-
: '<div style="font-size:12px;color:var(--text-muted);font-style:italic;">' + t('noObservations') + '</div>';
|
|
3203
|
-
const relHtml = related.length > 0
|
|
3204
|
-
? related.map(r => {
|
|
3205
|
-
const dir = r.from === nodeId;
|
|
3206
|
-
const other = dir ? r.to : r.from;
|
|
3207
|
-
return `<div class="gi-rel-item">
|
|
3208
|
-
<span class="gi-rel-arrow">${dir ? '\u2192' : '\u2190'}</span>
|
|
3209
|
-
<span class="gi-rel-type">${escapeHtml(r.relationType)}</span>
|
|
3210
|
-
<span class="gi-rel-target" data-inspector-nav="${escapeHtml(other)}">${escapeHtml(other)}</span>
|
|
3211
|
-
</div>`;
|
|
3212
|
-
}).join('')
|
|
3213
|
-
: '<div style="font-size:12px;color:var(--text-muted);font-style:italic;">' + t('noRelations') + '</div>';
|
|
3214
|
-
|
|
3215
|
-
inspector.innerHTML = `
|
|
3216
|
-
<div class="gi-header">
|
|
3217
|
-
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
|
|
3218
|
-
<span style="width:10px;height:10px;border-radius:50%;background:${color};flex-shrink:0;"></span>
|
|
3219
|
-
<div class="gi-name">${escapeHtml(nodeId)}</div>
|
|
3220
|
-
</div>
|
|
3221
|
-
<div class="gi-type">${escapeHtml(entity.entityType)}</div>
|
|
3222
|
-
</div>
|
|
3223
|
-
<div class="gi-stats">
|
|
3224
|
-
<div class="gi-stat"><div class="gi-stat-value">${deg}</div><div class="gi-stat-label">${t('graphConnections')}</div></div>
|
|
3225
|
-
<div class="gi-stat"><div class="gi-stat-value">${entity.observations.length}</div><div class="gi-stat-label">${t('graphEvidence')}</div></div>
|
|
3226
|
-
</div>
|
|
3227
|
-
<div class="gi-section">
|
|
3228
|
-
<div class="gi-section-title">${t('graphObservations')} <span class="gi-section-count">${entity.observations.length}</span></div>
|
|
3229
|
-
${obsHtml}
|
|
3230
|
-
</div>
|
|
3231
|
-
<div class="gi-section">
|
|
3232
|
-
<div class="gi-section-title">${t('graphRelations')} <span class="gi-section-count">${related.length}</span></div>
|
|
3233
|
-
${relHtml}
|
|
3234
|
-
</div>
|
|
3235
|
-
`;
|
|
3236
|
-
|
|
3237
|
-
// Navigation: click relation target to focus
|
|
3238
|
-
inspector.querySelectorAll('[data-inspector-nav]').forEach(el => {
|
|
3239
|
-
el.addEventListener('click', () => {
|
|
3240
|
-
const targetId = el.dataset.inspectorNav;
|
|
3241
|
-
if (entityMap[targetId]) {
|
|
3242
|
-
selectedNodeId = targetId;
|
|
3243
|
-
// If target is visible in current graph, select it
|
|
3244
|
-
if (cy && cy.$id(targetId).length > 0) {
|
|
3245
|
-
cy.$(':selected').unselect();
|
|
3246
|
-
cy.$id(targetId).select();
|
|
3247
|
-
cy.animate({ center: { eles: cy.$id(targetId) }, duration: 300 });
|
|
3248
|
-
} else {
|
|
3249
|
-
// Switch focus to target
|
|
3250
|
-
focusEntity = targetId;
|
|
3251
|
-
scope = 'neighborhood';
|
|
3252
|
-
rebuildGraph();
|
|
3253
|
-
}
|
|
3254
|
-
showInspector(targetId);
|
|
3255
|
-
}
|
|
3256
|
-
});
|
|
3257
|
-
});
|
|
3258
|
-
}
|
|
3259
|
-
|
|
3260
|
-
// --- Filter panel ---
|
|
3261
|
-
function renderFilterPanel() {
|
|
3262
|
-
const panel = document.getElementById('graph-filter-panel');
|
|
3263
|
-
if (!panel) return;
|
|
3264
|
-
|
|
3265
|
-
const searchHtml = `
|
|
3266
|
-
<div class="gfp-section">
|
|
3267
|
-
<div class="gfp-label">${t('graphSearch')}</div>
|
|
3268
|
-
<input type="text" class="gfp-search" id="gfp-search" placeholder="${t('graphFindEntity')}" autocomplete="off" />
|
|
3269
|
-
</div>
|
|
3270
|
-
`;
|
|
3271
|
-
|
|
3272
|
-
const scopeHtml = `
|
|
3273
|
-
<div class="gfp-section">
|
|
3274
|
-
<div class="gfp-label">${t('graphScope')}</div>
|
|
3275
|
-
<div class="gfp-radio-group">
|
|
3276
|
-
<button class="gfp-radio${scope === 'connected' ? ' active' : ''}" data-scope="connected">
|
|
3277
|
-
<span class="gfp-radio-dot"></span> ${t('graphConnected')}
|
|
3278
|
-
</button>
|
|
3279
|
-
<button class="gfp-radio${scope === 'neighborhood' ? ' active' : ''}" data-scope="neighborhood">
|
|
3280
|
-
<span class="gfp-radio-dot"></span> ${t('graphNeighborhood')}
|
|
3281
|
-
</button>
|
|
3282
|
-
<button class="gfp-radio${scope === 'full' ? ' active' : ''}" data-scope="full">
|
|
3283
|
-
<span class="gfp-radio-dot"></span> ${t('graphFullGraph')}
|
|
3284
|
-
</button>
|
|
3285
|
-
</div>
|
|
3286
|
-
${isSparse ? `<div style="font-size:10px;color:var(--accent-amber);margin-top:6px;line-height:1.4;">\u26A0 ${t('graphSparseWarning').replace('%isolated%', isolatedCount).replace('%total%', graph.entities.length)}</div>` : ''}
|
|
3287
|
-
</div>
|
|
3288
|
-
`;
|
|
3289
|
-
|
|
3290
|
-
const depthHtml = `
|
|
3291
|
-
<div class="gfp-section" id="gfp-depth-section"${scope !== 'neighborhood' ? ' style="display:none"' : ''}>
|
|
3292
|
-
<div class="gfp-label">${t('graphDepth')}</div>
|
|
3293
|
-
<div class="gfp-depth-row">
|
|
3294
|
-
<button class="gfp-depth-btn${depth === 1 ? ' active' : ''}" data-depth="1">1</button>
|
|
3295
|
-
<button class="gfp-depth-btn${depth === 2 ? ' active' : ''}" data-depth="2">2</button>
|
|
3296
|
-
<button class="gfp-depth-btn${depth === 3 ? ' active' : ''}" data-depth="3">3</button>
|
|
3297
|
-
</div>
|
|
3298
|
-
</div>
|
|
3299
|
-
`;
|
|
3300
|
-
|
|
3301
|
-
const viewHtml = `
|
|
3302
|
-
<div class="gfp-section">
|
|
3303
|
-
<div class="gfp-label">${t('graphView')}</div>
|
|
3304
|
-
<div class="gfp-radio-group">
|
|
3305
|
-
<button class="gfp-radio${currentView === 'topology' ? ' active' : ''}" data-view="topology">
|
|
3306
|
-
<span class="gfp-radio-dot"></span> ${t('graphTopology')}
|
|
3307
|
-
</button>
|
|
3308
|
-
<button class="gfp-radio${currentView === 'table' ? ' active' : ''}" data-view="table">
|
|
3309
|
-
<span class="gfp-radio-dot"></span> ${t('graphTable')}
|
|
3310
|
-
</button>
|
|
3311
|
-
</div>
|
|
3312
|
-
</div>
|
|
3313
|
-
`;
|
|
3314
|
-
|
|
3315
|
-
const layoutHtml = `
|
|
3316
|
-
<div class="gfp-section" id="gfp-layout-section"${currentView === 'table' ? ' style="display:none"' : ''}>
|
|
3317
|
-
<div class="gfp-label">${t('graphLayout')}</div>
|
|
3318
|
-
<div class="gfp-radio-group">
|
|
3319
|
-
<button class="gfp-radio${currentLayout === 'dagre-lr' ? ' active' : ''}" data-layout="dagre-lr">
|
|
3320
|
-
<span class="gfp-radio-dot"></span> ${t('graphLeftToRight')}
|
|
3321
|
-
</button>
|
|
3322
|
-
<button class="gfp-radio${currentLayout === 'dagre-tb' ? ' active' : ''}" data-layout="dagre-tb">
|
|
3323
|
-
<span class="gfp-radio-dot"></span> ${t('graphTopToBottom')}
|
|
3324
|
-
</button>
|
|
3325
|
-
</div>
|
|
3326
|
-
</div>
|
|
3327
|
-
`;
|
|
3328
|
-
|
|
3329
|
-
const typeEntries = Object.entries(typeCounts).sort((a, b) => b[1] - a[1]);
|
|
3330
|
-
const filterHtml = `
|
|
3331
|
-
<div class="gfp-section">
|
|
3332
|
-
<div class="gfp-label">${t('graphEntityType')}</div>
|
|
3333
|
-
<div class="gfp-radio-group">
|
|
3334
|
-
${typeEntries.map(([type, count]) => `
|
|
3335
|
-
<button class="gfp-check${activeTypes.has(type) ? ' active' : ''}" data-type-filter="${escapeHtml(type)}">
|
|
3336
|
-
<span class="gfp-check-box">\u2713</span>
|
|
3337
|
-
<span class="gfp-type-dot" style="background:${typeColors[type]}"></span>
|
|
3338
|
-
${escapeHtml(type)}
|
|
3339
|
-
<span class="gfp-check-count">${count}</span>
|
|
3340
|
-
</button>
|
|
3341
|
-
`).join('')}
|
|
3342
|
-
</div>
|
|
3343
|
-
</div>
|
|
3344
|
-
`;
|
|
3345
|
-
|
|
3346
|
-
panel.innerHTML = searchHtml + scopeHtml + depthHtml + viewHtml + layoutHtml + filterHtml;
|
|
3347
|
-
|
|
3348
|
-
// Bind scope
|
|
3349
|
-
panel.querySelectorAll('[data-scope]').forEach(btn => {
|
|
3350
|
-
btn.addEventListener('click', () => {
|
|
3351
|
-
scope = btn.dataset.scope;
|
|
3352
|
-
rebuildGraph();
|
|
3353
|
-
});
|
|
3354
|
-
});
|
|
3355
|
-
|
|
3356
|
-
// Bind depth
|
|
3357
|
-
panel.querySelectorAll('[data-depth]').forEach(btn => {
|
|
3358
|
-
btn.addEventListener('click', () => {
|
|
3359
|
-
depth = parseInt(btn.dataset.depth);
|
|
3360
|
-
rebuildGraph();
|
|
3361
|
-
});
|
|
3362
|
-
});
|
|
3363
|
-
|
|
3364
|
-
// Bind view
|
|
3365
|
-
panel.querySelectorAll('[data-view]').forEach(btn => {
|
|
3366
|
-
btn.addEventListener('click', () => {
|
|
3367
|
-
currentView = btn.dataset.view;
|
|
3368
|
-
switchView();
|
|
3369
|
-
renderFilterPanel();
|
|
3370
|
-
});
|
|
3371
|
-
});
|
|
3372
|
-
|
|
3373
|
-
// Bind layout
|
|
3374
|
-
panel.querySelectorAll('[data-layout]').forEach(btn => {
|
|
3375
|
-
btn.addEventListener('click', () => {
|
|
3376
|
-
currentLayout = btn.dataset.layout;
|
|
3377
|
-
if (cy) {
|
|
3378
|
-
cy.layout(getLayoutConfig()).run();
|
|
3379
|
-
}
|
|
3380
|
-
renderFilterPanel();
|
|
3381
|
-
});
|
|
3382
|
-
});
|
|
3383
|
-
|
|
3384
|
-
// Bind type filters
|
|
3385
|
-
panel.querySelectorAll('[data-type-filter]').forEach(btn => {
|
|
3386
|
-
btn.addEventListener('click', () => {
|
|
3387
|
-
const type = btn.dataset.typeFilter;
|
|
3388
|
-
if (activeTypes.has(type)) activeTypes.delete(type);
|
|
3389
|
-
else activeTypes.add(type);
|
|
3390
|
-
rebuildGraph();
|
|
3391
|
-
});
|
|
3392
|
-
});
|
|
3393
|
-
|
|
3394
|
-
// Bind search — focus on entity and navigate
|
|
3395
|
-
const searchInput = document.getElementById('gfp-search');
|
|
3396
|
-
if (searchInput) {
|
|
3397
|
-
searchInput.addEventListener('input', () => {
|
|
3398
|
-
const q = searchInput.value.toLowerCase();
|
|
3399
|
-
if (!q || !cy) {
|
|
3400
|
-
if (cy) cy.elements().removeClass('dimmed');
|
|
3401
|
-
return;
|
|
3402
|
-
}
|
|
3403
|
-
cy.nodes().forEach(n => {
|
|
3404
|
-
const match = n.data('fullLabel').toLowerCase().includes(q) || n.data('type').toLowerCase().includes(q);
|
|
3405
|
-
if (match) { n.removeClass('dimmed'); } else { n.addClass('dimmed'); }
|
|
3406
|
-
});
|
|
3407
|
-
cy.edges().forEach(e => {
|
|
3408
|
-
if (e.source().hasClass('dimmed') && e.target().hasClass('dimmed')) e.addClass('dimmed');
|
|
3409
|
-
else e.removeClass('dimmed');
|
|
3410
|
-
});
|
|
3411
|
-
});
|
|
3412
|
-
|
|
3413
|
-
searchInput.addEventListener('keydown', (e) => {
|
|
3414
|
-
if (e.key === 'Enter') {
|
|
3415
|
-
const q = searchInput.value.toLowerCase();
|
|
3416
|
-
const match = graph.entities.find(ent => ent.name.toLowerCase().includes(q));
|
|
3417
|
-
if (match) {
|
|
3418
|
-
focusEntity = match.name;
|
|
3419
|
-
scope = 'neighborhood';
|
|
3420
|
-
rebuildGraph();
|
|
3421
|
-
}
|
|
3422
|
-
}
|
|
3423
|
-
});
|
|
3424
|
-
}
|
|
3425
|
-
}
|
|
3426
|
-
|
|
3427
|
-
function switchView() {
|
|
3428
|
-
const graphContainer = document.getElementById('graph-container');
|
|
3429
|
-
const tableContainer = document.getElementById('graph-table-container');
|
|
3430
|
-
if (currentView === 'table') {
|
|
3431
|
-
graphContainer.style.display = 'none';
|
|
3432
|
-
tableContainer.style.display = 'flex';
|
|
3433
|
-
renderTable();
|
|
3434
|
-
} else {
|
|
3435
|
-
graphContainer.style.display = '';
|
|
3436
|
-
tableContainer.style.display = 'none';
|
|
3437
|
-
}
|
|
3438
|
-
}
|
|
3439
|
-
|
|
3440
|
-
// --- Table view ---
|
|
3441
|
-
function renderTable() {
|
|
3442
|
-
const tc = document.getElementById('graph-table-container');
|
|
3443
|
-
if (!tc) return;
|
|
3444
|
-
let entities;
|
|
3445
|
-
if (scope === 'full') {
|
|
3446
|
-
entities = graph.entities.filter(e => activeTypes.has(e.entityType) && (degreeMap[e.name] || 0) > 0);
|
|
3447
|
-
} else if (scope === 'neighborhood') {
|
|
3448
|
-
const sub = getNeighborhood(focusEntity, depth);
|
|
3449
|
-
entities = [...sub.nodeNames].filter(n => activeTypes.has(entityMap[n]?.entityType)).map(n => entityMap[n]).filter(Boolean);
|
|
3450
|
-
} else {
|
|
3451
|
-
// connected: only degree > 0
|
|
3452
|
-
entities = graph.entities.filter(e => activeTypes.has(e.entityType) && (degreeMap[e.name] || 0) > 0);
|
|
3453
|
-
}
|
|
3454
|
-
const sorted = entities.sort((a, b) => (degreeMap[b.name] || 0) - (degreeMap[a.name] || 0));
|
|
3455
|
-
tc.innerHTML = `
|
|
3456
|
-
<table class="graph-table">
|
|
3457
|
-
<thead>
|
|
3458
|
-
<tr>
|
|
3459
|
-
<th>Entity</th>
|
|
3460
|
-
<th>Type</th>
|
|
3461
|
-
<th>Connections</th>
|
|
3462
|
-
<th>Observations</th>
|
|
3463
|
-
</tr>
|
|
3464
|
-
</thead>
|
|
3465
|
-
<tbody>
|
|
3466
|
-
${sorted.map(e => `
|
|
3467
|
-
<tr data-table-node="${escapeHtml(e.name)}">
|
|
3468
|
-
<td class="entity-name"><span class="entity-type-dot" style="background:${getTypeColor(e.entityType)}"></span>${escapeHtml(e.name)}</td>
|
|
3469
|
-
<td>${escapeHtml(e.entityType)}</td>
|
|
3470
|
-
<td>${degreeMap[e.name] || 0}</td>
|
|
3471
|
-
<td>${e.observations.length}</td>
|
|
3472
|
-
</tr>
|
|
3473
|
-
`).join('')}
|
|
3474
|
-
</tbody>
|
|
3475
|
-
</table>
|
|
3476
|
-
`;
|
|
3477
|
-
tc.querySelectorAll('[data-table-node]').forEach(row => {
|
|
3478
|
-
row.addEventListener('click', () => {
|
|
3479
|
-
selectedNodeId = row.dataset.tableNode;
|
|
3480
|
-
showInspector(selectedNodeId);
|
|
3481
|
-
});
|
|
3482
|
-
});
|
|
3483
|
-
}
|
|
3484
|
-
|
|
3485
|
-
// --- Status bar ---
|
|
3486
|
-
function updateStatusBar(nodeCount, edgeCount) {
|
|
3487
|
-
const gsNodes = document.getElementById('gs-nodes');
|
|
3488
|
-
const gsEdges = document.getElementById('gs-edges');
|
|
3489
|
-
const gsLayout = document.getElementById('gs-layout');
|
|
3490
|
-
const gsScope = document.getElementById('gs-scope');
|
|
3491
|
-
if (gsNodes) gsNodes.textContent = `${nodeCount || 0} ${t('nodes')}`;
|
|
3492
|
-
if (gsEdges) gsEdges.textContent = `${edgeCount || 0} ${t('edges')}`;
|
|
3493
|
-
if (gsLayout) gsLayout.textContent = currentLayout === 'dagre-tb' ? 'TB' : 'LR';
|
|
3494
|
-
if (gsScope) gsScope.textContent = scope === 'full' ? t('graphFullGraph').toLowerCase() : scope === 'neighborhood' ? `${depth}-${t('graphDepth').toLowerCase().slice(0, 3)}` : t('graphConnected').toLowerCase();
|
|
3495
|
-
if (isolatedCount > 0 && scope !== 'neighborhood') {
|
|
3496
|
-
if (gsScope) gsScope.textContent += ` · ${isolatedCount} ${t('graphIsolatedEntities').toLowerCase()}`;
|
|
3497
|
-
}
|
|
3498
|
-
}
|
|
3499
|
-
|
|
3500
|
-
// --- Zoom controls ---
|
|
3501
|
-
const gzIn = document.getElementById('gz-in');
|
|
3502
|
-
const gzOut = document.getElementById('gz-out');
|
|
3503
|
-
const gzFit = document.getElementById('gz-fit');
|
|
3504
|
-
if (gzIn) gzIn.onclick = () => { if (cy) cy.zoom(cy.zoom() * 1.3); };
|
|
3505
|
-
if (gzOut) gzOut.onclick = () => { if (cy) cy.zoom(cy.zoom() / 1.3); };
|
|
3506
|
-
if (gzFit) gzFit.onclick = () => { if (cy) cy.fit(undefined, 30); };
|
|
3507
|
-
|
|
3508
|
-
// --- Initialize ---
|
|
3509
|
-
_graphState = { graph, entityMap, degreeMap, typeColors, showInspector };
|
|
3510
|
-
const hasGraphCanvas = !!document.getElementById('cytoscape-mount');
|
|
3511
|
-
if (hasGraphCanvas) {
|
|
3512
|
-
initCytoscape();
|
|
3513
|
-
renderFilterPanel();
|
|
3514
|
-
}
|
|
3515
|
-
renderIsolatedPanel();
|
|
3516
|
-
}
|
|
3517
2661
|
|
|
3518
2662
|
// ============================================================
|
|
3519
2663
|
// Observations Page
|