superlocalmemory 2.6.0 → 2.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +122 -1806
- package/README.md +142 -410
- package/docs/ACCESSIBILITY.md +291 -0
- package/docs/FRAMEWORK-INTEGRATIONS.md +300 -0
- package/package.json +1 -1
- package/src/learning/__init__.py +201 -0
- package/src/learning/adaptive_ranker.py +826 -0
- package/src/learning/cross_project_aggregator.py +866 -0
- package/src/learning/engagement_tracker.py +638 -0
- package/src/learning/feature_extractor.py +461 -0
- package/src/learning/feedback_collector.py +690 -0
- package/src/learning/learning_db.py +842 -0
- package/src/learning/project_context_manager.py +582 -0
- package/src/learning/source_quality_scorer.py +685 -0
- package/src/learning/workflow_pattern_miner.py +665 -0
- package/ui/index.html +346 -13
- package/ui/js/clusters.js +90 -1
- package/ui/js/graph-core.js +445 -0
- package/ui/js/graph-cytoscape-monolithic-backup.js +1168 -0
- package/ui/js/graph-cytoscape.js +1168 -0
- package/ui/js/graph-d3-backup.js +32 -0
- package/ui/js/graph-filters.js +220 -0
- package/ui/js/graph-interactions.js +354 -0
- package/ui/js/graph-ui.js +214 -0
- package/ui/js/memories.js +52 -0
- package/ui/js/modal.js +104 -1
|
@@ -0,0 +1,1168 @@
|
|
|
1
|
+
// SuperLocalMemory V2.6.5 - Interactive Knowledge Graph (Cytoscape.js)
|
|
2
|
+
// Copyright (c) 2026 Varun Pratap Bhardwaj — MIT License
|
|
3
|
+
// Replaces D3.js force-directed graph with Cytoscape.js for interactive exploration
|
|
4
|
+
|
|
5
|
+
var cy = null; // Cytoscape.js instance (global)
|
|
6
|
+
var graphData = { nodes: [], links: [] }; // Raw data from API
|
|
7
|
+
var originalGraphData = { nodes: [], links: [] }; // Unfiltered data (for reset)
|
|
8
|
+
var currentLayout = 'fcose'; // Default layout
|
|
9
|
+
var filterState = { cluster_id: null, entity: null }; // Current filters
|
|
10
|
+
var isInitialLoad = true; // Track if this is the first graph load
|
|
11
|
+
var focusedNodeIndex = 0; // Keyboard navigation: currently focused node
|
|
12
|
+
var keyboardNavigationEnabled = false; // Track if keyboard nav is active
|
|
13
|
+
var lastFocusedElement = null; // Store last focused element for modal return
|
|
14
|
+
|
|
15
|
+
// Cluster colors (match Clusters tab)
|
|
16
|
+
const CLUSTER_COLORS = [
|
|
17
|
+
'#667eea', '#764ba2', '#43e97b', '#38f9d7',
|
|
18
|
+
'#4facfe', '#00f2fe', '#f093fb', '#f5576c',
|
|
19
|
+
'#fa709a', '#fee140', '#30cfd0', '#330867'
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
function getClusterColor(cluster_id) {
|
|
23
|
+
if (!cluster_id) return '#999';
|
|
24
|
+
return CLUSTER_COLORS[cluster_id % CLUSTER_COLORS.length];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// HTML escape utility (prevent XSS)
|
|
28
|
+
function escapeHtml(text) {
|
|
29
|
+
const div = document.createElement('div');
|
|
30
|
+
div.textContent = text;
|
|
31
|
+
return div.innerHTML;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Load graph data from API
|
|
35
|
+
async function loadGraph() {
|
|
36
|
+
var maxNodes = document.getElementById('graph-max-nodes').value;
|
|
37
|
+
var minImportance = document.getElementById('graph-min-importance')?.value || 1;
|
|
38
|
+
|
|
39
|
+
// Get cluster filter from URL params ONLY on initial load
|
|
40
|
+
// After that, use filterState (which tab event handler controls)
|
|
41
|
+
if (isInitialLoad) {
|
|
42
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
43
|
+
const clusterIdParam = urlParams.get('cluster_id');
|
|
44
|
+
if (clusterIdParam) {
|
|
45
|
+
filterState.cluster_id = parseInt(clusterIdParam);
|
|
46
|
+
console.log('[loadGraph] Initial load with cluster filter from URL:', filterState.cluster_id);
|
|
47
|
+
}
|
|
48
|
+
isInitialLoad = false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// CRITICAL FIX: When filtering by cluster, fetch MORE nodes to ensure all cluster members are included
|
|
52
|
+
// Otherwise only top N memories are fetched and cluster filter fails
|
|
53
|
+
const fetchLimit = filterState.cluster_id ? 200 : maxNodes;
|
|
54
|
+
console.log('[loadGraph] Fetching with limit:', fetchLimit, 'Cluster filter:', filterState.cluster_id);
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
showLoadingSpinner();
|
|
58
|
+
const response = await fetch(`/api/graph?max_nodes=${fetchLimit}&min_importance=${minImportance}`);
|
|
59
|
+
graphData = await response.json();
|
|
60
|
+
originalGraphData = JSON.parse(JSON.stringify(graphData)); // Deep copy
|
|
61
|
+
|
|
62
|
+
// Apply filters if set
|
|
63
|
+
if (filterState.cluster_id) {
|
|
64
|
+
graphData = filterByCluster(originalGraphData, filterState.cluster_id);
|
|
65
|
+
}
|
|
66
|
+
if (filterState.entity) {
|
|
67
|
+
graphData = filterByEntity(originalGraphData, filterState.entity);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
renderGraph(graphData);
|
|
71
|
+
hideLoadingSpinner();
|
|
72
|
+
} catch (error) {
|
|
73
|
+
console.error('Error loading graph:', error);
|
|
74
|
+
showError('Failed to load graph. Please try again.');
|
|
75
|
+
hideLoadingSpinner();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Filter graph by cluster
|
|
80
|
+
function filterByCluster(data, cluster_id) {
|
|
81
|
+
console.log('[filterByCluster] Filtering for cluster_id:', cluster_id, 'Type:', typeof cluster_id);
|
|
82
|
+
console.log('[filterByCluster] Total nodes in data:', data.nodes.length);
|
|
83
|
+
|
|
84
|
+
// Convert to integer for comparison
|
|
85
|
+
const targetClusterId = parseInt(cluster_id);
|
|
86
|
+
let debugCount = 0;
|
|
87
|
+
|
|
88
|
+
const filteredNodes = data.nodes.filter(n => {
|
|
89
|
+
// Convert node cluster_id to integer for comparison
|
|
90
|
+
const nodeClusterId = n.cluster_id ? parseInt(n.cluster_id) : null;
|
|
91
|
+
|
|
92
|
+
// Log first 3 nodes to debug
|
|
93
|
+
if (debugCount < 3) {
|
|
94
|
+
console.log('[filterByCluster] Sample node:', {
|
|
95
|
+
id: n.id,
|
|
96
|
+
cluster_id: n.cluster_id,
|
|
97
|
+
type: typeof n.cluster_id,
|
|
98
|
+
parsed: nodeClusterId,
|
|
99
|
+
match: nodeClusterId === targetClusterId
|
|
100
|
+
});
|
|
101
|
+
debugCount++;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Use strict equality after type conversion
|
|
105
|
+
return nodeClusterId === targetClusterId;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
console.log('[filterByCluster] Filtered to', filteredNodes.length, 'nodes');
|
|
109
|
+
|
|
110
|
+
const nodeIds = new Set(filteredNodes.map(n => n.id));
|
|
111
|
+
const filteredLinks = data.links.filter(l =>
|
|
112
|
+
nodeIds.has(l.source) && nodeIds.has(l.target)
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
console.log('[filterByCluster] Found', filteredLinks.length, 'edges between filtered nodes');
|
|
116
|
+
|
|
117
|
+
return { nodes: filteredNodes, links: filteredLinks, clusters: data.clusters };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Filter graph by entity
|
|
121
|
+
function filterByEntity(data, entity) {
|
|
122
|
+
const filteredNodes = data.nodes.filter(n => {
|
|
123
|
+
if (n.entities && Array.isArray(n.entities)) {
|
|
124
|
+
return n.entities.includes(entity);
|
|
125
|
+
}
|
|
126
|
+
if (n.tags && n.tags.includes(entity)) {
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
});
|
|
131
|
+
const nodeIds = new Set(filteredNodes.map(n => n.id));
|
|
132
|
+
const filteredLinks = data.links.filter(l =>
|
|
133
|
+
nodeIds.has(l.source) && nodeIds.has(l.target)
|
|
134
|
+
);
|
|
135
|
+
return { nodes: filteredNodes, links: filteredLinks, clusters: data.clusters };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Clear all filters
|
|
139
|
+
function clearGraphFilters() {
|
|
140
|
+
console.log('[clearGraphFilters] Clearing all filters and reloading full graph');
|
|
141
|
+
|
|
142
|
+
// Clear filter state
|
|
143
|
+
filterState = { cluster_id: null, entity: null };
|
|
144
|
+
|
|
145
|
+
// CRITICAL: Clear URL completely - remove ?cluster_id=X from address bar
|
|
146
|
+
const cleanUrl = window.location.origin + window.location.pathname;
|
|
147
|
+
window.history.replaceState({}, '', cleanUrl);
|
|
148
|
+
console.log('[clearGraphFilters] URL cleaned to:', cleanUrl);
|
|
149
|
+
|
|
150
|
+
// CRITICAL: Clear saved layout positions so nodes don't stay in corner
|
|
151
|
+
// When switching from filtered to full graph, we want fresh layout
|
|
152
|
+
localStorage.removeItem('slm_graph_layout');
|
|
153
|
+
console.log('[clearGraphFilters] Cleared saved layout positions');
|
|
154
|
+
|
|
155
|
+
// Reload full graph from API (respects dropdown settings)
|
|
156
|
+
loadGraph();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Update filter badge UI - CLEAR status for users
|
|
160
|
+
function updateFilterBadge() {
|
|
161
|
+
const statusFull = document.getElementById('graph-status-full');
|
|
162
|
+
const statusFiltered = document.getElementById('graph-status-filtered');
|
|
163
|
+
const filterDescription = document.getElementById('graph-filter-description');
|
|
164
|
+
const filterCount = document.getElementById('graph-filter-count');
|
|
165
|
+
const statusFullText = document.getElementById('graph-status-full-text');
|
|
166
|
+
|
|
167
|
+
const hasFilter = filterState.cluster_id || filterState.entity;
|
|
168
|
+
|
|
169
|
+
if (hasFilter) {
|
|
170
|
+
// FILTERED STATE - Show prominent alert with "Show All Memories" button
|
|
171
|
+
if (statusFull) statusFull.style.display = 'none';
|
|
172
|
+
if (statusFiltered) statusFiltered.style.display = 'block';
|
|
173
|
+
|
|
174
|
+
// Update filter description
|
|
175
|
+
if (filterDescription) {
|
|
176
|
+
const text = filterState.cluster_id
|
|
177
|
+
? `Viewing Cluster ${filterState.cluster_id}`
|
|
178
|
+
: `Viewing: ${filterState.entity}`;
|
|
179
|
+
filterDescription.textContent = text;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Update count (will be set after graph renders)
|
|
183
|
+
if (filterCount && graphData && graphData.nodes) {
|
|
184
|
+
filterCount.textContent = `(${graphData.nodes.length} ${graphData.nodes.length === 1 ? 'memory' : 'memories'})`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
console.log('[updateFilterBadge] Showing FILTERED state');
|
|
188
|
+
} else {
|
|
189
|
+
// FULL GRAPH STATE - Show normal status with refresh button
|
|
190
|
+
if (statusFull) statusFull.style.display = 'block';
|
|
191
|
+
if (statusFiltered) statusFiltered.style.display = 'none';
|
|
192
|
+
|
|
193
|
+
// Update count
|
|
194
|
+
if (statusFullText && graphData && graphData.nodes) {
|
|
195
|
+
const maxNodes = document.getElementById('graph-max-nodes')?.value || 50;
|
|
196
|
+
statusFullText.textContent = `Showing ${graphData.nodes.length} of ${maxNodes} memories`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
console.log('[updateFilterBadge] Showing FULL state');
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Transform D3 data format → Cytoscape format
|
|
204
|
+
function transformDataForCytoscape(data) {
|
|
205
|
+
const elements = [];
|
|
206
|
+
|
|
207
|
+
// Add nodes
|
|
208
|
+
data.nodes.forEach(node => {
|
|
209
|
+
const label = node.category || node.project_name || `Memory #${node.id}`;
|
|
210
|
+
const contentPreview = node.content_preview || node.summary || node.content || '';
|
|
211
|
+
const preview = contentPreview.substring(0, 50) + (contentPreview.length > 50 ? '...' : '');
|
|
212
|
+
|
|
213
|
+
elements.push({
|
|
214
|
+
group: 'nodes',
|
|
215
|
+
data: {
|
|
216
|
+
id: String(node.id),
|
|
217
|
+
label: label,
|
|
218
|
+
content: node.content || '',
|
|
219
|
+
summary: node.summary || '',
|
|
220
|
+
content_preview: preview,
|
|
221
|
+
category: node.category || '',
|
|
222
|
+
project_name: node.project_name || '',
|
|
223
|
+
cluster_id: node.cluster_id || 0,
|
|
224
|
+
importance: node.importance || 5,
|
|
225
|
+
tags: node.tags || '',
|
|
226
|
+
entities: node.entities || [],
|
|
227
|
+
created_at: node.created_at || '',
|
|
228
|
+
// For rendering
|
|
229
|
+
weight: (node.importance || 5) * 5 // Size multiplier
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// Add edges
|
|
235
|
+
data.links.forEach(link => {
|
|
236
|
+
const sourceId = String(typeof link.source === 'object' ? link.source.id : link.source);
|
|
237
|
+
const targetId = String(typeof link.target === 'object' ? link.target.id : link.target);
|
|
238
|
+
|
|
239
|
+
elements.push({
|
|
240
|
+
group: 'edges',
|
|
241
|
+
data: {
|
|
242
|
+
id: `${sourceId}-${targetId}`,
|
|
243
|
+
source: sourceId,
|
|
244
|
+
target: targetId,
|
|
245
|
+
weight: link.weight || 0.5,
|
|
246
|
+
relationship_type: link.relationship_type || '',
|
|
247
|
+
shared_entities: link.shared_entities || []
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
return elements;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Render graph with Cytoscape.js
|
|
256
|
+
function renderGraph(data) {
|
|
257
|
+
const container = document.getElementById('graph-container');
|
|
258
|
+
if (!container) {
|
|
259
|
+
console.error('Graph container not found');
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Clear existing graph
|
|
264
|
+
if (cy) {
|
|
265
|
+
cy.destroy();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Check node count for performance tier
|
|
269
|
+
const nodeCount = data.nodes.length;
|
|
270
|
+
let layout = determineBestLayout(nodeCount);
|
|
271
|
+
|
|
272
|
+
// Transform data
|
|
273
|
+
const elements = transformDataForCytoscape(data);
|
|
274
|
+
|
|
275
|
+
if (elements.length === 0) {
|
|
276
|
+
const emptyMsg = document.createElement('div');
|
|
277
|
+
emptyMsg.style.cssText = 'text-align:center; padding:50px; color:#666;';
|
|
278
|
+
emptyMsg.textContent = 'No memories found. Try adjusting filters.';
|
|
279
|
+
container.textContent = ''; // Clear safely
|
|
280
|
+
container.appendChild(emptyMsg);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Clear container (remove spinner/old content) - safe approach
|
|
285
|
+
container.textContent = '';
|
|
286
|
+
console.log('[renderGraph] Container cleared, initializing Cytoscape with', elements.length, 'elements');
|
|
287
|
+
|
|
288
|
+
// Initialize Cytoscape WITHOUT running layout yet
|
|
289
|
+
try {
|
|
290
|
+
cy = cytoscape({
|
|
291
|
+
container: container,
|
|
292
|
+
elements: elements,
|
|
293
|
+
style: getCytoscapeStyles(),
|
|
294
|
+
layout: { name: 'preset' }, // Don't run layout yet
|
|
295
|
+
minZoom: 0.2,
|
|
296
|
+
maxZoom: 3,
|
|
297
|
+
wheelSensitivity: 0.05, // Reduced from 0.2 - less sensitive zoom
|
|
298
|
+
autoungrabify: false,
|
|
299
|
+
autounselectify: false,
|
|
300
|
+
// Mobile touch support
|
|
301
|
+
touchTapThreshold: 8, // Pixels of movement allowed for tap (vs drag)
|
|
302
|
+
desktopTapThreshold: 4, // Desktop click threshold
|
|
303
|
+
pixelRatio: 'auto' // Retina/HiDPI support
|
|
304
|
+
});
|
|
305
|
+
console.log('[renderGraph] Cytoscape initialized successfully, nodes:', cy.nodes().length);
|
|
306
|
+
} catch (error) {
|
|
307
|
+
console.error('[renderGraph] Cytoscape initialization failed:', error);
|
|
308
|
+
showError('Failed to render graph: ' + error.message);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Add interactions
|
|
313
|
+
addCytoscapeInteractions();
|
|
314
|
+
|
|
315
|
+
// Add navigator (mini-map) if available
|
|
316
|
+
if (cy.navigator && nodeCount > 50) {
|
|
317
|
+
cy.navigator({
|
|
318
|
+
container: false, // Will be added to DOM separately if needed
|
|
319
|
+
viewLiveFramerate: 0,
|
|
320
|
+
dblClickDelay: 200,
|
|
321
|
+
removeCustomContainer: false,
|
|
322
|
+
rerenderDelay: 100
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Update UI
|
|
327
|
+
updateFilterBadge();
|
|
328
|
+
updateGraphStats(data);
|
|
329
|
+
|
|
330
|
+
// Announce graph load to screen readers
|
|
331
|
+
updateScreenReaderStatus(`Graph loaded with ${data.nodes.length} memories and ${data.links.length} connections`);
|
|
332
|
+
|
|
333
|
+
// Check if we should restore saved layout or run fresh layout
|
|
334
|
+
const hasSavedLayout = localStorage.getItem('slm_graph_layout');
|
|
335
|
+
|
|
336
|
+
if (hasSavedLayout) {
|
|
337
|
+
// Restore saved positions, then fit
|
|
338
|
+
restoreSavedLayout();
|
|
339
|
+
console.log('[renderGraph] Restored saved layout positions');
|
|
340
|
+
cy.fit(null, 80);
|
|
341
|
+
} else {
|
|
342
|
+
// No saved layout - run layout algorithm with fit
|
|
343
|
+
console.log('[renderGraph] Running fresh layout:', layout);
|
|
344
|
+
const layoutConfig = getLayoutConfig(layout);
|
|
345
|
+
const graphLayout = cy.layout(layoutConfig);
|
|
346
|
+
|
|
347
|
+
// CRITICAL: Wait for layout to finish, THEN force fit
|
|
348
|
+
graphLayout.on('layoutstop', function() {
|
|
349
|
+
console.log('[renderGraph] Layout completed, forcing fit to viewport');
|
|
350
|
+
cy.fit(null, 80); // Force center with 80px padding
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
graphLayout.run();
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Determine best layout based on node count (3-tier strategy)
|
|
358
|
+
function determineBestLayout(nodeCount) {
|
|
359
|
+
if (nodeCount <= 500) {
|
|
360
|
+
return 'fcose'; // Full interactive graph
|
|
361
|
+
} else if (nodeCount <= 2000) {
|
|
362
|
+
return 'cose'; // Faster force-directed
|
|
363
|
+
} else {
|
|
364
|
+
return 'circle'; // Focus mode (circular for large graphs)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Get layout configuration
|
|
369
|
+
function getLayoutConfig(layoutName) {
|
|
370
|
+
const configs = {
|
|
371
|
+
'fcose': {
|
|
372
|
+
name: 'fcose',
|
|
373
|
+
quality: 'default',
|
|
374
|
+
randomize: false,
|
|
375
|
+
animate: false, // Disabled for stability
|
|
376
|
+
fit: true,
|
|
377
|
+
padding: 80, // Increased padding to keep within bounds
|
|
378
|
+
nodeSeparation: 100,
|
|
379
|
+
idealEdgeLength: 100,
|
|
380
|
+
edgeElasticity: 0.45,
|
|
381
|
+
nestingFactor: 0.1,
|
|
382
|
+
gravity: 0.25,
|
|
383
|
+
numIter: 2500,
|
|
384
|
+
tile: true,
|
|
385
|
+
tilingPaddingVertical: 10,
|
|
386
|
+
tilingPaddingHorizontal: 10,
|
|
387
|
+
gravityRangeCompound: 1.5,
|
|
388
|
+
gravityCompound: 1.0,
|
|
389
|
+
gravityRange: 3.8
|
|
390
|
+
},
|
|
391
|
+
'cose': {
|
|
392
|
+
name: 'cose',
|
|
393
|
+
animate: false, // Disabled for stability
|
|
394
|
+
fit: true,
|
|
395
|
+
padding: 80, // Increased padding
|
|
396
|
+
nodeRepulsion: 8000,
|
|
397
|
+
idealEdgeLength: 100,
|
|
398
|
+
edgeElasticity: 100,
|
|
399
|
+
nestingFactor: 5,
|
|
400
|
+
gravity: 80,
|
|
401
|
+
numIter: 1000,
|
|
402
|
+
randomize: false
|
|
403
|
+
},
|
|
404
|
+
'circle': {
|
|
405
|
+
name: 'circle',
|
|
406
|
+
animate: true,
|
|
407
|
+
animationDuration: 1000,
|
|
408
|
+
fit: true,
|
|
409
|
+
padding: 50,
|
|
410
|
+
sort: function(a, b) {
|
|
411
|
+
return b.data('importance') - a.data('importance');
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
'grid': {
|
|
415
|
+
name: 'grid',
|
|
416
|
+
animate: true,
|
|
417
|
+
animationDuration: 1000,
|
|
418
|
+
fit: true,
|
|
419
|
+
padding: 50,
|
|
420
|
+
sort: function(a, b) {
|
|
421
|
+
return b.data('importance') - a.data('importance');
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
'breadthfirst': {
|
|
425
|
+
name: 'breadthfirst',
|
|
426
|
+
animate: true,
|
|
427
|
+
animationDuration: 1000,
|
|
428
|
+
fit: true,
|
|
429
|
+
padding: 50,
|
|
430
|
+
directed: false,
|
|
431
|
+
circle: false,
|
|
432
|
+
spacingFactor: 1.5,
|
|
433
|
+
sort: function(a, b) {
|
|
434
|
+
return b.data('importance') - a.data('importance');
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
'concentric': {
|
|
438
|
+
name: 'concentric',
|
|
439
|
+
animate: true,
|
|
440
|
+
animationDuration: 1000,
|
|
441
|
+
fit: true,
|
|
442
|
+
padding: 50,
|
|
443
|
+
concentric: function(node) {
|
|
444
|
+
return node.data('importance');
|
|
445
|
+
},
|
|
446
|
+
levelWidth: function() {
|
|
447
|
+
return 2;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
return configs[layoutName] || configs['fcose'];
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Cytoscape.js styles
|
|
456
|
+
function getCytoscapeStyles() {
|
|
457
|
+
return [
|
|
458
|
+
{
|
|
459
|
+
selector: 'node',
|
|
460
|
+
style: {
|
|
461
|
+
'background-color': function(ele) {
|
|
462
|
+
return getClusterColor(ele.data('cluster_id'));
|
|
463
|
+
},
|
|
464
|
+
'width': function(ele) {
|
|
465
|
+
return Math.max(20, ele.data('weight'));
|
|
466
|
+
},
|
|
467
|
+
'height': function(ele) {
|
|
468
|
+
return Math.max(20, ele.data('weight'));
|
|
469
|
+
},
|
|
470
|
+
'label': 'data(label)',
|
|
471
|
+
'font-size': '10px',
|
|
472
|
+
'text-valign': 'center',
|
|
473
|
+
'text-halign': 'center',
|
|
474
|
+
'color': '#333',
|
|
475
|
+
'text-outline-width': 2,
|
|
476
|
+
'text-outline-color': '#fff',
|
|
477
|
+
'border-width': function(ele) {
|
|
478
|
+
// Trust score → border thickness (if available)
|
|
479
|
+
return 2;
|
|
480
|
+
},
|
|
481
|
+
'border-color': '#555'
|
|
482
|
+
}
|
|
483
|
+
},
|
|
484
|
+
{
|
|
485
|
+
selector: 'node:selected',
|
|
486
|
+
style: {
|
|
487
|
+
'border-width': 4,
|
|
488
|
+
'border-color': '#667eea',
|
|
489
|
+
'background-color': '#667eea'
|
|
490
|
+
}
|
|
491
|
+
},
|
|
492
|
+
{
|
|
493
|
+
selector: 'node.highlighted',
|
|
494
|
+
style: {
|
|
495
|
+
'border-width': 4,
|
|
496
|
+
'border-color': '#ff6b6b',
|
|
497
|
+
'box-shadow': '0 0 20px #ff6b6b'
|
|
498
|
+
}
|
|
499
|
+
},
|
|
500
|
+
{
|
|
501
|
+
selector: 'node.dimmed',
|
|
502
|
+
style: {
|
|
503
|
+
'opacity': 0.3
|
|
504
|
+
}
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
selector: 'node.keyboard-focused',
|
|
508
|
+
style: {
|
|
509
|
+
'border-width': 5,
|
|
510
|
+
'border-color': '#0066ff',
|
|
511
|
+
'border-style': 'solid',
|
|
512
|
+
'box-shadow': '0 0 15px #0066ff'
|
|
513
|
+
}
|
|
514
|
+
},
|
|
515
|
+
{
|
|
516
|
+
selector: 'edge',
|
|
517
|
+
style: {
|
|
518
|
+
'width': function(ele) {
|
|
519
|
+
return Math.max(1, ele.data('weight') * 3);
|
|
520
|
+
},
|
|
521
|
+
'line-color': '#ccc',
|
|
522
|
+
'line-style': function(ele) {
|
|
523
|
+
return ele.data('weight') > 0.3 ? 'solid' : 'dashed';
|
|
524
|
+
},
|
|
525
|
+
'curve-style': 'bezier',
|
|
526
|
+
'target-arrow-shape': 'none',
|
|
527
|
+
'opacity': 0.6
|
|
528
|
+
}
|
|
529
|
+
},
|
|
530
|
+
{
|
|
531
|
+
selector: 'edge.highlighted',
|
|
532
|
+
style: {
|
|
533
|
+
'line-color': '#667eea',
|
|
534
|
+
'width': 3,
|
|
535
|
+
'opacity': 1
|
|
536
|
+
}
|
|
537
|
+
},
|
|
538
|
+
{
|
|
539
|
+
selector: 'edge.dimmed',
|
|
540
|
+
style: {
|
|
541
|
+
'opacity': 0.1
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
];
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Add all interactions (hover, click, double-click, drag)
|
|
548
|
+
function addCytoscapeInteractions() {
|
|
549
|
+
if (!cy) return;
|
|
550
|
+
|
|
551
|
+
// Hover: Show tooltip
|
|
552
|
+
cy.on('mouseover', 'node', function(evt) {
|
|
553
|
+
const node = evt.target;
|
|
554
|
+
const pos = evt.renderedPosition;
|
|
555
|
+
showTooltip(node, pos.x, pos.y);
|
|
556
|
+
|
|
557
|
+
// Highlight connected nodes
|
|
558
|
+
node.addClass('highlighted');
|
|
559
|
+
node.connectedEdges().addClass('highlighted');
|
|
560
|
+
node.neighborhood('node').addClass('highlighted');
|
|
561
|
+
|
|
562
|
+
// Dim others
|
|
563
|
+
cy.nodes().not(node).not(node.neighborhood('node')).addClass('dimmed');
|
|
564
|
+
cy.edges().not(node.connectedEdges()).addClass('dimmed');
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
cy.on('mouseout', 'node', function(evt) {
|
|
568
|
+
hideTooltip();
|
|
569
|
+
|
|
570
|
+
// Remove highlighting
|
|
571
|
+
cy.elements().removeClass('highlighted').removeClass('dimmed');
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
// Single click: Open modal preview
|
|
575
|
+
cy.on('tap', 'node', function(evt) {
|
|
576
|
+
const node = evt.target;
|
|
577
|
+
openMemoryModal(node);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
// Double click: Navigate to Memories tab
|
|
581
|
+
cy.on('dbltap', 'node', function(evt) {
|
|
582
|
+
const node = evt.target;
|
|
583
|
+
navigateToMemoryTab(node.data('id'));
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
// Enable node dragging
|
|
587
|
+
cy.on('drag', 'node', function(evt) {
|
|
588
|
+
// Node position is automatically updated by Cytoscape
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
// Save layout when drag ends
|
|
592
|
+
cy.on('dragfree', 'node', function(evt) {
|
|
593
|
+
saveLayoutPositions();
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
// Pan & zoom events (for performance monitoring)
|
|
597
|
+
let panZoomTimeout;
|
|
598
|
+
cy.on('pan zoom', function() {
|
|
599
|
+
clearTimeout(panZoomTimeout);
|
|
600
|
+
panZoomTimeout = setTimeout(() => {
|
|
601
|
+
saveLayoutPositions();
|
|
602
|
+
}, 1000);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
// Add keyboard navigation
|
|
606
|
+
setupKeyboardNavigation();
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Tooltip (XSS-safe: uses textContent and createElement)
|
|
610
|
+
let tooltipTimeout;
|
|
611
|
+
function showTooltip(node, x, y) {
|
|
612
|
+
clearTimeout(tooltipTimeout);
|
|
613
|
+
tooltipTimeout = setTimeout(() => {
|
|
614
|
+
let tooltip = document.getElementById('graph-tooltip');
|
|
615
|
+
if (!tooltip) {
|
|
616
|
+
tooltip = document.createElement('div');
|
|
617
|
+
tooltip.id = 'graph-tooltip';
|
|
618
|
+
tooltip.style.cssText = 'position:fixed; background:#333; color:#fff; padding:10px; border-radius:6px; font-size:12px; max-width:300px; z-index:10000; pointer-events:none; box-shadow: 0 4px 12px rgba(0,0,0,0.3);';
|
|
619
|
+
document.body.appendChild(tooltip);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Build tooltip content safely (no innerHTML)
|
|
623
|
+
tooltip.textContent = ''; // Clear
|
|
624
|
+
const data = node.data();
|
|
625
|
+
|
|
626
|
+
const title = document.createElement('strong');
|
|
627
|
+
title.textContent = data.label;
|
|
628
|
+
tooltip.appendChild(title);
|
|
629
|
+
|
|
630
|
+
tooltip.appendChild(document.createElement('br'));
|
|
631
|
+
|
|
632
|
+
const meta = document.createElement('span');
|
|
633
|
+
meta.style.color = '#aaa';
|
|
634
|
+
meta.textContent = `Cluster ${data.cluster_id} • Importance ${data.importance}`;
|
|
635
|
+
tooltip.appendChild(meta);
|
|
636
|
+
|
|
637
|
+
tooltip.appendChild(document.createElement('br'));
|
|
638
|
+
|
|
639
|
+
const preview = document.createElement('span');
|
|
640
|
+
preview.style.cssText = 'font-size:11px; color:#ccc;';
|
|
641
|
+
preview.textContent = data.content_preview;
|
|
642
|
+
tooltip.appendChild(preview);
|
|
643
|
+
|
|
644
|
+
tooltip.style.display = 'block';
|
|
645
|
+
tooltip.style.left = (x + 20) + 'px';
|
|
646
|
+
tooltip.style.top = (y - 20) + 'px';
|
|
647
|
+
}, 200);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function hideTooltip() {
|
|
651
|
+
clearTimeout(tooltipTimeout);
|
|
652
|
+
const tooltip = document.getElementById('graph-tooltip');
|
|
653
|
+
if (tooltip) {
|
|
654
|
+
tooltip.style.display = 'none';
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Open modal preview (reuse existing modal.js function)
|
|
659
|
+
function openMemoryModal(node) {
|
|
660
|
+
const memoryData = {
|
|
661
|
+
id: node.data('id'),
|
|
662
|
+
content: node.data('content'),
|
|
663
|
+
summary: node.data('summary'),
|
|
664
|
+
category: node.data('category'),
|
|
665
|
+
project_name: node.data('project_name'),
|
|
666
|
+
cluster_id: node.data('cluster_id'),
|
|
667
|
+
importance: node.data('importance'),
|
|
668
|
+
tags: node.data('tags'),
|
|
669
|
+
created_at: node.data('created_at')
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
// Call existing openMemoryDetail function from modal.js
|
|
673
|
+
if (typeof openMemoryDetail === 'function') {
|
|
674
|
+
openMemoryDetail(memoryData);
|
|
675
|
+
} else {
|
|
676
|
+
console.error('openMemoryDetail function not found. Is modal.js loaded?');
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Navigate to Memories tab and scroll to memory
|
|
681
|
+
function navigateToMemoryTab(memoryId) {
|
|
682
|
+
// Switch to Memories tab
|
|
683
|
+
const memoriesTab = document.querySelector('a[href="#memories"]');
|
|
684
|
+
if (memoriesTab) {
|
|
685
|
+
memoriesTab.click();
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Scroll to memory after a short delay (for tab to load)
|
|
689
|
+
setTimeout(() => {
|
|
690
|
+
if (typeof scrollToMemory === 'function') {
|
|
691
|
+
scrollToMemory(memoryId);
|
|
692
|
+
} else {
|
|
693
|
+
console.warn('scrollToMemory function not found in memories.js');
|
|
694
|
+
}
|
|
695
|
+
}, 300);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Save layout positions to localStorage
|
|
699
|
+
function saveLayoutPositions() {
|
|
700
|
+
if (!cy) return;
|
|
701
|
+
|
|
702
|
+
const positions = {};
|
|
703
|
+
cy.nodes().forEach(node => {
|
|
704
|
+
positions[node.id()] = node.position();
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
try {
|
|
708
|
+
localStorage.setItem('slm_graph_layout', JSON.stringify(positions));
|
|
709
|
+
} catch (e) {
|
|
710
|
+
console.warn('Failed to save graph layout:', e);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Restore saved layout positions
|
|
715
|
+
function restoreSavedLayout() {
|
|
716
|
+
if (!cy) return;
|
|
717
|
+
|
|
718
|
+
try {
|
|
719
|
+
const saved = localStorage.getItem('slm_graph_layout');
|
|
720
|
+
if (saved) {
|
|
721
|
+
const positions = JSON.parse(saved);
|
|
722
|
+
cy.nodes().forEach(node => {
|
|
723
|
+
const pos = positions[node.id()];
|
|
724
|
+
if (pos) {
|
|
725
|
+
node.position(pos);
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
} catch (e) {
|
|
730
|
+
console.warn('Failed to restore graph layout:', e);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Layout selector: Change graph layout
|
|
735
|
+
function changeGraphLayout(layoutName) {
|
|
736
|
+
if (!cy) return;
|
|
737
|
+
|
|
738
|
+
currentLayout = layoutName;
|
|
739
|
+
const layout = cy.layout(getLayoutConfig(layoutName));
|
|
740
|
+
layout.run();
|
|
741
|
+
|
|
742
|
+
// Save preference
|
|
743
|
+
localStorage.setItem('slm_graph_layout_preference', layoutName);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Expand neighbors: Show only this node + connected nodes
|
|
747
|
+
function expandNeighbors(memoryId) {
|
|
748
|
+
if (!cy) return;
|
|
749
|
+
|
|
750
|
+
const node = cy.getElementById(String(memoryId));
|
|
751
|
+
if (!node || node.length === 0) return;
|
|
752
|
+
|
|
753
|
+
// Hide all nodes and edges
|
|
754
|
+
cy.elements().addClass('dimmed');
|
|
755
|
+
|
|
756
|
+
// Show target node + neighbors + connecting edges
|
|
757
|
+
node.removeClass('dimmed');
|
|
758
|
+
node.neighborhood().removeClass('dimmed');
|
|
759
|
+
node.connectedEdges().removeClass('dimmed');
|
|
760
|
+
|
|
761
|
+
// Fit view to visible elements
|
|
762
|
+
cy.fit(node.neighborhood().union(node), 50);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Update graph stats display
|
|
766
|
+
function updateGraphStats(data) {
|
|
767
|
+
const statsEl = document.getElementById('graph-stats');
|
|
768
|
+
if (statsEl) {
|
|
769
|
+
// Clear and rebuild safely
|
|
770
|
+
statsEl.textContent = '';
|
|
771
|
+
|
|
772
|
+
const nodeBadge = document.createElement('span');
|
|
773
|
+
nodeBadge.className = 'badge bg-primary';
|
|
774
|
+
nodeBadge.textContent = `${data.nodes.length} nodes`;
|
|
775
|
+
statsEl.appendChild(nodeBadge);
|
|
776
|
+
|
|
777
|
+
const edgeBadge = document.createElement('span');
|
|
778
|
+
edgeBadge.className = 'badge bg-secondary';
|
|
779
|
+
edgeBadge.textContent = `${data.links.length} edges`;
|
|
780
|
+
statsEl.appendChild(document.createTextNode(' '));
|
|
781
|
+
statsEl.appendChild(edgeBadge);
|
|
782
|
+
|
|
783
|
+
const clusterBadge = document.createElement('span');
|
|
784
|
+
clusterBadge.className = 'badge bg-info';
|
|
785
|
+
clusterBadge.textContent = `${data.clusters?.length || 0} clusters`;
|
|
786
|
+
statsEl.appendChild(document.createTextNode(' '));
|
|
787
|
+
statsEl.appendChild(clusterBadge);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Loading spinner helpers
|
|
792
|
+
function showLoadingSpinner() {
|
|
793
|
+
const container = document.getElementById('graph-container');
|
|
794
|
+
if (container) {
|
|
795
|
+
container.textContent = ''; // Clear safely
|
|
796
|
+
|
|
797
|
+
const wrapper = document.createElement('div');
|
|
798
|
+
wrapper.style.cssText = 'text-align:center; padding:100px;';
|
|
799
|
+
|
|
800
|
+
const spinner = document.createElement('div');
|
|
801
|
+
spinner.className = 'spinner-border text-primary';
|
|
802
|
+
spinner.setAttribute('role', 'status');
|
|
803
|
+
wrapper.appendChild(spinner);
|
|
804
|
+
|
|
805
|
+
const text = document.createElement('p');
|
|
806
|
+
text.style.marginTop = '20px';
|
|
807
|
+
text.textContent = 'Loading graph...';
|
|
808
|
+
wrapper.appendChild(text);
|
|
809
|
+
|
|
810
|
+
container.appendChild(wrapper);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function hideLoadingSpinner() {
|
|
815
|
+
// Do nothing - renderGraph() already cleared the spinner
|
|
816
|
+
// If we clear here, we destroy the Cytoscape canvas!
|
|
817
|
+
console.log('[hideLoadingSpinner] Graph already rendered, spinner cleared by renderGraph()');
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function showError(message) {
|
|
821
|
+
const container = document.getElementById('graph-container');
|
|
822
|
+
if (container) {
|
|
823
|
+
container.textContent = ''; // Clear safely
|
|
824
|
+
|
|
825
|
+
const alert = document.createElement('div');
|
|
826
|
+
alert.className = 'alert alert-danger';
|
|
827
|
+
alert.setAttribute('role', 'alert');
|
|
828
|
+
alert.style.margin = '50px';
|
|
829
|
+
alert.textContent = message;
|
|
830
|
+
container.appendChild(alert);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// Initialize on page load
|
|
835
|
+
function setupGraphEventListeners() {
|
|
836
|
+
// Load graph when Graph tab is clicked
|
|
837
|
+
const graphTab = document.querySelector('a[href="#graph"]');
|
|
838
|
+
if (graphTab) {
|
|
839
|
+
// CRITICAL FIX: Add click handler to clear filter even when already on KG tab
|
|
840
|
+
// Bootstrap's shown.bs.tab only fires when SWITCHING TO tab, not when clicking same tab!
|
|
841
|
+
graphTab.addEventListener('click', function(event) {
|
|
842
|
+
console.log('[Event] Knowledge Graph tab CLICKED');
|
|
843
|
+
|
|
844
|
+
// Check if we're viewing a filtered graph
|
|
845
|
+
const hasFilter = filterState.cluster_id || filterState.entity;
|
|
846
|
+
|
|
847
|
+
if (hasFilter && cy) {
|
|
848
|
+
console.log('[Event] Click detected on KG tab while filter active - clearing filter');
|
|
849
|
+
|
|
850
|
+
// Clear filter state
|
|
851
|
+
filterState = { cluster_id: null, entity: null };
|
|
852
|
+
|
|
853
|
+
// Clear URL completely
|
|
854
|
+
const cleanUrl = window.location.origin + window.location.pathname;
|
|
855
|
+
window.history.replaceState({}, '', cleanUrl);
|
|
856
|
+
console.log('[Event] URL cleaned to:', cleanUrl);
|
|
857
|
+
|
|
858
|
+
// CRITICAL: Clear saved layout positions so nodes don't stay in corner
|
|
859
|
+
localStorage.removeItem('slm_graph_layout');
|
|
860
|
+
console.log('[Event] Cleared saved layout positions');
|
|
861
|
+
|
|
862
|
+
// Reload with full graph
|
|
863
|
+
loadGraph();
|
|
864
|
+
}
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
// Also keep shown.bs.tab for when switching FROM another tab TO KG tab
|
|
868
|
+
graphTab.addEventListener('shown.bs.tab', function(event) {
|
|
869
|
+
console.log('[Event] Knowledge Graph tab SHOWN (tab switch)');
|
|
870
|
+
|
|
871
|
+
if (cy) {
|
|
872
|
+
// Graph already exists - user is returning to KG tab from another tab
|
|
873
|
+
// Clear filter and reload to show full graph
|
|
874
|
+
console.log('[Event] Returning to KG tab from another tab - clearing filter');
|
|
875
|
+
|
|
876
|
+
// Clear filter state
|
|
877
|
+
filterState = { cluster_id: null, entity: null };
|
|
878
|
+
|
|
879
|
+
// Clear URL completely
|
|
880
|
+
const cleanUrl = window.location.origin + window.location.pathname;
|
|
881
|
+
window.history.replaceState({}, '', cleanUrl);
|
|
882
|
+
console.log('[Event] URL cleaned to:', cleanUrl);
|
|
883
|
+
|
|
884
|
+
// CRITICAL: Clear saved layout positions so nodes don't stay in corner
|
|
885
|
+
localStorage.removeItem('slm_graph_layout');
|
|
886
|
+
console.log('[Event] Cleared saved layout positions');
|
|
887
|
+
|
|
888
|
+
// Reload with full graph (respects dropdown settings)
|
|
889
|
+
loadGraph();
|
|
890
|
+
} else {
|
|
891
|
+
// First load - check if cluster filter is in URL (from cluster badge click)
|
|
892
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
893
|
+
const clusterIdParam = urlParams.get('cluster_id');
|
|
894
|
+
|
|
895
|
+
if (clusterIdParam) {
|
|
896
|
+
console.log('[Event] First load with cluster filter:', clusterIdParam);
|
|
897
|
+
// Load with filter (will be applied in loadGraph)
|
|
898
|
+
} else {
|
|
899
|
+
console.log('[Event] First load, no filter');
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
loadGraph();
|
|
903
|
+
}
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Reload graph when dropdown settings change
|
|
908
|
+
const maxNodesSelect = document.getElementById('graph-max-nodes');
|
|
909
|
+
if (maxNodesSelect) {
|
|
910
|
+
maxNodesSelect.addEventListener('change', function() {
|
|
911
|
+
console.log('[Event] Max nodes changed to:', this.value);
|
|
912
|
+
|
|
913
|
+
// Clear any active filter when user manually changes settings
|
|
914
|
+
if (filterState.cluster_id || filterState.entity) {
|
|
915
|
+
console.log('[Event] Clearing filter due to dropdown change');
|
|
916
|
+
filterState = { cluster_id: null, entity: null };
|
|
917
|
+
|
|
918
|
+
// Clear URL
|
|
919
|
+
const cleanUrl = window.location.origin + window.location.pathname;
|
|
920
|
+
window.history.replaceState({}, '', cleanUrl);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Clear saved layout when changing node count (different # of nodes = different layout)
|
|
924
|
+
localStorage.removeItem('slm_graph_layout');
|
|
925
|
+
console.log('[Event] Cleared saved layout due to settings change');
|
|
926
|
+
|
|
927
|
+
loadGraph();
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const minImportanceSelect = document.getElementById('graph-min-importance');
|
|
932
|
+
if (minImportanceSelect) {
|
|
933
|
+
minImportanceSelect.addEventListener('change', function() {
|
|
934
|
+
console.log('[Event] Min importance changed to:', this.value);
|
|
935
|
+
|
|
936
|
+
// Clear any active filter when user manually changes settings
|
|
937
|
+
if (filterState.cluster_id || filterState.entity) {
|
|
938
|
+
console.log('[Event] Clearing filter due to dropdown change');
|
|
939
|
+
filterState = { cluster_id: null, entity: null };
|
|
940
|
+
|
|
941
|
+
// Clear URL
|
|
942
|
+
const cleanUrl = window.location.origin + window.location.pathname;
|
|
943
|
+
window.history.replaceState({}, '', cleanUrl);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// Clear saved layout when changing importance (different nodes = different layout)
|
|
947
|
+
localStorage.removeItem('slm_graph_layout');
|
|
948
|
+
console.log('[Event] Cleared saved layout due to settings change');
|
|
949
|
+
|
|
950
|
+
loadGraph();
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
console.log('[Init] Graph event listeners setup complete');
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// Keyboard navigation for graph
|
|
958
|
+
function setupKeyboardNavigation() {
|
|
959
|
+
if (!cy) return;
|
|
960
|
+
|
|
961
|
+
const container = document.getElementById('graph-container');
|
|
962
|
+
if (!container) return;
|
|
963
|
+
|
|
964
|
+
// Make container focusable
|
|
965
|
+
container.setAttribute('tabindex', '0');
|
|
966
|
+
|
|
967
|
+
// Focus handler - enable keyboard nav when container is focused
|
|
968
|
+
container.addEventListener('focus', function() {
|
|
969
|
+
keyboardNavigationEnabled = true;
|
|
970
|
+
if (cy.nodes().length > 0) {
|
|
971
|
+
focusNodeAtIndex(0);
|
|
972
|
+
}
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
container.addEventListener('blur', function() {
|
|
976
|
+
keyboardNavigationEnabled = false;
|
|
977
|
+
cy.nodes().removeClass('keyboard-focused');
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
// Keyboard event handler
|
|
981
|
+
container.addEventListener('keydown', function(e) {
|
|
982
|
+
if (!keyboardNavigationEnabled || !cy) return;
|
|
983
|
+
|
|
984
|
+
const nodes = cy.nodes();
|
|
985
|
+
if (nodes.length === 0) return;
|
|
986
|
+
|
|
987
|
+
const currentNode = nodes[focusedNodeIndex];
|
|
988
|
+
|
|
989
|
+
switch(e.key) {
|
|
990
|
+
case 'Tab':
|
|
991
|
+
e.preventDefault();
|
|
992
|
+
if (e.shiftKey) {
|
|
993
|
+
// Shift+Tab: previous node
|
|
994
|
+
focusedNodeIndex = (focusedNodeIndex - 1 + nodes.length) % nodes.length;
|
|
995
|
+
} else {
|
|
996
|
+
// Tab: next node
|
|
997
|
+
focusedNodeIndex = (focusedNodeIndex + 1) % nodes.length;
|
|
998
|
+
}
|
|
999
|
+
focusNodeAtIndex(focusedNodeIndex);
|
|
1000
|
+
announceNode(nodes[focusedNodeIndex]);
|
|
1001
|
+
break;
|
|
1002
|
+
|
|
1003
|
+
case 'Enter':
|
|
1004
|
+
case ' ':
|
|
1005
|
+
e.preventDefault();
|
|
1006
|
+
if (currentNode) {
|
|
1007
|
+
lastFocusedElement = container;
|
|
1008
|
+
openMemoryModal(currentNode);
|
|
1009
|
+
}
|
|
1010
|
+
break;
|
|
1011
|
+
|
|
1012
|
+
case 'ArrowRight':
|
|
1013
|
+
e.preventDefault();
|
|
1014
|
+
moveToAdjacentNode('right', currentNode);
|
|
1015
|
+
break;
|
|
1016
|
+
|
|
1017
|
+
case 'ArrowLeft':
|
|
1018
|
+
e.preventDefault();
|
|
1019
|
+
moveToAdjacentNode('left', currentNode);
|
|
1020
|
+
break;
|
|
1021
|
+
|
|
1022
|
+
case 'ArrowDown':
|
|
1023
|
+
e.preventDefault();
|
|
1024
|
+
moveToAdjacentNode('down', currentNode);
|
|
1025
|
+
break;
|
|
1026
|
+
|
|
1027
|
+
case 'ArrowUp':
|
|
1028
|
+
e.preventDefault();
|
|
1029
|
+
moveToAdjacentNode('up', currentNode);
|
|
1030
|
+
break;
|
|
1031
|
+
|
|
1032
|
+
case 'Escape':
|
|
1033
|
+
e.preventDefault();
|
|
1034
|
+
if (filterState.cluster_id || filterState.entity) {
|
|
1035
|
+
clearGraphFilters();
|
|
1036
|
+
updateScreenReaderStatus('Filters cleared, showing all memories');
|
|
1037
|
+
} else {
|
|
1038
|
+
container.blur();
|
|
1039
|
+
keyboardNavigationEnabled = false;
|
|
1040
|
+
}
|
|
1041
|
+
break;
|
|
1042
|
+
|
|
1043
|
+
case 'Home':
|
|
1044
|
+
e.preventDefault();
|
|
1045
|
+
focusedNodeIndex = 0;
|
|
1046
|
+
focusNodeAtIndex(0);
|
|
1047
|
+
announceNode(nodes[0]);
|
|
1048
|
+
break;
|
|
1049
|
+
|
|
1050
|
+
case 'End':
|
|
1051
|
+
e.preventDefault();
|
|
1052
|
+
focusedNodeIndex = nodes.length - 1;
|
|
1053
|
+
focusNodeAtIndex(focusedNodeIndex);
|
|
1054
|
+
announceNode(nodes[focusedNodeIndex]);
|
|
1055
|
+
break;
|
|
1056
|
+
}
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// Focus a node at specific index
|
|
1061
|
+
function focusNodeAtIndex(index) {
|
|
1062
|
+
if (!cy) return;
|
|
1063
|
+
|
|
1064
|
+
const nodes = cy.nodes();
|
|
1065
|
+
if (index < 0 || index >= nodes.length) return;
|
|
1066
|
+
|
|
1067
|
+
// Remove focus from all nodes
|
|
1068
|
+
cy.nodes().removeClass('keyboard-focused');
|
|
1069
|
+
|
|
1070
|
+
// Add focus to target node
|
|
1071
|
+
const node = nodes[index];
|
|
1072
|
+
node.addClass('keyboard-focused');
|
|
1073
|
+
|
|
1074
|
+
// Center node in viewport with smooth animation
|
|
1075
|
+
cy.animate({
|
|
1076
|
+
center: { eles: node },
|
|
1077
|
+
zoom: Math.max(cy.zoom(), 1.0),
|
|
1078
|
+
duration: 300,
|
|
1079
|
+
easing: 'ease-in-out'
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// Move focus to adjacent node based on direction
|
|
1084
|
+
function moveToAdjacentNode(direction, currentNode) {
|
|
1085
|
+
if (!currentNode) return;
|
|
1086
|
+
|
|
1087
|
+
const nodes = cy.nodes();
|
|
1088
|
+
const currentPos = currentNode.position();
|
|
1089
|
+
let bestNode = null;
|
|
1090
|
+
let bestScore = Infinity;
|
|
1091
|
+
|
|
1092
|
+
// Find adjacent nodes based on direction
|
|
1093
|
+
nodes.forEach((node, index) => {
|
|
1094
|
+
if (node.id() === currentNode.id()) return;
|
|
1095
|
+
|
|
1096
|
+
const pos = node.position();
|
|
1097
|
+
const dx = pos.x - currentPos.x;
|
|
1098
|
+
const dy = pos.y - currentPos.y;
|
|
1099
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
1100
|
+
|
|
1101
|
+
let isCorrectDirection = false;
|
|
1102
|
+
let directionScore = 0;
|
|
1103
|
+
|
|
1104
|
+
switch(direction) {
|
|
1105
|
+
case 'right':
|
|
1106
|
+
isCorrectDirection = dx > 0;
|
|
1107
|
+
directionScore = dx;
|
|
1108
|
+
break;
|
|
1109
|
+
case 'left':
|
|
1110
|
+
isCorrectDirection = dx < 0;
|
|
1111
|
+
directionScore = -dx;
|
|
1112
|
+
break;
|
|
1113
|
+
case 'down':
|
|
1114
|
+
isCorrectDirection = dy > 0;
|
|
1115
|
+
directionScore = dy;
|
|
1116
|
+
break;
|
|
1117
|
+
case 'up':
|
|
1118
|
+
isCorrectDirection = dy < 0;
|
|
1119
|
+
directionScore = -dy;
|
|
1120
|
+
break;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// Combine distance with direction preference
|
|
1124
|
+
if (isCorrectDirection) {
|
|
1125
|
+
const score = distance - (directionScore * 0.5);
|
|
1126
|
+
if (score < bestScore) {
|
|
1127
|
+
bestScore = score;
|
|
1128
|
+
bestNode = node;
|
|
1129
|
+
focusedNodeIndex = index;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
if (bestNode) {
|
|
1135
|
+
focusNodeAtIndex(focusedNodeIndex);
|
|
1136
|
+
announceNode(bestNode);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// Announce node to screen reader
|
|
1141
|
+
function announceNode(node) {
|
|
1142
|
+
if (!node) return;
|
|
1143
|
+
|
|
1144
|
+
const data = node.data();
|
|
1145
|
+
const message = `Memory ${data.id}: ${data.label}, Cluster ${data.cluster_id}, Importance ${data.importance} out of 10`;
|
|
1146
|
+
updateScreenReaderStatus(message);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// Update screen reader status
|
|
1150
|
+
function updateScreenReaderStatus(message) {
|
|
1151
|
+
let statusRegion = document.getElementById('graph-sr-status');
|
|
1152
|
+
if (!statusRegion) {
|
|
1153
|
+
statusRegion = document.createElement('div');
|
|
1154
|
+
statusRegion.id = 'graph-sr-status';
|
|
1155
|
+
statusRegion.setAttribute('role', 'status');
|
|
1156
|
+
statusRegion.setAttribute('aria-live', 'polite');
|
|
1157
|
+
statusRegion.setAttribute('aria-atomic', 'true');
|
|
1158
|
+
statusRegion.style.cssText = 'position:absolute; left:-10000px; width:1px; height:1px; overflow:hidden;';
|
|
1159
|
+
document.body.appendChild(statusRegion);
|
|
1160
|
+
}
|
|
1161
|
+
statusRegion.textContent = message;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
if (document.readyState === 'loading') {
|
|
1165
|
+
document.addEventListener('DOMContentLoaded', setupGraphEventListeners);
|
|
1166
|
+
} else {
|
|
1167
|
+
setupGraphEventListeners();
|
|
1168
|
+
}
|