memorix 1.0.4 → 1.0.6

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.
@@ -173,6 +173,18 @@ const i18n = {
173
173
  identityUnavailable: 'Identity Unavailable',
174
174
  identityUnavailableDesc: 'Could not load project identity data',
175
175
 
176
+ // System Health
177
+ systemHealth: 'System Health',
178
+ searchMode: 'Search Mode',
179
+ embeddingProvider: 'Embedding Provider',
180
+ backfillPending: 'Backfill Pending',
181
+ vectorsMissing: 'vectors missing',
182
+ noBackfillNeeded: 'All vectors indexed',
183
+ providerReady: 'Ready',
184
+ providerUnavailable: 'Unavailable',
185
+ providerDisabled: 'Disabled (BM25 only)',
186
+ degradedHint: 'Search is degraded — no vector similarity',
187
+
176
188
  // Nav tooltips
177
189
  navDashboard: 'Dashboard',
178
190
  navGitMemory: 'Git Memory',
@@ -349,6 +361,18 @@ const i18n = {
349
361
  identityUnavailable: '身份信息不可用',
350
362
  identityUnavailableDesc: '无法加载项目身份数据',
351
363
 
364
+ // System Health
365
+ systemHealth: '系统健康',
366
+ searchMode: '搜索模式',
367
+ embeddingProvider: '向量提供者',
368
+ backfillPending: '回填待处理',
369
+ vectorsMissing: '条向量缺失',
370
+ noBackfillNeeded: '所有向量已索引',
371
+ providerReady: '就绪',
372
+ providerUnavailable: '不可用',
373
+ providerDisabled: '已禁用 (仅 BM25)',
374
+ degradedHint: '搜索已降级 — 无向量相似性',
375
+
352
376
  // Nav tooltips
353
377
  navDashboard: '仪表盘',
354
378
  navGitMemory: 'Git 记忆',
@@ -522,7 +546,8 @@ async function initProjectSwitcher() {
522
546
  }
523
547
 
524
548
  // Determine active project
525
- let active = allProjects.find(p => p.isCurrent);
549
+ // Strategy: prefer URL param > isCurrent (if it has real data) > first project with most observations
550
+ let active = null;
526
551
  if (urlProject) {
527
552
  const urlMatch = allProjects.find(p => p.id === urlProject);
528
553
  if (urlMatch) {
@@ -532,7 +557,26 @@ async function initProjectSwitcher() {
532
557
  loadPage(currentPage);
533
558
  }
534
559
  }
535
- if (!active) active = allProjects[0];
560
+ if (!active) {
561
+ const current = allProjects.find(p => p.isCurrent);
562
+ // Only use isCurrent if it's a real project with data (not __unresolved__ / system dir with 0 obs)
563
+ if (current && current.count > 0 && current.id !== '__unresolved__') {
564
+ active = current;
565
+ selectedProject = current.id;
566
+ } else {
567
+ // Auto-select the first project with the most observations (list is pre-sorted by count desc)
568
+ const firstReal = allProjects.find(p => p.count > 0 && p.id !== '__unresolved__');
569
+ if (firstReal) {
570
+ active = firstReal;
571
+ selectedProject = firstReal.id;
572
+ } else {
573
+ active = current || allProjects[0];
574
+ selectedProject = active?.id || '';
575
+ }
576
+ }
577
+ Object.keys(loaded).forEach(k => delete loaded[k]);
578
+ loadPage(currentPage);
579
+ }
536
580
 
537
581
  updateTrigger(active);
538
582
  renderProjectList(allProjects, active);
@@ -596,7 +640,7 @@ async function initProjectSwitcher() {
596
640
  const project = allProjects.find(p => p.id === id);
597
641
  if (!project) return;
598
642
 
599
- selectedProject = project.isCurrent ? '' : project.id;
643
+ selectedProject = project.id;
600
644
  updateTrigger(project);
601
645
  switcher.classList.remove('open');
602
646
 
@@ -703,6 +747,41 @@ async function loadDashboard() {
703
747
  </div>
704
748
  </div>
705
749
 
750
+ <!-- System Health -->
751
+ <div class="overview-row">
752
+ <div class="panel" style="flex:1;">
753
+ <div class="panel-header"><span class="panel-title">${t('systemHealth')}</span></div>
754
+ <div class="panel-body">
755
+ <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;">
756
+ <div>
757
+ <div style="font-size:11px;color:var(--text-muted);margin-bottom:4px;">${t('embeddingProvider')}</div>
758
+ <div style="font-size:14px;font-weight:600;color:${stats.embedding?.enabled ? 'var(--accent-green)' : stats.embedding?.provider ? 'var(--accent-amber)' : 'var(--text-muted)'};">
759
+ ${stats.embedding?.enabled ? t('providerReady') : stats.embedding?.provider ? t('providerUnavailable') : t('providerDisabled')}
760
+ </div>
761
+ ${stats.embedding?.provider ? `<div style="font-size:11px;color:var(--text-secondary);margin-top:2px;">${stats.embedding.provider} (${stats.embedding.dimensions}d)</div>` : ''}
762
+ </div>
763
+ <div>
764
+ <div style="font-size:11px;color:var(--text-muted);margin-bottom:4px;">${t('backfillPending')}</div>
765
+ <div style="font-size:14px;font-weight:600;color:${(stats.vectorStatus?.missing || 0) > 0 ? 'var(--accent-amber)' : 'var(--accent-green)'};">
766
+ ${(stats.vectorStatus?.missing || 0) > 0 ? stats.vectorStatus.missing + ' ' + t('vectorsMissing') : t('noBackfillNeeded')}
767
+ </div>
768
+ </div>
769
+ <div>
770
+ <div style="font-size:11px;color:var(--text-muted);margin-bottom:4px;">${t('searchMode')}</div>
771
+ <div style="font-size:14px;font-weight:600;color:${
772
+ (stats.searchMode || '').includes('hybrid') ? 'var(--accent-blue)'
773
+ : (stats.searchMode || '').includes('vector') ? 'var(--accent-purple)'
774
+ : (stats.searchMode || '').includes('rerank') ? 'var(--accent-green)'
775
+ : 'var(--accent-amber)'};">
776
+ ${stats.searchMode || (stats.embedding?.enabled ? 'hybrid' : 'fulltext')}
777
+ </div>
778
+ ${stats.embeddingProviderState === 'temporarily_unavailable' ? `<div style="font-size:11px;color:var(--accent-amber);margin-top:2px;">${t('degradedHint')}</div>` : ''}
779
+ </div>
780
+ </div>
781
+ </div>
782
+ </div>
783
+ </div>
784
+
706
785
  <!-- Source Breakdown -->
707
786
  <div class="overview-row">
708
787
  <div class="panel" style="flex:1;">
@@ -833,9 +912,12 @@ function renderPieChart(canvasId, entries, icons) {
833
912
  }
834
913
 
835
914
  // ============================================================
836
- // Knowledge Graph Page
915
+ // Memory Topology Explorer — Cytoscape.js + Dagre
916
+ // Focused topology default, not full graph dump
837
917
  // ============================================================
838
918
 
919
+ let _graphState = null;
920
+
839
921
  async function loadGraph() {
840
922
  const container = document.getElementById('page-graph');
841
923
  container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
@@ -848,70 +930,52 @@ async function loadGraph() {
848
930
 
849
931
  container.innerHTML = `
850
932
  <div class="page-header">
851
- <h1 class="page-title">${t('knowledgeGraph')}</h1>
852
- <p class="page-subtitle">${graph.entities.length} ${t('entities').toLowerCase()}, ${graph.relations.length} ${t('relations').toLowerCase()}</p>
933
+ <h1 class="page-title">Memory Topology</h1>
934
+ <p class="page-subtitle">${graph.entities.length} entities · ${graph.relations.length} relations</p>
853
935
  </div>
854
936
  <div class="graph-layout">
937
+ <div class="graph-filter-panel" id="graph-filter-panel"></div>
855
938
  <div id="graph-container">
856
- <canvas id="graph-canvas"></canvas>
857
- <div class="graph-tooltip" id="graph-tooltip">
858
- <div class="graph-tooltip-name"></div>
859
- <div class="graph-tooltip-type"></div>
860
- </div>
861
- <div class="graph-panel" id="graph-panel">
862
- <div class="graph-panel-tabs">
863
- <button class="gp-tab active" data-gptab="stats">STATS</button>
864
- <button class="gp-tab" data-gptab="legend">LEGEND</button>
865
- <button class="gp-tab" data-gptab="filter">FILTER</button>
866
- <button class="gp-tab" data-gptab="search">SEARCH</button>
939
+ <div id="cytoscape-mount"></div>
940
+ <div class="graph-status-bar">
941
+ <span class="graph-status-item" id="gs-nodes"></span>
942
+ <span class="graph-status-item" id="gs-edges"></span>
943
+ <span class="graph-status-item" id="gs-layout"></span>
944
+ <span class="graph-status-item" id="gs-scope"></span>
945
+ <div class="graph-zoom-controls">
946
+ <button class="graph-zoom-btn" id="gz-out">\u2212</button>
947
+ <button class="graph-zoom-btn" id="gz-fit">\u2B21</button>
948
+ <button class="graph-zoom-btn" id="gz-in">+</button>
867
949
  </div>
868
- <div class="gp-content" id="gp-content"></div>
869
950
  </div>
870
- <div class="graph-detail-drawer" id="graph-detail-drawer"></div>
951
+ </div>
952
+ <div class="graph-table-container" id="graph-table-container" style="display:none;"></div>
953
+ <div class="graph-inspector" id="graph-inspector">
954
+ <div class="gi-empty"><div class="gi-empty-icon">\u2B21</div>Select a node to inspect</div>
871
955
  </div>
872
956
  </div>
957
+ <div id="graph-isolated-panel" style="display:none;"></div>
873
958
  `;
874
959
 
875
960
  renderGraph(graph);
876
961
  }
877
962
 
878
963
  // ============================================================
879
- // Canvas-based Force-Directed GraphInfraNodus Style
880
- // Solid glowing nodes, colored gradient edges, labels on nodes
964
+ // Cytoscape.js + DagreFocused Topology Renderer
965
+ // Default: 1-hop neighborhood of top entity, dagre LR layout
881
966
  // ============================================================
882
967
 
883
968
  function renderGraph(graph) {
884
- const canvas = document.getElementById('graph-canvas');
885
- const ctx = canvas.getContext('2d');
886
- const container = document.getElementById('graph-container');
887
-
888
- const rect = container.getBoundingClientRect();
889
- const dpr = window.devicePixelRatio || 1;
890
- canvas.width = rect.width * dpr;
891
- canvas.height = rect.height * dpr;
892
- canvas.style.width = rect.width + 'px';
893
- canvas.style.height = rect.height + 'px';
894
- ctx.scale(dpr, dpr);
895
-
896
- const W = rect.width;
897
- const H = rect.height;
898
-
899
- // --- Cosmic starfield background ---
900
- const stars = [];
901
- for (let i = 0; i < 180; i++) {
902
- stars.push({
903
- x: Math.random() * W,
904
- y: Math.random() * H,
905
- r: Math.random() * 1.2 + 0.2,
906
- a: Math.random() * 0.5 + 0.05,
907
- twinkle: Math.random() * Math.PI * 2,
908
- });
969
+ // Register dagre layout if not already registered
970
+ if (typeof cytoscape !== 'undefined' && typeof cytoscapeDagre !== 'undefined' && !cytoscape._dagreRegistered) {
971
+ cytoscapeDagre(cytoscape);
972
+ cytoscape._dagreRegistered = true;
909
973
  }
910
974
 
911
- // --- InfraNodus-inspired vibrant palette ---
975
+ // --- Muted enterprise palette ---
912
976
  const palette = [
913
- '#69F0AE', '#FFAB40', '#D0BCFF', '#80D8FF',
914
- '#FFD54F', '#FFB8D1', '#82B1FF', '#FF8A80',
977
+ '#7C9CBF', '#8FB996', '#C4956A', '#A893C2',
978
+ '#6BA3A0', '#B8A44C', '#C27878', '#7B8EB8',
915
979
  ];
916
980
  const typeColors = {};
917
981
  let colorIdx = 0;
@@ -920,707 +984,724 @@ function renderGraph(graph) {
920
984
  return typeColors[type];
921
985
  }
922
986
 
923
- // Detect if one type dominates — if so, use name-hash for color variety
924
987
  const typeCounts = {};
925
988
  graph.entities.forEach(e => { typeCounts[e.entityType] = (typeCounts[e.entityType] || 0) + 1; });
926
- const maxTypeCount = Math.max(...Object.values(typeCounts));
927
- const useNameHash = maxTypeCount > graph.entities.length * 0.6;
928
-
929
- function hashColor(name) {
930
- let h = 0;
931
- for (let i = 0; i < name.length; i++) h = ((h << 5) - h + name.charCodeAt(i)) | 0;
932
- return palette[((h % palette.length) + palette.length) % palette.length];
933
- }
934
-
935
- // --- Build nodes & edges ---
936
- const nodes = graph.entities.map((e) => {
937
- const obsCount = e.observations.length;
938
- return {
939
- id: e.name, type: e.entityType, observations: e.observations,
940
- x: (Math.random() - 0.5) * W * 0.5,
941
- y: (Math.random() - 0.5) * H * 0.5,
942
- vx: 0, vy: 0,
943
- baseRadius: Math.max(5, Math.min(4 + Math.sqrt(obsCount) * 3, 20)),
944
- radius: 0,
945
- color: useNameHash ? hashColor(e.name) : getTypeColor(e.entityType),
946
- degree: 0,
947
- };
948
- });
949
- const nodeMap = {};
950
- nodes.forEach(n => nodeMap[n.id] = n);
951
-
952
- const edges = graph.relations
953
- .filter(r => nodeMap[r.from] && nodeMap[r.to])
954
- .map(r => {
955
- nodeMap[r.from].degree++;
956
- nodeMap[r.to].degree++;
957
- return { source: nodeMap[r.from], target: nodeMap[r.to], type: r.relationType };
958
- });
959
-
960
- // Ensure typeColors populated for legend
961
- Object.keys(typeCounts).forEach(t => getTypeColor(t));
962
-
963
- // Node sizing: smaller circles (labels are the visual identity in InfraNodus)
964
- const maxDegree = Math.max(1, ...nodes.map(n => n.degree));
965
- nodes.forEach(n => {
966
- const degreeBoost = (n.degree / maxDegree) * 12;
967
- n.radius = Math.min(n.baseRadius * 0.7 + degreeBoost, 24);
968
- });
989
+ Object.keys(typeCounts).forEach(t2 => getTypeColor(t2));
969
990
 
970
- // --- Camera (zoom & pan) ---
971
- let cam = { x: 0, y: 0, zoom: 1 };
972
- function worldToScreen(wx, wy) {
973
- return { x: (wx - cam.x) * cam.zoom + W / 2, y: (wy - cam.y) * cam.zoom + H / 2 };
974
- }
975
- function screenToWorld(sx, sy) {
976
- return { x: (sx - W / 2) / cam.zoom + cam.x, y: (sy - H / 2) / cam.zoom + cam.y };
977
- }
991
+ function isLight() { return document.documentElement.getAttribute('data-theme') === 'light'; }
978
992
 
979
- // --- Physics (cluster-based layout — separate galaxies per type) ---
980
- const REPULSION = 4000;
981
- const ATTRACTION = 0.008;
982
- const DAMPING = 0.82;
983
- const IDEAL_DIST = 80;
984
- const CLUSTER_PULL = 0.012; // Pull nodes toward their cluster center
985
- const INTER_CLUSTER_REPEL = 8000; // Repel cluster centers apart
986
-
987
- let hoveredNode = null;
988
- let selectedNode = null;
989
- let dragNode = null;
990
- let panStart = null;
991
- let simTick = 0;
992
- let isSettled = false;
993
- let stableMode = nodes.length > 15; // Auto-stable for larger graphs
994
- const SETTLE_THRESHOLD = 0.15;
995
- let settleCountdown = 0; // frames below threshold before declaring settled
996
-
997
- // Group nodes by color → separate galaxies
998
- const colorGroups = {};
999
- nodes.forEach(n => { (colorGroups[n.color] = colorGroups[n.color] || []).push(n); });
1000
- const groupKeys = Object.keys(colorGroups);
1001
-
1002
- // Assign cluster centers with asymmetric organic placement
1003
- const clusterCenters = {};
1004
- const baseR = Math.min(W, H) * 0.28;
1005
- groupKeys.forEach((color, gi) => {
1006
- // Irregular angle spacing + random orbit distance
1007
- const baseAngle = (gi / groupKeys.length) * Math.PI * 2;
1008
- const angleJitter = (Math.random() - 0.5) * (Math.PI * 0.4);
1009
- const angle = baseAngle + angleJitter;
1010
- const r = baseR * (0.5 + Math.random() * 0.7);
1011
- clusterCenters[color] = {
1012
- x: Math.cos(angle) * r + (Math.random() - 0.5) * baseR * 0.3,
1013
- y: Math.sin(angle) * r + (Math.random() - 0.5) * baseR * 0.3,
1014
- };
993
+ // --- Build data structures ---
994
+ const entityMap = {};
995
+ graph.entities.forEach(e => {
996
+ entityMap[e.name] = e;
1015
997
  });
1016
998
 
1017
- // Initial placement near cluster centers
1018
- groupKeys.forEach((color) => {
1019
- const cc = clusterCenters[color];
1020
- const spread = 40 + colorGroups[color].length * 5;
1021
- colorGroups[color].forEach(n => {
1022
- n.x = cc.x + (Math.random() - 0.5) * spread;
1023
- n.y = cc.y + (Math.random() - 0.5) * spread;
1024
- });
999
+ // Compute degree for each entity
1000
+ const degreeMap = {};
1001
+ graph.entities.forEach(e => { degreeMap[e.name] = 0; });
1002
+ graph.relations.forEach(r => {
1003
+ if (degreeMap[r.from] !== undefined) degreeMap[r.from]++;
1004
+ if (degreeMap[r.to] !== undefined) degreeMap[r.to]++;
1025
1005
  });
1026
1006
 
1027
- function simulate() {
1028
- simTick++;
1029
-
1030
- // --- Inter-cluster repulsion (push cluster centers apart) ---
1031
- for (let i = 0; i < groupKeys.length; i++) {
1032
- for (let j = i + 1; j < groupKeys.length; j++) {
1033
- const ca = clusterCenters[groupKeys[i]], cb = clusterCenters[groupKeys[j]];
1034
- let dx = cb.x - ca.x, dy = cb.y - ca.y;
1035
- let dist = Math.sqrt(dx * dx + dy * dy) || 1;
1036
- let force = INTER_CLUSTER_REPEL / (dist * dist);
1037
- ca.x -= (dx / dist) * force * 0.01;
1038
- ca.y -= (dy / dist) * force * 0.01;
1039
- cb.x += (dx / dist) * force * 0.01;
1040
- cb.y += (dy / dist) * force * 0.01;
1007
+ // Find top entity by degree (for default focus)
1008
+ const topEntity = graph.entities.reduce((best, e) =>
1009
+ (degreeMap[e.name] || 0) > (degreeMap[best.name] || 0) ? e : best,
1010
+ graph.entities[0]
1011
+ );
1012
+
1013
+ // --- Computed stats ---
1014
+ const isolatedCount = graph.entities.filter(e => (degreeMap[e.name] || 0) === 0).length;
1015
+ const connectedCount = graph.entities.length - isolatedCount;
1016
+ const isSparse = isolatedCount > connectedCount;
1017
+
1018
+ // --- State ---
1019
+ let activeTypes = new Set(Object.keys(typeCounts));
1020
+ let currentView = 'topology'; // 'topology' | 'table'
1021
+ let currentLayout = 'dagre-lr'; // 'dagre-lr' | 'dagre-tb'
1022
+ let focusEntity = topEntity.name;
1023
+ let depth = 1;
1024
+ let scope = 'connected'; // 'connected' | 'neighborhood' | 'full'
1025
+ let selectedNodeId = null;
1026
+ let cy = null; // Cytoscape instance
1027
+
1028
+ // --- Subgraph extraction (BFS n-hop neighborhood) ---
1029
+ function getNeighborhood(centerName, maxDepth) {
1030
+ const visited = new Set();
1031
+ const edgeSet = new Set();
1032
+ const queue = [{ name: centerName, d: 0 }];
1033
+ visited.add(centerName);
1034
+
1035
+ while (queue.length > 0) {
1036
+ const { name, d } = queue.shift();
1037
+ if (d >= maxDepth) continue;
1038
+ for (const r of graph.relations) {
1039
+ if (r.from === name && entityMap[r.to] && !visited.has(r.to)) {
1040
+ visited.add(r.to);
1041
+ edgeSet.add(r);
1042
+ queue.push({ name: r.to, d: d + 1 });
1043
+ } else if (r.from === name && entityMap[r.to]) {
1044
+ edgeSet.add(r);
1045
+ }
1046
+ if (r.to === name && entityMap[r.from] && !visited.has(r.from)) {
1047
+ visited.add(r.from);
1048
+ edgeSet.add(r);
1049
+ queue.push({ name: r.from, d: d + 1 });
1050
+ } else if (r.to === name && entityMap[r.from]) {
1051
+ edgeSet.add(r);
1052
+ }
1041
1053
  }
1042
1054
  }
1055
+ return {
1056
+ nodeNames: visited,
1057
+ edges: [...edgeSet].filter(r => visited.has(r.from) && visited.has(r.to)),
1058
+ };
1059
+ }
1043
1060
 
1044
- // --- Warmup factor (ramp all forces gradually to prevent explosive start) ---
1045
- const warmup = Math.min(1, simTick / 100);
1046
-
1047
- // --- Node-to-node repulsion (also ramped) ---
1048
- const curRepulsion = REPULSION * (0.1 + 0.9 * warmup);
1049
- for (let i = 0; i < nodes.length; i++) {
1050
- for (let j = i + 1; j < nodes.length; j++) {
1051
- const a = nodes[i], b = nodes[j];
1052
- let dx = b.x - a.x, dy = b.y - a.y;
1053
- let dist = Math.sqrt(dx * dx + dy * dy) || 1;
1054
- let force = curRepulsion / (dist * dist);
1055
- // Same-color nodes repel less (stay in galaxy)
1056
- if (a.color === b.color) force *= 0.4;
1057
- let fx = (dx / dist) * force, fy = (dy / dist) * force;
1058
- a.vx -= fx; a.vy -= fy;
1059
- b.vx += fx; b.vy += fy;
1060
- }
1061
+ // --- Build Cytoscape elements from current state ---
1062
+ function buildElements() {
1063
+ let nodeNames, visibleEdges;
1064
+
1065
+ if (scope === 'full') {
1066
+ // Full graph canvas: only connected nodes (isolated go to inventory panel below)
1067
+ nodeNames = new Set(
1068
+ graph.entities.filter(e => activeTypes.has(e.entityType) && (degreeMap[e.name] || 0) > 0).map(e => e.name)
1069
+ );
1070
+ visibleEdges = graph.relations.filter(r => nodeNames.has(r.from) && nodeNames.has(r.to));
1071
+ } else if (scope === 'neighborhood') {
1072
+ // Focused neighborhood: BFS from focusEntity
1073
+ const sub = getNeighborhood(focusEntity, depth);
1074
+ nodeNames = new Set([...sub.nodeNames].filter(n => activeTypes.has(entityMap[n]?.entityType)));
1075
+ if (entityMap[focusEntity]) nodeNames.add(focusEntity);
1076
+ visibleEdges = sub.edges.filter(r => nodeNames.has(r.from) && nodeNames.has(r.to));
1077
+ } else {
1078
+ // DEFAULT: 'connected' — only nodes with degree > 0 (no isolated nodes)
1079
+ nodeNames = new Set(
1080
+ graph.entities
1081
+ .filter(e => activeTypes.has(e.entityType) && (degreeMap[e.name] || 0) > 0)
1082
+ .map(e => e.name)
1083
+ );
1084
+ visibleEdges = graph.relations.filter(r => nodeNames.has(r.from) && nodeNames.has(r.to));
1061
1085
  }
1062
1086
 
1063
- // --- Edge attraction (also ramped) ---
1064
- const curAttraction = ATTRACTION * warmup;
1065
- for (const edge of edges) {
1066
- let dx = edge.target.x - edge.source.x, dy = edge.target.y - edge.source.y;
1067
- let dist = Math.sqrt(dx * dx + dy * dy) || 1;
1068
- let force = (dist - IDEAL_DIST) * curAttraction;
1069
- let fx = (dx / dist) * force, fy = (dy / dist) * force;
1070
- edge.source.vx += fx; edge.source.vy += fy;
1071
- edge.target.vx -= fx; edge.target.vy -= fy;
1072
- }
1087
+ // Top centrality: only top 3 show labels by default (not 10)
1088
+ const visibleDegrees = {};
1089
+ nodeNames.forEach(n => { visibleDegrees[n] = 0; });
1090
+ visibleEdges.forEach(r => {
1091
+ if (visibleDegrees[r.from] !== undefined) visibleDegrees[r.from]++;
1092
+ if (visibleDegrees[r.to] !== undefined) visibleDegrees[r.to]++;
1093
+ });
1094
+ const topCentrality = new Set(
1095
+ [...nodeNames].sort((a, b) => (visibleDegrees[b] || 0) - (visibleDegrees[a] || 0)).slice(0, 3)
1096
+ );
1073
1097
 
1074
- // --- Cluster gravity (pull each node toward its galaxy center) ---
1075
- for (const node of nodes) {
1076
- const cc = clusterCenters[node.color];
1077
- if (!cc) continue;
1078
- node.vx += (cc.x - node.x) * CLUSTER_PULL;
1079
- node.vy += (cc.y - node.y) * CLUSTER_PULL;
1080
- }
1098
+ const nodes = [...nodeNames].map(name => {
1099
+ const e = entityMap[name];
1100
+ const deg = visibleDegrees[name] || 0;
1101
+ const isFocus = scope === 'neighborhood' && name === focusEntity;
1102
+ const isTop = topCentrality.has(name);
1103
+ // Labels: only top 3 centrality nodes show labels by default
1104
+ const showLabel = isFocus || isTop;
1105
+ return {
1106
+ data: {
1107
+ id: name,
1108
+ label: showLabel ? (name.length > 24 ? name.slice(0, 22) + '\u2026' : name) : '',
1109
+ fullLabel: name,
1110
+ type: e.entityType,
1111
+ obsCount: e.observations.length,
1112
+ degree: deg,
1113
+ color: getTypeColor(e.entityType),
1114
+ isFocus: isFocus,
1115
+ nodeSize: Math.max(16, Math.min(12 + Math.sqrt(deg) * 6, 40)),
1116
+ },
1117
+ };
1118
+ });
1081
1119
 
1082
- // --- Gentle jitter only during warmup, then pure physics ---
1083
- const jitter = simTick < 60 ? 0.06 * (1 - simTick / 60) : 0;
1084
- const maxV = simTick < 40 ? 1.2 : 3.0; // Strict speed limit during early frames
1085
- let totalMovement = 0;
1086
- for (const node of nodes) {
1087
- if (node === dragNode) continue;
1088
- node.vx *= DAMPING; node.vy *= DAMPING;
1089
- if (jitter > 0) {
1090
- node.vx += (Math.random() - 0.5) * jitter;
1091
- node.vy += (Math.random() - 0.5) * jitter;
1092
- }
1093
- // Clamp velocity
1094
- const speed = Math.sqrt(node.vx * node.vx + node.vy * node.vy);
1095
- if (speed > maxV) { node.vx *= maxV / speed; node.vy *= maxV / speed; }
1096
- node.x += node.vx; node.y += node.vy;
1097
- totalMovement += Math.abs(node.vx) + Math.abs(node.vy);
1098
- }
1120
+ const edges = visibleEdges.map((r, i) => ({
1121
+ data: {
1122
+ id: 'e' + i + '_' + r.from + '_' + r.to,
1123
+ source: r.from,
1124
+ target: r.to,
1125
+ relationType: r.relationType,
1126
+ },
1127
+ }));
1099
1128
 
1100
- // Settle detection stop physics when stable
1101
- if (stableMode && simTick > 120) {
1102
- const avgMovement = totalMovement / Math.max(1, nodes.length);
1103
- if (avgMovement < SETTLE_THRESHOLD) {
1104
- settleCountdown++;
1105
- if (settleCountdown > 30) isSettled = true;
1106
- } else {
1107
- settleCountdown = 0;
1108
- }
1109
- }
1110
- return totalMovement;
1129
+ return { nodes, edges, visibleCount: nodeNames.size, edgeCount: visibleEdges.length };
1111
1130
  }
1112
1131
 
1113
- function isLight() { return document.documentElement.getAttribute('data-theme') === 'light'; }
1114
-
1115
- function hexRGBA(hex, alpha) {
1116
- const r = parseInt(hex.slice(1, 3), 16);
1117
- const g = parseInt(hex.slice(3, 5), 16);
1118
- const b = parseInt(hex.slice(5, 7), 16);
1119
- return `rgba(${r},${g},${b},${alpha})`;
1132
+ // --- Cytoscape style ---
1133
+ function getCyStyle() {
1134
+ const light = isLight();
1135
+ return [
1136
+ {
1137
+ selector: 'node',
1138
+ style: {
1139
+ 'width': 'data(nodeSize)',
1140
+ 'height': 'data(nodeSize)',
1141
+ 'background-color': 'data(color)',
1142
+ 'background-opacity': 0.85,
1143
+ 'border-width': 1,
1144
+ 'border-color': light ? 'rgba(0,0,0,0.12)' : 'rgba(255,255,255,0.12)',
1145
+ 'label': 'data(label)',
1146
+ 'font-size': 10,
1147
+ 'font-family': 'Inter, system-ui, sans-serif',
1148
+ 'font-weight': 400,
1149
+ 'color': light ? '#1C1B1F' : '#E6E1E5',
1150
+ 'text-valign': 'bottom',
1151
+ 'text-halign': 'center',
1152
+ 'text-margin-y': 4,
1153
+ 'text-max-width': 120,
1154
+ 'text-wrap': 'ellipsis',
1155
+ 'text-background-color': light ? '#F7F2FA' : '#0F0F17',
1156
+ 'text-background-opacity': 0.7,
1157
+ 'text-background-padding': '2px',
1158
+ 'text-background-shape': 'roundrectangle',
1159
+ 'min-zoomed-font-size': 8,
1160
+ },
1161
+ },
1162
+ {
1163
+ selector: 'node[?isFocus]',
1164
+ style: {
1165
+ 'border-width': 3,
1166
+ 'border-color': light ? '#6750A4' : '#D0BCFF',
1167
+ 'font-weight': 600,
1168
+ 'font-size': 12,
1169
+ },
1170
+ },
1171
+ {
1172
+ selector: 'node:selected',
1173
+ style: {
1174
+ 'border-width': 3,
1175
+ 'border-color': light ? '#6750A4' : '#D0BCFF',
1176
+ 'border-style': 'dashed',
1177
+ 'font-weight': 600,
1178
+ 'label': 'data(fullLabel)',
1179
+ },
1180
+ },
1181
+ {
1182
+ selector: 'node.hover',
1183
+ style: {
1184
+ 'border-width': 2,
1185
+ 'border-color': light ? '#6750A4' : '#D0BCFF',
1186
+ 'label': 'data(fullLabel)',
1187
+ 'font-weight': 500,
1188
+ 'z-index': 999,
1189
+ },
1190
+ },
1191
+ {
1192
+ selector: 'edge',
1193
+ style: {
1194
+ 'width': 1,
1195
+ 'line-color': light ? 'rgba(0,0,0,0.1)' : 'rgba(255,255,255,0.1)',
1196
+ 'target-arrow-color': light ? 'rgba(0,0,0,0.15)' : 'rgba(255,255,255,0.15)',
1197
+ 'target-arrow-shape': 'triangle',
1198
+ 'arrow-scale': 0.7,
1199
+ 'curve-style': 'bezier',
1200
+ 'label': '',
1201
+ },
1202
+ },
1203
+ {
1204
+ selector: 'edge:selected, edge.hover',
1205
+ style: {
1206
+ 'width': 2,
1207
+ 'line-color': light ? 'rgba(103,80,164,0.5)' : 'rgba(208,188,255,0.4)',
1208
+ 'target-arrow-color': light ? 'rgba(103,80,164,0.6)' : 'rgba(208,188,255,0.5)',
1209
+ 'label': 'data(relationType)',
1210
+ 'font-size': 9,
1211
+ 'font-family': 'JetBrains Mono, monospace',
1212
+ 'color': light ? '#6750A4' : '#D0BCFF',
1213
+ 'text-background-color': light ? '#F7F2FA' : '#0F0F17',
1214
+ 'text-background-opacity': 0.8,
1215
+ 'text-background-padding': '2px',
1216
+ 'text-background-shape': 'roundrectangle',
1217
+ 'text-rotation': 'autorotate',
1218
+ },
1219
+ },
1220
+ {
1221
+ selector: '.dimmed',
1222
+ style: {
1223
+ 'opacity': 0.15,
1224
+ },
1225
+ },
1226
+ ];
1120
1227
  }
1121
1228
 
1122
- // --- Draw (InfraNodus style) ---
1123
- // Canvas always renders dark cosmic background, independent of page theme
1124
- function draw() {
1125
- ctx.clearRect(0, 0, W, H);
1126
-
1127
- // --- Cosmic background (always dark) ---
1128
- const bgGrad = ctx.createRadialGradient(W * 0.5, H * 1.2, 0, W * 0.5, H * 0.5, H * 1.1);
1129
- bgGrad.addColorStop(0, '#1a1040');
1130
- bgGrad.addColorStop(0.4, '#0e0e1e');
1131
- bgGrad.addColorStop(1, '#06060C');
1132
- ctx.fillStyle = bgGrad;
1133
- ctx.fillRect(0, 0, W, H);
1134
-
1135
- // Stars with subtle twinkle
1136
- const t0 = Date.now() * 0.001;
1137
- for (const star of stars) {
1138
- const flicker = 0.6 + 0.4 * Math.sin(t0 * 0.8 + star.twinkle);
1139
- ctx.beginPath();
1140
- ctx.arc(star.x, star.y, star.r, 0, Math.PI * 2);
1141
- ctx.fillStyle = `rgba(220,210,255,${star.a * flicker})`;
1142
- ctx.fill();
1229
+ // --- Layout config ---
1230
+ function getLayoutConfig() {
1231
+ if (currentLayout === 'dagre-tb') {
1232
+ return { name: 'dagre', rankDir: 'TB', nodeSep: 40, rankSep: 60, edgeSep: 20, padding: 30 };
1143
1233
  }
1234
+ // Default: dagre LR
1235
+ return { name: 'dagre', rankDir: 'LR', nodeSep: 40, rankSep: 80, edgeSep: 20, padding: 30 };
1236
+ }
1144
1237
 
1145
- // Subtle nebula glow
1146
- const neb = ctx.createRadialGradient(W * 0.3, H * 0.7, 0, W * 0.3, H * 0.7, W * 0.35);
1147
- neb.addColorStop(0, 'rgba(100, 60, 180, 0.04)');
1148
- neb.addColorStop(1, 'rgba(100, 60, 180, 0)');
1149
- ctx.fillStyle = neb;
1150
- ctx.fillRect(0, 0, W, H);
1151
-
1152
- // --- Edges: ALL colored with gradient (InfraNodus signature) ---
1153
- for (const edge of edges) {
1154
- const isActive = (hoveredNode && (edge.source === hoveredNode || edge.target === hoveredNode))
1155
- || (selectedNode && (edge.source === selectedNode || edge.target === selectedNode));
1156
- const s = worldToScreen(edge.source.x, edge.source.y);
1157
- const t2 = worldToScreen(edge.target.x, edge.target.y);
1158
- const mx = (s.x + t2.x) / 2, my = (s.y + t2.y) / 2;
1159
- const dx = t2.x - s.x, dy = t2.y - s.y;
1160
- const edgeLen = Math.sqrt(dx * dx + dy * dy);
1161
- const ox = -dy * 0.05, oy = dx * 0.05;
1162
-
1163
- if (edge.source._dimmed || edge.target._dimmed) { ctx.globalAlpha = 0.03; }
1164
-
1165
- // --- Edge glow (chain-like, zditor style) ---
1166
- if (edgeLen > 2) {
1167
- // Outer glow layer
1168
- ctx.beginPath();
1169
- ctx.moveTo(s.x, s.y);
1170
- ctx.quadraticCurveTo(mx + ox, my + oy, t2.x, t2.y);
1171
- const glowGrad = ctx.createLinearGradient(s.x, s.y, t2.x, t2.y);
1172
- glowGrad.addColorStop(0, hexRGBA(edge.source.color, isActive ? 0.25 : 0.12));
1173
- glowGrad.addColorStop(1, hexRGBA(edge.target.color, isActive ? 0.25 : 0.12));
1174
- ctx.strokeStyle = glowGrad;
1175
- ctx.lineWidth = isActive ? 12 * cam.zoom : 6 * cam.zoom;
1176
- ctx.stroke();
1177
- }
1238
+ // --- Initialize / rebuild Cytoscape ---
1239
+ function initCytoscape() {
1240
+ const { nodes, edges, visibleCount, edgeCount } = buildElements();
1241
+
1242
+ if (cy) cy.destroy();
1243
+
1244
+ const light = isLight();
1245
+ const mountEl = document.getElementById('cytoscape-mount');
1246
+ if (!mountEl) return;
1247
+
1248
+ cy = cytoscape({
1249
+ container: mountEl,
1250
+ elements: [...nodes, ...edges],
1251
+ style: getCyStyle(),
1252
+ layout: getLayoutConfig(),
1253
+ wheelSensitivity: 0.3,
1254
+ minZoom: 0.1,
1255
+ maxZoom: 4,
1256
+ boxSelectionEnabled: false,
1257
+ });
1178
1258
 
1179
- ctx.beginPath();
1180
- ctx.moveTo(s.x, s.y);
1181
- ctx.quadraticCurveTo(mx + ox, my + oy, t2.x, t2.y);
1259
+ // --- Event handlers ---
1260
+ cy.on('tap', 'node', function (evt) {
1261
+ const node = evt.target;
1262
+ selectedNodeId = node.id();
1263
+ showInspector(node.id());
1264
+ });
1182
1265
 
1183
- // Fix: avoid degenerate gradient when endpoints overlap
1184
- let edgeStyle;
1185
- if (edgeLen < 2) {
1186
- edgeStyle = hexRGBA(edge.source.color, isActive ? 1.0 : 0.7);
1187
- } else {
1188
- const grad = ctx.createLinearGradient(s.x, s.y, t2.x, t2.y);
1189
- if (isActive) {
1190
- grad.addColorStop(0, hexRGBA(edge.source.color, 1.0));
1191
- grad.addColorStop(1, hexRGBA(edge.target.color, 1.0));
1192
- } else {
1193
- grad.addColorStop(0, hexRGBA(edge.source.color, 0.7));
1194
- grad.addColorStop(1, hexRGBA(edge.target.color, 0.7));
1195
- }
1196
- edgeStyle = grad;
1197
- }
1198
- ctx.strokeStyle = edgeStyle;
1199
- ctx.lineWidth = isActive ? 3.0 * cam.zoom : Math.max(1.5, 2.0 * cam.zoom);
1200
- ctx.stroke();
1201
-
1202
- // Active edge: label
1203
- if (isActive) {
1204
- ctx.font = `500 ${Math.max(9, 10 * cam.zoom)}px Inter, sans-serif`;
1205
- ctx.fillStyle = 'rgba(255,255,255,0.6)';
1206
- ctx.textAlign = 'center';
1207
- ctx.fillText(edge.type, mx + ox, my + oy - 6 * cam.zoom);
1266
+ cy.on('tap', function (evt) {
1267
+ if (evt.target === cy) {
1268
+ selectedNodeId = null;
1269
+ showInspector(null);
1208
1270
  }
1209
- ctx.globalAlpha = 1;
1210
- }
1271
+ });
1211
1272
 
1212
- // --- Breathing time variable ---
1213
- const breathT = Date.now() * 0.001;
1214
-
1215
- // --- Nodes: solid with breathing glow (zditor-style) ---
1216
- for (const node of nodes) {
1217
- const active = node === hoveredNode || node === selectedNode;
1218
- const p = worldToScreen(node.x, node.y);
1219
- const r = node.radius * cam.zoom;
1220
-
1221
- if (p.x + r * 5 < 0 || p.x - r * 5 > W || p.y + r * 5 < 0 || p.y - r * 5 > H) continue;
1222
- if (node._dimmed) { ctx.globalAlpha = 0.06; }
1223
-
1224
- // --- Breathing outer glow (pulsing nebula effect) ---
1225
- const breathPhase = Math.sin(breathT * 1.2 + node.x * 0.01 + node.y * 0.01);
1226
- const breathScale = 0.85 + 0.15 * breathPhase;
1227
- const glowAlpha = active ? 0.4 : 0.18 * breathScale;
1228
- const glowR = r * (active ? 4.0 : 3.0) * breathScale;
1229
- const glow = ctx.createRadialGradient(p.x, p.y, r * 0.2, p.x, p.y, glowR);
1230
- glow.addColorStop(0, hexRGBA(node.color, glowAlpha));
1231
- glow.addColorStop(0.5, hexRGBA(node.color, glowAlpha * 0.3));
1232
- glow.addColorStop(1, hexRGBA(node.color, 0));
1233
- ctx.beginPath();
1234
- ctx.arc(p.x, p.y, glowR, 0, Math.PI * 2);
1235
- ctx.fillStyle = glow;
1236
- ctx.fill();
1237
-
1238
- // --- Solid filled node ---
1239
- ctx.beginPath();
1240
- ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
1241
- ctx.fillStyle = node.color;
1242
- ctx.fill();
1243
-
1244
- // Specular highlight
1245
- if (r > 3) {
1246
- const spec = ctx.createRadialGradient(
1247
- p.x - r * 0.3, p.y - r * 0.3, 0,
1248
- p.x, p.y, r * 0.9
1249
- );
1250
- spec.addColorStop(0, 'rgba(255,255,255,0.35)');
1251
- spec.addColorStop(0.4, 'rgba(255,255,255,0.08)');
1252
- spec.addColorStop(1, 'rgba(255,255,255,0)');
1253
- ctx.beginPath();
1254
- ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
1255
- ctx.fillStyle = spec;
1256
- ctx.fill();
1257
- }
1273
+ let hoverNode = null;
1274
+ cy.on('mouseover', 'node', function (evt) {
1275
+ const node = evt.target;
1276
+ hoverNode = node;
1277
+ node.addClass('hover');
1278
+ // Show label on hover for all connected edges
1279
+ node.connectedEdges().addClass('hover');
1280
+ });
1281
+ cy.on('mouseout', 'node', function (evt) {
1282
+ const node = evt.target;
1283
+ if (hoverNode === node) hoverNode = null;
1284
+ node.removeClass('hover');
1285
+ node.connectedEdges().removeClass('hover');
1286
+ });
1258
1287
 
1259
- // --- Labels: BELOW the node, only on hover/select (zditor-style) ---
1260
- const showLabel = active || cam.zoom > 1.8;
1288
+ // Double-click to refocus
1289
+ cy.on('dbltap', 'node', function (evt) {
1290
+ focusEntity = evt.target.id();
1291
+ scope = 'neighborhood';
1292
+ rebuildGraph();
1293
+ });
1261
1294
 
1262
- if (showLabel) {
1263
- const baseFontSize = active ? 14 : 10;
1264
- const fontSize = Math.max(8, baseFontSize * cam.zoom);
1265
- ctx.font = `${active ? '600' : '500'} ${fontSize}px Inter, sans-serif`;
1266
- ctx.textAlign = 'center';
1267
- ctx.textBaseline = 'top';
1295
+ updateStatusBar(visibleCount, edgeCount);
1296
+ }
1268
1297
 
1269
- const labelText = node.id.length > 20 && !active ? node.id.slice(0, 18) + '\u2026' : node.id;
1270
- const labelY = p.y + r + 5 * cam.zoom;
1298
+ function rebuildGraph() {
1299
+ initCytoscape();
1300
+ renderFilterPanel();
1301
+ renderIsolatedPanel();
1302
+ }
1271
1303
 
1272
- // Always white text (canvas bg is always dark)
1273
- ctx.fillStyle = '#FFFFFF';
1274
- ctx.fillText(labelText, p.x, labelY);
1275
- ctx.textBaseline = 'alphabetic';
1276
- }
1304
+ // --- Isolated Entities Inventory (not in graph canvas) ---
1305
+ function renderIsolatedPanel() {
1306
+ const panel = document.getElementById('graph-isolated-panel');
1307
+ if (!panel) return;
1277
1308
 
1278
- ctx.globalAlpha = 1;
1309
+ // Only show when scope is connected or full (not neighborhood)
1310
+ if (scope === 'neighborhood') {
1311
+ panel.style.display = 'none';
1312
+ return;
1279
1313
  }
1280
1314
 
1281
- // --- Zoom indicator ---
1282
- const zoomPct = Math.round(cam.zoom * 100);
1283
- if (zoomPct !== 100) {
1284
- ctx.font = '500 11px Inter, sans-serif';
1285
- ctx.fillStyle = 'rgba(255,255,255,0.25)';
1286
- ctx.textAlign = 'left';
1287
- ctx.textBaseline = 'alphabetic';
1288
- ctx.fillText(`${zoomPct}%`, 12, H - 12);
1315
+ const isolated = graph.entities.filter(e =>
1316
+ activeTypes.has(e.entityType) && (degreeMap[e.name] || 0) === 0
1317
+ );
1318
+
1319
+ if (isolated.length === 0) {
1320
+ panel.style.display = 'none';
1321
+ return;
1289
1322
  }
1290
1323
 
1291
- // Continuous loop handles redraw — no manual trigger needed
1292
- }
1324
+ // Group by entityType
1325
+ const groups = {};
1326
+ isolated.forEach(e => {
1327
+ (groups[e.entityType] = groups[e.entityType] || []).push(e);
1328
+ });
1293
1329
 
1294
- // --- Graph Panel (zditor-style tabs: Stats / Legend / Filter / Search) ---
1295
- const typeCount = {};
1296
- nodes.forEach(n => { typeCount[n.type] = (typeCount[n.type] || 0) + 1; });
1297
- Object.keys(typeCounts).forEach(t2 => getTypeColor(t2));
1330
+ const groupEntries = Object.entries(groups).sort((a, b) => b[1].length - a[1].length);
1298
1331
 
1299
- let activeFilterType = null;
1300
-
1301
- function renderPanelTab(tab) {
1302
- const content = document.getElementById('gp-content');
1303
- if (!content) return;
1304
-
1305
- if (tab === 'stats') {
1306
- const maxDeg = Math.max(1, ...nodes.map(n => n.degree));
1307
- const topNodes = [...nodes].sort((a, b) => b.degree - a.degree).slice(0, 5);
1308
- content.innerHTML = `
1309
- <div class="gp-stat-row"><span class="gp-stat-label">Nodes</span><span class="gp-stat-value">${nodes.length}</span></div>
1310
- <div class="gp-stat-row"><span class="gp-stat-label">Edges</span><span class="gp-stat-value">${edges.length}</span></div>
1311
- <div class="gp-stat-row"><span class="gp-stat-label">Types</span><span class="gp-stat-value">${Object.keys(typeCount).length}</span></div>
1312
- <div class="gp-stat-row"><span class="gp-stat-label">Density</span><span class="gp-stat-value">${nodes.length > 1 ? (2 * edges.length / (nodes.length * (nodes.length - 1))).toFixed(3) : '0'}</span></div>
1313
- <div style="margin-top:12px;padding-top:10px;border-top:1px solid rgba(255,255,255,0.04);">
1314
- <div style="font-size:10px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.8px;margin-bottom:8px;">Top Nodes</div>
1315
- ${topNodes.map(n => `
1316
- <div class="gp-legend-item" data-node-id="${escapeHtml(n.id)}">
1317
- <div class="gp-legend-dot" style="background:${n.color};box-shadow:0 0 6px ${n.color}60;"></div>
1318
- <span class="gp-legend-name">${escapeHtml(n.id)}</span>
1319
- <span class="gp-legend-count">${n.degree}</span>
1320
- </div>
1321
- `).join('')}
1322
- </div>
1323
- <div class="gp-zoom-controls">
1324
- <button class="gp-zoom-btn" id="gp-zoom-out">\u2212</button>
1325
- <button class="gp-zoom-btn" id="gp-zoom-reset">\u27F3</button>
1326
- <button class="gp-zoom-btn" id="gp-zoom-in">+</button>
1327
- </div>
1328
- `;
1329
- } else if (tab === 'legend') {
1330
- content.innerHTML = Object.entries(typeCount)
1331
- .sort((a, b) => b[1] - a[1])
1332
- .map(([type, count]) => `
1333
- <div class="gp-legend-item" data-legend-type="${escapeHtml(type)}">
1334
- <div class="gp-legend-dot" style="background:${typeColors[type] || '#666'};box-shadow:0 0 8px ${typeColors[type] || '#666'}40;"></div>
1335
- <span class="gp-legend-name">${escapeHtml(type)}</span>
1336
- <span class="gp-legend-count">${count}</span>
1337
- </div>
1338
- `).join('') + `
1339
- <div class="gp-zoom-controls">
1340
- <button class="gp-zoom-btn" id="gp-zoom-out">\u2212</button>
1341
- <button class="gp-zoom-btn" id="gp-zoom-reset">\u27F3</button>
1342
- <button class="gp-zoom-btn" id="gp-zoom-in">+</button>
1343
- </div>
1344
- `;
1345
- } else if (tab === 'filter') {
1346
- content.innerHTML = `
1347
- <div class="gp-legend-item${!activeFilterType ? ' active' : ''}" data-filter-type="" style="${!activeFilterType ? 'background:rgba(208,188,255,0.08)' : ''}">
1348
- <span class="gp-legend-name" style="font-weight:500;">Show All</span>
1349
- <span class="gp-legend-count">${nodes.length}</span>
1332
+ panel.style.display = 'block';
1333
+ panel.innerHTML = `
1334
+ <div class="panel" style="margin-top:16px;">
1335
+ <div class="panel-header">
1336
+ <span class="panel-title">Isolated Entities</span>
1337
+ <span style="font-size:11px;color:var(--text-muted);">${isolated.length} entities with no relations \u2014 shown separately for readability</span>
1350
1338
  </div>
1351
- ${Object.entries(typeCount).sort((a,b) => b[1] - a[1]).map(([type, count]) => `
1352
- <div class="gp-legend-item${activeFilterType === type ? ' active' : ''}" data-filter-type="${escapeHtml(type)}" style="${activeFilterType === type ? 'background:rgba(208,188,255,0.08)' : ''}">
1353
- <div class="gp-legend-dot" style="background:${typeColors[type] || '#666'};"></div>
1354
- <span class="gp-legend-name">${escapeHtml(type)}</span>
1355
- <span class="gp-legend-count">${count}</span>
1356
- </div>
1357
- `).join('')}
1358
- `;
1359
- } else if (tab === 'search') {
1360
- content.innerHTML = `
1361
- <input type="text" class="gp-search" id="gp-search-input" placeholder="Start typing to search nodes" autocomplete="off" />
1362
- <div id="gp-search-results" style="margin-top:10px;"></div>
1363
- `;
1364
- const input = document.getElementById('gp-search-input');
1365
- if (input) {
1366
- input.focus();
1367
- input.addEventListener('input', () => {
1368
- const q = input.value.toLowerCase();
1369
- const results = document.getElementById('gp-search-results');
1370
- if (!q) { results.innerHTML = ''; return; }
1371
- const matches = nodes.filter(n => n.id.toLowerCase().includes(q) || n.type.toLowerCase().includes(q)).slice(0, 12);
1372
- results.innerHTML = matches.length === 0
1373
- ? '<div style="font-size:12px;color:var(--text-muted);padding:8px 0;">No matches</div>'
1374
- : matches.map(n => `
1375
- <div class="gp-legend-item" data-node-id="${escapeHtml(n.id)}">
1376
- <div class="gp-legend-dot" style="background:${n.color};box-shadow:0 0 6px ${n.color}60;"></div>
1377
- <span class="gp-legend-name">${escapeHtml(n.id)}</span>
1378
- <span class="gp-legend-count">${n.type}</span>
1339
+ <div class="panel-body" style="padding:12px 16px;">
1340
+ ${groupEntries.map(([type, entities]) => {
1341
+ const color = getTypeColor(type);
1342
+ const collapsed = entities.length > 8;
1343
+ const shown = collapsed ? entities.slice(0, 8) : entities;
1344
+ return `
1345
+ <div style="margin-bottom:12px;">
1346
+ <div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;">
1347
+ <span style="width:8px;height:8px;border-radius:50%;background:${color};flex-shrink:0;"></span>
1348
+ <span style="font-size:11px;font-weight:600;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.5px;">${escapeHtml(type)}</span>
1349
+ <span style="font-size:10px;color:var(--text-muted);font-family:var(--font-mono);">${entities.length}</span>
1350
+ </div>
1351
+ <div style="display:flex;flex-wrap:wrap;gap:4px;">
1352
+ ${shown.map(e => `
1353
+ <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;">
1354
+ ${escapeHtml(e.name.length > 28 ? e.name.slice(0, 26) + '\u2026' : e.name)}
1355
+ ${e.observations.length > 0 ? '<span style="font-size:9px;color:var(--text-muted);">' + e.observations.length + '</span>' : ''}
1356
+ </span>
1357
+ `).join('')}
1358
+ ${collapsed ? '<span style="font-size:11px;color:var(--text-muted);padding:3px 8px;">+' + (entities.length - 8) + ' more</span>' : ''}
1359
+ </div>
1379
1360
  </div>
1380
- `).join('');
1381
- bindNodeClicks();
1382
- });
1383
- }
1384
- }
1385
-
1386
- // Bind event handlers
1387
- bindNodeClicks();
1388
- bindLegendHovers();
1389
- bindFilterClicks();
1390
- bindZoomControls();
1391
- }
1361
+ `;
1362
+ }).join('')}
1363
+ </div>
1364
+ </div>
1365
+ `;
1392
1366
 
1393
- function bindNodeClicks() {
1394
- document.querySelectorAll('[data-node-id]').forEach(el => {
1367
+ // Bind clicks: inspect isolated entity
1368
+ panel.querySelectorAll('[data-iso-entity]').forEach(el => {
1395
1369
  el.addEventListener('click', () => {
1396
- const node = nodes.find(n => n.id === el.dataset.nodeId);
1397
- if (node) {
1398
- selectedNode = node;
1399
- cam.x = node.x;
1400
- cam.y = node.y;
1401
- cam.zoom = Math.max(cam.zoom, 1);
1402
- showDetail(node);
1403
- draw();
1370
+ const name = el.dataset.isoEntity;
1371
+ if (entityMap[name]) {
1372
+ selectedNodeId = name;
1373
+ showInspector(name);
1404
1374
  }
1405
1375
  });
1406
1376
  });
1407
1377
  }
1408
1378
 
1409
- function bindLegendHovers() {
1410
- document.querySelectorAll('[data-legend-type]').forEach(el => {
1411
- el.addEventListener('mouseenter', () => {
1412
- const type = el.dataset.legendType;
1413
- nodes.forEach(n => { n._dimmed = n.type !== type; });
1414
- draw();
1415
- });
1416
- el.addEventListener('mouseleave', () => {
1417
- nodes.forEach(n => { n._dimmed = false; });
1418
- draw();
1419
- });
1420
- });
1421
- }
1379
+ // --- Inspector ---
1380
+ function showInspector(nodeId) {
1381
+ const inspector = document.getElementById('graph-inspector');
1382
+ if (!inspector) return;
1383
+ if (!nodeId || !entityMap[nodeId]) {
1384
+ inspector.innerHTML = '<div class="gi-empty"><div class="gi-empty-icon">\u2B21</div>Select a node to inspect</div>';
1385
+ return;
1386
+ }
1387
+ const entity = entityMap[nodeId];
1388
+ const related = graph.relations.filter(r => r.from === nodeId || r.to === nodeId);
1389
+ const deg = degreeMap[nodeId] || 0;
1390
+ const color = getTypeColor(entity.entityType);
1391
+
1392
+ const obsHtml = entity.observations.length > 0
1393
+ ? entity.observations.map(o => `<div class="gi-obs-item">${escapeHtml(o)}</div>`).join('')
1394
+ : '<div style="font-size:12px;color:var(--text-muted);font-style:italic;">No observations</div>';
1395
+ const relHtml = related.length > 0
1396
+ ? related.map(r => {
1397
+ const dir = r.from === nodeId;
1398
+ const other = dir ? r.to : r.from;
1399
+ return `<div class="gi-rel-item">
1400
+ <span class="gi-rel-arrow">${dir ? '\u2192' : '\u2190'}</span>
1401
+ <span class="gi-rel-type">${escapeHtml(r.relationType)}</span>
1402
+ <span class="gi-rel-target" data-inspector-nav="${escapeHtml(other)}">${escapeHtml(other)}</span>
1403
+ </div>`;
1404
+ }).join('')
1405
+ : '<div style="font-size:12px;color:var(--text-muted);font-style:italic;">No relations</div>';
1422
1406
 
1423
- function bindFilterClicks() {
1424
- document.querySelectorAll('[data-filter-type]').forEach(el => {
1407
+ inspector.innerHTML = `
1408
+ <div class="gi-header">
1409
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
1410
+ <span style="width:10px;height:10px;border-radius:50%;background:${color};flex-shrink:0;"></span>
1411
+ <div class="gi-name">${escapeHtml(nodeId)}</div>
1412
+ </div>
1413
+ <div class="gi-type">${escapeHtml(entity.entityType)}</div>
1414
+ </div>
1415
+ <div class="gi-stats">
1416
+ <div class="gi-stat"><div class="gi-stat-value">${deg}</div><div class="gi-stat-label">Connections</div></div>
1417
+ <div class="gi-stat"><div class="gi-stat-value">${entity.observations.length}</div><div class="gi-stat-label">Evidence</div></div>
1418
+ </div>
1419
+ <div class="gi-section">
1420
+ <div class="gi-section-title">Observations <span class="gi-section-count">${entity.observations.length}</span></div>
1421
+ ${obsHtml}
1422
+ </div>
1423
+ <div class="gi-section">
1424
+ <div class="gi-section-title">Relations <span class="gi-section-count">${related.length}</span></div>
1425
+ ${relHtml}
1426
+ </div>
1427
+ `;
1428
+
1429
+ // Navigation: click relation target to focus
1430
+ inspector.querySelectorAll('[data-inspector-nav]').forEach(el => {
1425
1431
  el.addEventListener('click', () => {
1426
- const type = el.dataset.filterType || null;
1427
- activeFilterType = type;
1428
- nodes.forEach(n => { n._dimmed = type ? n.type !== type : false; });
1429
- draw();
1430
- renderPanelTab('filter');
1432
+ const targetId = el.dataset.inspectorNav;
1433
+ if (entityMap[targetId]) {
1434
+ selectedNodeId = targetId;
1435
+ // If target is visible in current graph, select it
1436
+ if (cy && cy.$id(targetId).length > 0) {
1437
+ cy.$(':selected').unselect();
1438
+ cy.$id(targetId).select();
1439
+ cy.animate({ center: { eles: cy.$id(targetId) }, duration: 300 });
1440
+ } else {
1441
+ // Switch focus to target
1442
+ focusEntity = targetId;
1443
+ scope = 'neighborhood';
1444
+ rebuildGraph();
1445
+ }
1446
+ showInspector(targetId);
1447
+ }
1431
1448
  });
1432
1449
  });
1433
1450
  }
1434
1451
 
1435
- function bindZoomControls() {
1436
- const zi = document.getElementById('gp-zoom-in');
1437
- const zo = document.getElementById('gp-zoom-out');
1438
- const zr = document.getElementById('gp-zoom-reset');
1439
- if (zi) zi.onclick = () => { cam.zoom = Math.min(cam.zoom * 1.3, 4); draw(); };
1440
- if (zo) zo.onclick = () => { cam.zoom = Math.max(cam.zoom / 1.3, 0.2); draw(); };
1441
- if (zr) zr.onclick = () => { cam = { x: 0, y: 0, zoom: 1 }; draw(); };
1442
- }
1452
+ // --- Filter panel ---
1453
+ function renderFilterPanel() {
1454
+ const panel = document.getElementById('graph-filter-panel');
1455
+ if (!panel) return;
1443
1456
 
1444
- // Tab switching
1445
- document.querySelectorAll('.gp-tab').forEach(tab => {
1446
- tab.addEventListener('click', () => {
1447
- document.querySelectorAll('.gp-tab').forEach(t2 => t2.classList.remove('active'));
1448
- tab.classList.add('active');
1449
- renderPanelTab(tab.dataset.gptab);
1450
- });
1451
- });
1457
+ const searchHtml = `
1458
+ <div class="gfp-section">
1459
+ <div class="gfp-label">Search</div>
1460
+ <input type="text" class="gfp-search" id="gfp-search" placeholder="Find entity..." autocomplete="off" />
1461
+ </div>
1462
+ `;
1452
1463
 
1453
- renderPanelTab('stats');
1464
+ const scopeHtml = `
1465
+ <div class="gfp-section">
1466
+ <div class="gfp-label">Scope</div>
1467
+ <div class="gfp-radio-group">
1468
+ <button class="gfp-radio${scope === 'connected' ? ' active' : ''}" data-scope="connected">
1469
+ <span class="gfp-radio-dot"></span> Connected
1470
+ </button>
1471
+ <button class="gfp-radio${scope === 'neighborhood' ? ' active' : ''}" data-scope="neighborhood">
1472
+ <span class="gfp-radio-dot"></span> Neighborhood
1473
+ </button>
1474
+ <button class="gfp-radio${scope === 'full' ? ' active' : ''}" data-scope="full">
1475
+ <span class="gfp-radio-dot"></span> Full Graph
1476
+ </button>
1477
+ </div>
1478
+ ${isSparse ? `<div style="font-size:10px;color:var(--accent-amber);margin-top:6px;line-height:1.4;">\u26A0 Sparse graph: ${isolatedCount} of ${graph.entities.length} entities have no relations. Isolated entities shown in inventory below.</div>` : ''}
1479
+ </div>
1480
+ `;
1454
1481
 
1455
- function showDetail(node) {
1456
- const drawer = document.getElementById('graph-detail-drawer');
1457
- if (!drawer) return;
1458
- if (!node) {
1459
- drawer.classList.remove('open');
1460
- return;
1461
- }
1462
- const related = edges.filter(e => e.source === node || e.target === node);
1463
- const obsHtml = node.observations.length > 0
1464
- ? node.observations.map(o => `<div class="graph-obs-item">${escapeHtml(o)}</div>`).join('')
1465
- : `<div class="graph-detail-muted">${t('noObservations') || 'No observations'}</div>`;
1466
- const relHtml = related.length > 0
1467
- ? related.map(e => {
1468
- const dir = e.source === node;
1469
- const other = dir ? e.target : e.source;
1470
- return `<div class="graph-rel-item"><span class="graph-rel-arrow">${dir ? '\u2192' : '\u2190'}</span> <span class="graph-rel-type">${escapeHtml(e.type)}</span> <strong>${escapeHtml(other.id)}</strong></div>`;
1471
- }).join('')
1472
- : `<div class="graph-detail-muted">${t('noRelations') || 'No relations'}</div>`;
1473
-
1474
- drawer.innerHTML = `
1475
- <div class="graph-detail-header">
1476
- <div class="graph-detail-dot" style="background:${node.color};box-shadow:0 0 12px ${hexRGBA(node.color, 0.5)}"></div>
1477
- <div style="flex:1;">
1478
- <div class="graph-detail-name">${escapeHtml(node.id)}</div>
1479
- <div class="graph-detail-type">${escapeHtml(node.type)}</div>
1482
+ const depthHtml = `
1483
+ <div class="gfp-section" id="gfp-depth-section"${scope !== 'neighborhood' ? ' style="display:none"' : ''}>
1484
+ <div class="gfp-label">Depth</div>
1485
+ <div class="gfp-depth-row">
1486
+ <button class="gfp-depth-btn${depth === 1 ? ' active' : ''}" data-depth="1">1</button>
1487
+ <button class="gfp-depth-btn${depth === 2 ? ' active' : ''}" data-depth="2">2</button>
1488
+ <button class="gfp-depth-btn${depth === 3 ? ' active' : ''}" data-depth="3">3</button>
1480
1489
  </div>
1481
- <button onclick="document.getElementById('graph-detail-drawer').classList.remove('open')" style="background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:18px;padding:4px 8px;">\u2715</button>
1482
1490
  </div>
1483
- <div style="display:flex;gap:0;">
1484
- <div class="graph-detail-section" style="flex:1;border-right:1px solid rgba(255,255,255,0.04);">
1485
- <h3>${t('observations')} <span class="graph-detail-count">${node.observations.length}</span></h3>
1486
- ${obsHtml}
1491
+ `;
1492
+
1493
+ const viewHtml = `
1494
+ <div class="gfp-section">
1495
+ <div class="gfp-label">View</div>
1496
+ <div class="gfp-radio-group">
1497
+ <button class="gfp-radio${currentView === 'topology' ? ' active' : ''}" data-view="topology">
1498
+ <span class="gfp-radio-dot"></span> Topology
1499
+ </button>
1500
+ <button class="gfp-radio${currentView === 'table' ? ' active' : ''}" data-view="table">
1501
+ <span class="gfp-radio-dot"></span> Table
1502
+ </button>
1487
1503
  </div>
1488
- <div class="graph-detail-section" style="flex:1;">
1489
- <h3>${t('relations')} <span class="graph-detail-count">${related.length}</span></h3>
1490
- ${relHtml}
1504
+ </div>
1505
+ `;
1506
+
1507
+ const layoutHtml = `
1508
+ <div class="gfp-section" id="gfp-layout-section"${currentView === 'table' ? ' style="display:none"' : ''}>
1509
+ <div class="gfp-label">Layout</div>
1510
+ <div class="gfp-radio-group">
1511
+ <button class="gfp-radio${currentLayout === 'dagre-lr' ? ' active' : ''}" data-layout="dagre-lr">
1512
+ <span class="gfp-radio-dot"></span> Left \u2192 Right
1513
+ </button>
1514
+ <button class="gfp-radio${currentLayout === 'dagre-tb' ? ' active' : ''}" data-layout="dagre-tb">
1515
+ <span class="gfp-radio-dot"></span> Top \u2192 Bottom
1516
+ </button>
1491
1517
  </div>
1492
1518
  </div>
1493
1519
  `;
1494
- drawer.classList.add('open');
1495
- }
1496
1520
 
1497
- // --- Animation loop stops physics when settled, breathing is visual-only ---
1498
- let animFrame = null;
1499
- function tick() {
1500
- if (!isSettled) {
1501
- simulate();
1502
- }
1503
- draw();
1504
- animFrame = requestAnimationFrame(tick);
1505
- }
1521
+ const typeEntries = Object.entries(typeCounts).sort((a, b) => b[1] - a[1]);
1522
+ const filterHtml = `
1523
+ <div class="gfp-section">
1524
+ <div class="gfp-label">Entity Type</div>
1525
+ <div class="gfp-radio-group">
1526
+ ${typeEntries.map(([type, count]) => `
1527
+ <button class="gfp-check${activeTypes.has(type) ? ' active' : ''}" data-type-filter="${escapeHtml(type)}">
1528
+ <span class="gfp-check-box">\u2713</span>
1529
+ <span class="gfp-type-dot" style="background:${typeColors[type]}"></span>
1530
+ ${escapeHtml(type)}
1531
+ <span class="gfp-check-count">${count}</span>
1532
+ </button>
1533
+ `).join('')}
1534
+ </div>
1535
+ </div>
1536
+ `;
1506
1537
 
1507
- function wakeUp() {
1508
- isSettled = false;
1509
- settleCountdown = 0;
1510
- nodes.forEach(n => {
1511
- n.vx += (Math.random() - 0.5) * 0.5;
1512
- n.vy += (Math.random() - 0.5) * 0.5;
1538
+ panel.innerHTML = searchHtml + scopeHtml + depthHtml + viewHtml + layoutHtml + filterHtml;
1539
+
1540
+ // Bind scope
1541
+ panel.querySelectorAll('[data-scope]').forEach(btn => {
1542
+ btn.addEventListener('click', () => {
1543
+ scope = btn.dataset.scope;
1544
+ rebuildGraph();
1545
+ });
1513
1546
  });
1514
- }
1515
1547
 
1516
- // --- Mouse interaction ---
1517
- function getMouseWorld(e) {
1518
- const r = canvas.getBoundingClientRect();
1519
- return screenToWorld(e.clientX - r.left, e.clientY - r.top);
1520
- }
1548
+ // Bind depth
1549
+ panel.querySelectorAll('[data-depth]').forEach(btn => {
1550
+ btn.addEventListener('click', () => {
1551
+ depth = parseInt(btn.dataset.depth);
1552
+ rebuildGraph();
1553
+ });
1554
+ });
1521
1555
 
1522
- canvas.addEventListener('mousemove', (e) => {
1523
- const r = canvas.getBoundingClientRect();
1524
- const sx = e.clientX - r.left, sy = e.clientY - r.top;
1556
+ // Bind view
1557
+ panel.querySelectorAll('[data-view]').forEach(btn => {
1558
+ btn.addEventListener('click', () => {
1559
+ currentView = btn.dataset.view;
1560
+ switchView();
1561
+ renderFilterPanel();
1562
+ });
1563
+ });
1525
1564
 
1526
- // Panning
1527
- if (panStart) {
1528
- cam.x -= (e.movementX) / cam.zoom;
1529
- cam.y -= (e.movementY) / cam.zoom;
1530
- draw();
1531
- return;
1532
- }
1565
+ // Bind layout
1566
+ panel.querySelectorAll('[data-layout]').forEach(btn => {
1567
+ btn.addEventListener('click', () => {
1568
+ currentLayout = btn.dataset.layout;
1569
+ if (cy) {
1570
+ cy.layout(getLayoutConfig()).run();
1571
+ }
1572
+ renderFilterPanel();
1573
+ });
1574
+ });
1533
1575
 
1534
- // Dragging node
1535
- if (dragNode) {
1536
- const w = screenToWorld(sx, sy);
1537
- dragNode.x = w.x; dragNode.y = w.y;
1538
- dragNode.vx = 0; dragNode.vy = 0;
1539
- draw();
1540
- return;
1541
- }
1576
+ // Bind type filters
1577
+ panel.querySelectorAll('[data-type-filter]').forEach(btn => {
1578
+ btn.addEventListener('click', () => {
1579
+ const type = btn.dataset.typeFilter;
1580
+ if (activeTypes.has(type)) activeTypes.delete(type);
1581
+ else activeTypes.add(type);
1582
+ rebuildGraph();
1583
+ });
1584
+ });
1542
1585
 
1543
- // Hit test
1544
- const w = screenToWorld(sx, sy);
1545
- let found = null;
1546
- for (const node of nodes) {
1547
- const dx = w.x - node.x, dy = w.y - node.y;
1548
- if (dx * dx + dy * dy < (node.radius + 4) * (node.radius + 4)) { found = node; break; }
1549
- }
1550
- if (found !== hoveredNode) {
1551
- hoveredNode = found;
1552
- canvas.style.cursor = found ? 'pointer' : 'grab';
1553
- if (found) {
1554
- const tt = document.getElementById('graph-tooltip');
1555
- tt.querySelector('.graph-tooltip-name').textContent = found.id;
1556
- tt.querySelector('.graph-tooltip-type').textContent = `${found.type} · ${found.observations.length} ${t('observation_s')}`;
1557
- tt.style.left = (sx + 16) + 'px';
1558
- tt.style.top = (sy - 20) + 'px';
1559
- tt.classList.add('visible');
1560
- } else {
1561
- document.getElementById('graph-tooltip').classList.remove('visible');
1562
- }
1563
- draw();
1586
+ // Bind search — focus on entity and navigate
1587
+ const searchInput = document.getElementById('gfp-search');
1588
+ if (searchInput) {
1589
+ searchInput.addEventListener('input', () => {
1590
+ const q = searchInput.value.toLowerCase();
1591
+ if (!q || !cy) {
1592
+ if (cy) cy.elements().removeClass('dimmed');
1593
+ return;
1594
+ }
1595
+ cy.nodes().forEach(n => {
1596
+ const match = n.data('fullLabel').toLowerCase().includes(q) || n.data('type').toLowerCase().includes(q);
1597
+ if (match) { n.removeClass('dimmed'); } else { n.addClass('dimmed'); }
1598
+ });
1599
+ cy.edges().forEach(e => {
1600
+ if (e.source().hasClass('dimmed') && e.target().hasClass('dimmed')) e.addClass('dimmed');
1601
+ else e.removeClass('dimmed');
1602
+ });
1603
+ });
1604
+
1605
+ searchInput.addEventListener('keydown', (e) => {
1606
+ if (e.key === 'Enter') {
1607
+ const q = searchInput.value.toLowerCase();
1608
+ const match = graph.entities.find(ent => ent.name.toLowerCase().includes(q));
1609
+ if (match) {
1610
+ focusEntity = match.name;
1611
+ scope = 'neighborhood';
1612
+ rebuildGraph();
1613
+ }
1614
+ }
1615
+ });
1564
1616
  }
1565
- });
1617
+ }
1566
1618
 
1567
- canvas.addEventListener('mousedown', (e) => {
1568
- if (hoveredNode) {
1569
- dragNode = hoveredNode;
1570
- canvas.style.cursor = 'grabbing';
1619
+ function switchView() {
1620
+ const graphContainer = document.getElementById('graph-container');
1621
+ const tableContainer = document.getElementById('graph-table-container');
1622
+ if (currentView === 'table') {
1623
+ graphContainer.style.display = 'none';
1624
+ tableContainer.style.display = 'flex';
1625
+ renderTable();
1571
1626
  } else {
1572
- panStart = { x: e.clientX, y: e.clientY };
1573
- canvas.style.cursor = 'grabbing';
1627
+ graphContainer.style.display = '';
1628
+ tableContainer.style.display = 'none';
1574
1629
  }
1575
- });
1576
-
1577
- canvas.addEventListener('mouseup', () => {
1578
- if (dragNode) { dragNode = null; canvas.style.cursor = hoveredNode ? 'pointer' : 'grab'; wakeUp(); }
1579
- if (panStart) { panStart = null; canvas.style.cursor = hoveredNode ? 'pointer' : 'grab'; }
1580
- });
1630
+ }
1581
1631
 
1582
- canvas.addEventListener('click', (e) => {
1583
- if (hoveredNode) {
1584
- selectedNode = hoveredNode;
1585
- showDetail(selectedNode);
1586
- wakeUp();
1632
+ // --- Table view ---
1633
+ function renderTable() {
1634
+ const tc = document.getElementById('graph-table-container');
1635
+ if (!tc) return;
1636
+ let entities;
1637
+ if (scope === 'full') {
1638
+ entities = graph.entities.filter(e => activeTypes.has(e.entityType) && (degreeMap[e.name] || 0) > 0);
1639
+ } else if (scope === 'neighborhood') {
1640
+ const sub = getNeighborhood(focusEntity, depth);
1641
+ entities = [...sub.nodeNames].filter(n => activeTypes.has(entityMap[n]?.entityType)).map(n => entityMap[n]).filter(Boolean);
1587
1642
  } else {
1588
- // Click on empty space: deselect and close drawer
1589
- selectedNode = null;
1590
- showDetail(null);
1591
- draw();
1643
+ // connected: only degree > 0
1644
+ entities = graph.entities.filter(e => activeTypes.has(e.entityType) && (degreeMap[e.name] || 0) > 0);
1592
1645
  }
1593
- });
1646
+ const sorted = entities.sort((a, b) => (degreeMap[b.name] || 0) - (degreeMap[a.name] || 0));
1647
+ tc.innerHTML = `
1648
+ <table class="graph-table">
1649
+ <thead>
1650
+ <tr>
1651
+ <th>Entity</th>
1652
+ <th>Type</th>
1653
+ <th>Connections</th>
1654
+ <th>Observations</th>
1655
+ </tr>
1656
+ </thead>
1657
+ <tbody>
1658
+ ${sorted.map(e => `
1659
+ <tr data-table-node="${escapeHtml(e.name)}">
1660
+ <td class="entity-name"><span class="entity-type-dot" style="background:${getTypeColor(e.entityType)}"></span>${escapeHtml(e.name)}</td>
1661
+ <td>${escapeHtml(e.entityType)}</td>
1662
+ <td>${degreeMap[e.name] || 0}</td>
1663
+ <td>${e.observations.length}</td>
1664
+ </tr>
1665
+ `).join('')}
1666
+ </tbody>
1667
+ </table>
1668
+ `;
1669
+ tc.querySelectorAll('[data-table-node]').forEach(row => {
1670
+ row.addEventListener('click', () => {
1671
+ selectedNodeId = row.dataset.tableNode;
1672
+ showInspector(selectedNodeId);
1673
+ });
1674
+ });
1675
+ }
1594
1676
 
1595
- canvas.addEventListener('mouseleave', () => {
1596
- hoveredNode = null; dragNode = null; panStart = null;
1597
- document.getElementById('graph-tooltip').classList.remove('visible');
1598
- draw();
1599
- });
1677
+ // --- Status bar ---
1678
+ function updateStatusBar(nodeCount, edgeCount) {
1679
+ const gsNodes = document.getElementById('gs-nodes');
1680
+ const gsEdges = document.getElementById('gs-edges');
1681
+ const gsLayout = document.getElementById('gs-layout');
1682
+ const gsScope = document.getElementById('gs-scope');
1683
+ if (gsNodes) gsNodes.textContent = `${nodeCount || 0} nodes`;
1684
+ if (gsEdges) gsEdges.textContent = `${edgeCount || 0} edges`;
1685
+ if (gsLayout) gsLayout.textContent = currentLayout === 'dagre-tb' ? 'TB' : 'LR';
1686
+ if (gsScope) gsScope.textContent = scope === 'full' ? 'full' : scope === 'neighborhood' ? `${depth}-hop` : 'connected';
1687
+ if (isolatedCount > 0 && scope !== 'neighborhood') {
1688
+ if (gsScope) gsScope.textContent += ` · ${isolatedCount} isolated below`;
1689
+ }
1690
+ }
1600
1691
 
1601
- // Zoom with mouse wheel
1602
- canvas.addEventListener('wheel', (e) => {
1603
- e.preventDefault();
1604
- const factor = e.deltaY > 0 ? 0.9 : 1.1;
1605
- const newZoom = Math.max(0.15, Math.min(cam.zoom * factor, 5));
1606
- // Zoom toward mouse position
1607
- const r = canvas.getBoundingClientRect();
1608
- const mx = e.clientX - r.left, my = e.clientY - r.top;
1609
- const wx = (mx - W / 2) / cam.zoom + cam.x;
1610
- const wy = (my - H / 2) / cam.zoom + cam.y;
1611
- cam.zoom = newZoom;
1612
- cam.x = wx - (mx - W / 2) / cam.zoom;
1613
- cam.y = wy - (my - H / 2) / cam.zoom;
1614
- draw();
1615
- }, { passive: false });
1616
-
1617
- // Start with a slight zoom-out for large graphs
1618
- if (nodes.length > 60) cam.zoom = 0.55;
1619
- else if (nodes.length > 30) cam.zoom = 0.7;
1620
-
1621
- canvas.style.cursor = 'grab';
1622
- tick();
1623
- // Continuous loop — no timeout needed
1692
+ // --- Zoom controls ---
1693
+ const gzIn = document.getElementById('gz-in');
1694
+ const gzOut = document.getElementById('gz-out');
1695
+ const gzFit = document.getElementById('gz-fit');
1696
+ if (gzIn) gzIn.onclick = () => { if (cy) cy.zoom(cy.zoom() * 1.3); };
1697
+ if (gzOut) gzOut.onclick = () => { if (cy) cy.zoom(cy.zoom() / 1.3); };
1698
+ if (gzFit) gzFit.onclick = () => { if (cy) cy.fit(undefined, 30); };
1699
+
1700
+ // --- Initialize ---
1701
+ _graphState = { graph, entityMap, degreeMap, typeColors, showInspector };
1702
+ initCytoscape();
1703
+ renderFilterPanel();
1704
+ renderIsolatedPanel();
1624
1705
  }
1625
1706
 
1626
1707
  // ============================================================
@@ -2461,13 +2542,15 @@ function teamLockTTL(expiresAt) {
2461
2542
  return min + 'm left';
2462
2543
  }
2463
2544
 
2545
+ let teamScope = 'project'; // 'project' | 'global'
2546
+
2464
2547
  async function loadTeam() {
2465
2548
  const container = document.getElementById('page-team');
2466
2549
  if (!container.innerHTML || container.innerHTML.includes('spinner')) {
2467
2550
  container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
2468
2551
  }
2469
2552
 
2470
- const data = await api('team');
2553
+ const data = await api('team?scope=' + teamScope);
2471
2554
  if (!data || data.unavailable) {
2472
2555
  container.innerHTML = `
2473
2556
  <div class="page-header">
@@ -2502,6 +2585,11 @@ async function loadTeam() {
2502
2585
  const tasksByStatus = { pending: 0, in_progress: 0, completed: 0, failed: 0 };
2503
2586
  data.tasks.forEach(tk => { tasksByStatus[tk.status] = (tasksByStatus[tk.status] || 0) + 1; });
2504
2587
 
2588
+ const scopeLabel = teamScope === 'global' ? 'Global Team' : 'Project Team';
2589
+ const scopeDesc = teamScope === 'global'
2590
+ ? 'All agents across projects (runtime + persisted)'
2591
+ : 'Agents active in current control-plane session';
2592
+
2505
2593
  let html = `
2506
2594
  <div class="team-header">
2507
2595
  <div class="team-header-left">
@@ -2509,11 +2597,15 @@ async function loadTeam() {
2509
2597
  <span class="iconify" data-icon="lucide:users"></span>
2510
2598
  </div>
2511
2599
  <div>
2512
- <h1 class="page-title">${t('teamTitle')}</h1>
2513
- <p class="page-subtitle">${t('teamSubtitle')}${data.sessions != null ? ' &middot; ' + data.sessions + ' session(s)' : ''}</p>
2600
+ <h1 class="page-title">${scopeLabel}</h1>
2601
+ <p class="page-subtitle">${scopeDesc}${data.sessions != null ? ' &middot; ' + data.sessions + ' session(s)' : ''}</p>
2514
2602
  </div>
2515
2603
  </div>
2516
2604
  <div class="team-header-right">
2605
+ <div style="display:flex;gap:2px;margin-right:12px;">
2606
+ <button class="filter-btn${teamScope === 'project' ? ' active' : ''}" onclick="teamScope='project';delete loaded['team'];loadTeam();" style="padding:6px 14px;font-size:12px;">Project</button>
2607
+ <button class="filter-btn${teamScope === 'global' ? ' active' : ''}" onclick="teamScope='global';delete loaded['team'];loadTeam();" style="padding:6px 14px;font-size:12px;">Global</button>
2608
+ </div>
2517
2609
  <span class="team-refresh-time" id="team-refresh-indicator"></span>
2518
2610
  <button class="team-refresh-btn" onclick="loadTeam()">
2519
2611
  <span class="iconify" data-icon="lucide:refresh-cw" style="font-size:14px;"></span>
@@ -2555,12 +2647,12 @@ async function loadTeam() {
2555
2647
  </div>
2556
2648
  <div class="panel-body team-scrollable">
2557
2649
  ${data.agents.length === 0
2558
- ? '<div class="team-empty"><span class="team-empty-icon"><span class="iconify" data-icon="lucide:user-x"></span></span><span class="team-empty-text">No agents registered</span></div>'
2650
+ ? '<div class="team-empty"><span class="team-empty-icon"><span class="iconify" data-icon="lucide:user-x"></span></span><span class="team-empty-text">No agents in ' + (teamScope === 'project' ? 'this session' : 'any scope') + '</span>' + (teamScope === 'project' && data._meta && data._meta.persistedAgents > 0 ? '<div style="font-size:11px;color:var(--accent-amber);margin-top:6px;">' + data._meta.persistedAgents + ' persisted agent(s) available in <a href="#" onclick="teamScope=\'global\';delete loaded[\'team\'];loadTeam();return false;" style="color:var(--accent-purple);text-decoration:underline;">Global view</a></div>' : '') + '</div>'
2559
2651
  : data.agents.map(a => `
2560
2652
  <div class="team-agent-row${a.status !== 'active' ? ' inactive' : ''}">
2561
2653
  <div class="team-agent-status ${a.status === 'active' ? 'active' : 'offline'}"></div>
2562
2654
  <div class="team-agent-info">
2563
- <div class="team-agent-name">${escapeHtml(a.name)}</div>
2655
+ <div class="team-agent-name">${escapeHtml(a.name)} <span style="font-size:9px;padding:1px 5px;border-radius:4px;font-weight:500;margin-left:4px;${a.source === 'persisted' ? 'background:rgba(255,171,64,0.12);color:var(--accent-amber);' : 'background:rgba(105,240,174,0.12);color:var(--accent-green);'}">${a.source === 'persisted' ? 'file' : 'live'}</span></div>
2564
2656
  <div class="team-agent-meta">
2565
2657
  <span>${a.role ? escapeHtml(a.role) : 'no role'}</span>
2566
2658
  ${a.capabilities && a.capabilities.length ? a.capabilities.map(c => '<span class="team-cap-tag">' + escapeHtml(c) + '</span>').join('') : ''}