memorix 1.8.4 → 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.
Files changed (73) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/CLAUDE.md +3 -0
  3. package/README.md +4 -4
  4. package/README.zh-CN.md +5 -3
  5. package/dist/cli/index.js +1542 -626
  6. package/dist/dashboard/static/app.js +29 -885
  7. package/dist/dashboard/static/index.html +1 -5
  8. package/dist/dashboard/static/style.css +36 -503
  9. package/dist/index.d.ts +4 -4
  10. package/dist/index.js +213 -70
  11. package/dist/memcode/index.js +28035 -169
  12. package/dist/memcode-runtime/CHANGELOG.md +34 -0
  13. package/dist/sdk.js +30 -12
  14. package/docs/1.8.0-RELEASE-CANDIDATE-SPEC.md +22 -16
  15. package/docs/AGENT_OPERATOR_PLAYBOOK.md +2 -2
  16. package/docs/API_REFERENCE.md +10 -8
  17. package/docs/DASHBOARD.md +23 -0
  18. package/docs/DEVELOPMENT.md +2 -2
  19. package/docs/DOCKER.md +6 -3
  20. package/docs/INTEGRATIONS.md +1 -1
  21. package/docs/MODULES.md +46 -20
  22. package/docs/PERFORMANCE.md +1 -1
  23. package/docs/README.md +8 -7
  24. package/docs/SETUP.md +3 -3
  25. package/package.json +11 -11
  26. package/plugins/antigravity/memorix/plugin.json +1 -1
  27. package/plugins/antigravity/memorix/rules/memorix.md +5 -0
  28. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  29. package/plugins/claude/memorix/skills/memorix-memory/SKILL.md +3 -0
  30. package/plugins/claude/memorix/skills/memorix-mini-skills/SKILL.md +2 -0
  31. package/plugins/claude/memorix/skills/memorix-orchestrate/SKILL.md +2 -0
  32. package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
  33. package/plugins/codebuddy/memorix/skills/memorix-memory/SKILL.md +3 -0
  34. package/plugins/codebuddy/memorix/skills/memorix-mini-skills/SKILL.md +2 -0
  35. package/plugins/codebuddy/memorix/skills/memorix-orchestrate/SKILL.md +2 -0
  36. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  37. package/plugins/codex/memorix/skills/memorix-memory/SKILL.md +3 -0
  38. package/plugins/codex/memorix/skills/memorix-mini-skills/SKILL.md +2 -0
  39. package/plugins/codex/memorix/skills/memorix-orchestrate/SKILL.md +2 -0
  40. package/plugins/copilot/memorix/plugin.json +1 -1
  41. package/plugins/copilot/memorix/skills/memorix-memory/SKILL.md +3 -0
  42. package/plugins/copilot/memorix/skills/memorix-mini-skills/SKILL.md +2 -0
  43. package/plugins/copilot/memorix/skills/memorix-orchestrate/SKILL.md +2 -0
  44. package/plugins/cursor/memorix/skills/memorix-memory/SKILL.md +3 -0
  45. package/plugins/cursor/memorix/skills/memorix-mini-skills/SKILL.md +2 -0
  46. package/plugins/cursor/memorix/skills/memorix-orchestrate/SKILL.md +2 -0
  47. package/plugins/gemini/memorix/GEMINI.md +5 -0
  48. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  49. package/plugins/hermes/memorix/plugin.yaml +7 -1
  50. package/plugins/omp/memorix/package.json +1 -1
  51. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  52. package/plugins/pi/memorix/package.json +1 -1
  53. package/plugins/pi/memorix/skills/memorix-memory/SKILL.md +3 -0
  54. package/plugins/pi/memorix/skills/memorix-mini-skills/SKILL.md +2 -0
  55. package/plugins/pi/memorix/skills/memorix-orchestrate/SKILL.md +2 -0
  56. package/server.json +2 -2
  57. package/src/cli/command-guide.ts +52 -1
  58. package/src/cli/commands/background.ts +166 -6
  59. package/src/cli/commands/serve-http.ts +73 -80
  60. package/src/cli/commands/serve.ts +95 -93
  61. package/src/cli/index.ts +1 -1
  62. package/src/dashboard/static/index.html +1 -5
  63. package/src/dashboard/static/style.css +36 -503
  64. package/src/hooks/installers/index.ts +3 -0
  65. package/src/hooks/official-skills.ts +13 -6
  66. package/src/memory/graph.ts +2 -2
  67. package/src/server/mcp-diagnostics.ts +9 -7
  68. package/src/server/modern-mcp-bridge.ts +108 -0
  69. package/src/server/stdio-startup-gate.ts +41 -2
  70. package/src/server/tool-profile.ts +7 -6
  71. package/src/server.ts +1 -1
  72. package/src/store/orama-store.ts +2 -2
  73. 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 Topology Explorer — Cytoscape.js + Dagre
2091
- // Focused topology default, not full graph dump
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
- // Prefer the deterministic memory-map projection when it is available.
2101
- try {
2102
- const kg = await api('knowledge-graph');
2103
- if (kg && kg.nodes && kg.nodes.length > 0) {
2104
- renderSemanticGraph(kg);
2105
- return;
2106
- }
2107
- } catch (_e) {
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
- // Fallback: entity/relation graph
2112
- const graph = await api('graph');
2113
- if (!graph || (graph.entities.length === 0 && graph.relations.length === 0)) {
2114
- container.innerHTML = emptyState('<span class="iconify" data-icon="lucide:network" style="font-size:36px;"></span>', t('kgNoData'), t('kgNoDataDesc'));
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
- // Check for no-relations state (only isolated entities)
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 — solid hex + opacity controlled by G6 strokeOpacity
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 > 28 ? node.label.slice(0, 26) + '\u2026' : node.label,
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