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.
- package/CHANGELOG.md +564 -489
- package/CLAUDE.md +106 -56
- package/README.md +193 -24
- package/README.zh-CN.md +222 -53
- package/dist/cli/index.js +41006 -35768
- package/dist/cli/index.js.map +1 -1
- package/dist/dashboard/static/app.js +769 -677
- package/dist/dashboard/static/index.html +4 -0
- package/dist/dashboard/static/style.css +492 -20
- package/dist/index.js +2979 -870
- package/dist/index.js.map +1 -1
- package/llms-full.txt +365 -0
- package/llms.txt +75 -0
- package/package.json +110 -101
|
@@ -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
|
-
|
|
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)
|
|
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.
|
|
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
|
-
//
|
|
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"
|
|
852
|
-
<p class="page-subtitle">${graph.entities.length}
|
|
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
|
-
<
|
|
857
|
-
<div class="graph-
|
|
858
|
-
<
|
|
859
|
-
<
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
<div class="graph-
|
|
863
|
-
<button class="
|
|
864
|
-
<button class="
|
|
865
|
-
<button class="
|
|
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
|
-
|
|
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
|
-
//
|
|
880
|
-
//
|
|
964
|
+
// Cytoscape.js + Dagre — Focused Topology Renderer
|
|
965
|
+
// Default: 1-hop neighborhood of top entity, dagre LR layout
|
|
881
966
|
// ============================================================
|
|
882
967
|
|
|
883
968
|
function renderGraph(graph) {
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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
|
-
// ---
|
|
975
|
+
// --- Muted enterprise palette ---
|
|
912
976
|
const palette = [
|
|
913
|
-
'#
|
|
914
|
-
'#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
// ---
|
|
980
|
-
const
|
|
981
|
-
|
|
982
|
-
|
|
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
|
-
//
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
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
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
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
|
-
//
|
|
1064
|
-
const
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
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
|
-
|
|
1075
|
-
|
|
1076
|
-
const
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
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
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
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
|
-
// ---
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
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
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
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
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
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
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
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
|
-
|
|
1210
|
-
}
|
|
1271
|
+
});
|
|
1211
1272
|
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
if (node
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
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
|
-
|
|
1260
|
-
|
|
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
|
-
|
|
1263
|
-
|
|
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
|
-
|
|
1270
|
-
|
|
1298
|
+
function rebuildGraph() {
|
|
1299
|
+
initCytoscape();
|
|
1300
|
+
renderFilterPanel();
|
|
1301
|
+
renderIsolatedPanel();
|
|
1302
|
+
}
|
|
1271
1303
|
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
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
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
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
|
-
|
|
1381
|
-
|
|
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
|
-
|
|
1394
|
-
|
|
1367
|
+
// Bind clicks: inspect isolated entity
|
|
1368
|
+
panel.querySelectorAll('[data-iso-entity]').forEach(el => {
|
|
1395
1369
|
el.addEventListener('click', () => {
|
|
1396
|
-
const
|
|
1397
|
-
if (
|
|
1398
|
-
|
|
1399
|
-
|
|
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
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
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
|
-
|
|
1424
|
-
|
|
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
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
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
|
-
|
|
1436
|
-
|
|
1437
|
-
const
|
|
1438
|
-
|
|
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
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
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
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
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
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
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
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
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
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
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
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
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
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
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
|
-
//
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
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
|
-
//
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
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
|
-
//
|
|
1544
|
-
const
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
}
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
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
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
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
|
-
|
|
1573
|
-
|
|
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
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
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
|
-
//
|
|
1589
|
-
|
|
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
|
-
|
|
1596
|
-
|
|
1597
|
-
document.getElementById('
|
|
1598
|
-
|
|
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
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
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">${
|
|
2513
|
-
<p class="page-subtitle">${
|
|
2600
|
+
<h1 class="page-title">${scopeLabel}</h1>
|
|
2601
|
+
<p class="page-subtitle">${scopeDesc}${data.sessions != null ? ' · ' + 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
|
|
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('') : ''}
|