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.
@@ -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
+ }