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,32 @@
|
|
|
1
|
+
// SuperLocalMemory V2 - Knowledge Graph (D3.js force-directed)
|
|
2
|
+
// Depends on: core.js, modal.js (openMemoryDetail)
|
|
3
|
+
|
|
4
|
+
var graphData = { nodes: [], links: [] };
|
|
5
|
+
|
|
6
|
+
async function loadGraph() {
|
|
7
|
+
var maxNodes = document.getElementById('graph-max-nodes').value;
|
|
8
|
+
try {
|
|
9
|
+
var response = await fetch('/api/graph?max_nodes=' + maxNodes);
|
|
10
|
+
graphData = await response.json();
|
|
11
|
+
renderGraph(graphData);
|
|
12
|
+
} catch (error) {
|
|
13
|
+
console.error('Error loading graph:', error);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function renderGraph(data) {
|
|
18
|
+
var container = document.getElementById('graph-container');
|
|
19
|
+
container.textContent = '';
|
|
20
|
+
var width = container.clientWidth || 1200;
|
|
21
|
+
var height = 600;
|
|
22
|
+
var svg = d3.select('#graph-container').append('svg').attr('width', width).attr('height', height);
|
|
23
|
+
var tooltip = d3.select('body').append('div').attr('class', 'tooltip-custom').style('opacity', 0);
|
|
24
|
+
var colorScale = d3.scaleOrdinal(d3.schemeCategory10);
|
|
25
|
+
var simulation = d3.forceSimulation(data.nodes).force('link', d3.forceLink(data.links).id(function(d) { return d.id; }).distance(100)).force('charge', d3.forceManyBody().strength(-200)).force('center', d3.forceCenter(width / 2, height / 2)).force('collision', d3.forceCollide().radius(20));
|
|
26
|
+
var link = svg.append('g').selectAll('line').data(data.links).enter().append('line').attr('class', 'link').attr('stroke-width', function(d) { return Math.sqrt(d.weight * 2); });
|
|
27
|
+
var node = svg.append('g').selectAll('circle').data(data.nodes).enter().append('circle').attr('class', 'node').attr('r', function(d) { return 5 + (d.importance || 5); }).attr('fill', function(d) { return colorScale(d.cluster_id || 0); }).call(d3.drag().on('start', dragStarted).on('drag', dragged).on('end', dragEnded)).on('mouseover', function(event, d) { tooltip.transition().duration(200).style('opacity', .9); var label = d.category || d.project_name || 'Memory #' + d.id; tooltip.text(label + ': ' + (d.content_preview || d.summary || 'No content')).style('left', (event.pageX + 10) + 'px').style('top', (event.pageY - 28) + 'px'); }).on('mouseout', function() { tooltip.transition().duration(500).style('opacity', 0); }).on('click', function(event, d) { openMemoryDetail(d); });
|
|
28
|
+
simulation.on('tick', function() { link.attr('x1', function(d) { return d.source.x; }).attr('y1', function(d) { return d.source.y; }).attr('x2', function(d) { return d.target.x; }).attr('y2', function(d) { return d.target.y; }); node.attr('cx', function(d) { return d.x; }).attr('cy', function(d) { return d.y; }); });
|
|
29
|
+
function dragStarted(event, d) { if (!event.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }
|
|
30
|
+
function dragged(event, d) { d.fx = event.x; d.fy = event.y; }
|
|
31
|
+
function dragEnded(event, d) { if (!event.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; }
|
|
32
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// SuperLocalMemory V2.6.5 - Interactive Knowledge Graph - Filtering Module
|
|
2
|
+
// Copyright (c) 2026 Varun Pratap Bhardwaj — MIT License
|
|
3
|
+
// Part of modular graph visualization system (split from monolithic graph-cytoscape.js)
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// FILTER LOGIC
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
function filterByCluster(data, cluster_id) {
|
|
10
|
+
console.log('[filterByCluster] Filtering for cluster_id:', cluster_id, 'Type:', typeof cluster_id);
|
|
11
|
+
console.log('[filterByCluster] Total nodes in data:', data.nodes.length);
|
|
12
|
+
|
|
13
|
+
// Convert to integer for comparison
|
|
14
|
+
const targetClusterId = parseInt(cluster_id);
|
|
15
|
+
let debugCount = 0;
|
|
16
|
+
|
|
17
|
+
const filteredNodes = data.nodes.filter(n => {
|
|
18
|
+
// Convert node cluster_id to integer for comparison
|
|
19
|
+
const nodeClusterId = n.cluster_id ? parseInt(n.cluster_id) : null;
|
|
20
|
+
|
|
21
|
+
// Log first 3 nodes to debug
|
|
22
|
+
if (debugCount < 3) {
|
|
23
|
+
console.log('[filterByCluster] Sample node:', {
|
|
24
|
+
id: n.id,
|
|
25
|
+
cluster_id: n.cluster_id,
|
|
26
|
+
type: typeof n.cluster_id,
|
|
27
|
+
parsed: nodeClusterId,
|
|
28
|
+
match: nodeClusterId === targetClusterId
|
|
29
|
+
});
|
|
30
|
+
debugCount++;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Use strict equality after type conversion
|
|
34
|
+
return nodeClusterId === targetClusterId;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
console.log('[filterByCluster] Filtered to', filteredNodes.length, 'nodes');
|
|
38
|
+
|
|
39
|
+
const nodeIds = new Set(filteredNodes.map(n => n.id));
|
|
40
|
+
const filteredLinks = data.links.filter(l =>
|
|
41
|
+
nodeIds.has(l.source) && nodeIds.has(l.target)
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
console.log('[filterByCluster] Found', filteredLinks.length, 'edges between filtered nodes');
|
|
45
|
+
|
|
46
|
+
return { nodes: filteredNodes, links: filteredLinks, clusters: data.clusters };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function filterByEntity(data, entity) {
|
|
50
|
+
const filteredNodes = data.nodes.filter(n => {
|
|
51
|
+
if (n.entities && Array.isArray(n.entities)) {
|
|
52
|
+
return n.entities.includes(entity);
|
|
53
|
+
}
|
|
54
|
+
if (n.tags && n.tags.includes(entity)) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
});
|
|
59
|
+
const nodeIds = new Set(filteredNodes.map(n => n.id));
|
|
60
|
+
const filteredLinks = data.links.filter(l =>
|
|
61
|
+
nodeIds.has(l.source) && nodeIds.has(l.target)
|
|
62
|
+
);
|
|
63
|
+
return { nodes: filteredNodes, links: filteredLinks, clusters: data.clusters };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function clearGraphFilters() {
|
|
67
|
+
console.log('[clearGraphFilters] Clearing all filters and reloading full graph');
|
|
68
|
+
|
|
69
|
+
// Clear filter state
|
|
70
|
+
filterState = { cluster_id: null, entity: null };
|
|
71
|
+
|
|
72
|
+
// CRITICAL: Clear URL completely - remove ?cluster_id=X from address bar
|
|
73
|
+
const cleanUrl = window.location.origin + window.location.pathname;
|
|
74
|
+
window.history.replaceState({}, '', cleanUrl);
|
|
75
|
+
console.log('[clearGraphFilters] URL cleaned to:', cleanUrl);
|
|
76
|
+
|
|
77
|
+
// CRITICAL: Clear saved layout positions so nodes don't stay in corner
|
|
78
|
+
// When switching from filtered to full graph, we want fresh layout
|
|
79
|
+
localStorage.removeItem('slm_graph_layout');
|
|
80
|
+
console.log('[clearGraphFilters] Cleared saved layout positions');
|
|
81
|
+
|
|
82
|
+
// Reload full graph from API (respects dropdown settings)
|
|
83
|
+
loadGraph();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ============================================================================
|
|
87
|
+
// EVENT HANDLERS
|
|
88
|
+
// ============================================================================
|
|
89
|
+
|
|
90
|
+
function setupGraphEventListeners() {
|
|
91
|
+
// Load graph when Graph tab is clicked
|
|
92
|
+
const graphTab = document.querySelector('a[href="#graph"]');
|
|
93
|
+
if (graphTab) {
|
|
94
|
+
// CRITICAL FIX: Add click handler to clear filter even when already on KG tab
|
|
95
|
+
// Bootstrap's shown.bs.tab only fires when SWITCHING TO tab, not when clicking same tab!
|
|
96
|
+
graphTab.addEventListener('click', function(event) {
|
|
97
|
+
console.log('[Event] Knowledge Graph tab CLICKED');
|
|
98
|
+
|
|
99
|
+
// Check if we're viewing a filtered graph
|
|
100
|
+
const hasFilter = filterState.cluster_id || filterState.entity;
|
|
101
|
+
|
|
102
|
+
if (hasFilter && cy) {
|
|
103
|
+
console.log('[Event] Click detected on KG tab while filter active - clearing filter');
|
|
104
|
+
|
|
105
|
+
// Clear filter state
|
|
106
|
+
filterState = { cluster_id: null, entity: null };
|
|
107
|
+
|
|
108
|
+
// Clear URL completely
|
|
109
|
+
const cleanUrl = window.location.origin + window.location.pathname;
|
|
110
|
+
window.history.replaceState({}, '', cleanUrl);
|
|
111
|
+
console.log('[Event] URL cleaned to:', cleanUrl);
|
|
112
|
+
|
|
113
|
+
// CRITICAL: Clear saved layout positions so nodes don't stay in corner
|
|
114
|
+
localStorage.removeItem('slm_graph_layout');
|
|
115
|
+
console.log('[Event] Cleared saved layout positions');
|
|
116
|
+
|
|
117
|
+
// Reload with full graph
|
|
118
|
+
loadGraph();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Also keep shown.bs.tab for when switching FROM another tab TO KG tab
|
|
123
|
+
graphTab.addEventListener('shown.bs.tab', function(event) {
|
|
124
|
+
console.log('[Event] Knowledge Graph tab SHOWN (tab switch)');
|
|
125
|
+
|
|
126
|
+
if (cy) {
|
|
127
|
+
// Graph already exists - user is returning to KG tab from another tab
|
|
128
|
+
// Clear filter and reload to show full graph
|
|
129
|
+
console.log('[Event] Returning to KG tab from another tab - clearing filter');
|
|
130
|
+
|
|
131
|
+
// Clear filter state
|
|
132
|
+
filterState = { cluster_id: null, entity: null };
|
|
133
|
+
|
|
134
|
+
// Clear URL completely
|
|
135
|
+
const cleanUrl = window.location.origin + window.location.pathname;
|
|
136
|
+
window.history.replaceState({}, '', cleanUrl);
|
|
137
|
+
console.log('[Event] URL cleaned to:', cleanUrl);
|
|
138
|
+
|
|
139
|
+
// CRITICAL: Clear saved layout positions so nodes don't stay in corner
|
|
140
|
+
localStorage.removeItem('slm_graph_layout');
|
|
141
|
+
console.log('[Event] Cleared saved layout positions');
|
|
142
|
+
|
|
143
|
+
// Reload with full graph (respects dropdown settings)
|
|
144
|
+
loadGraph();
|
|
145
|
+
} else {
|
|
146
|
+
// First load - check if cluster filter is in URL (from cluster badge click)
|
|
147
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
148
|
+
const clusterIdParam = urlParams.get('cluster_id');
|
|
149
|
+
|
|
150
|
+
if (clusterIdParam) {
|
|
151
|
+
console.log('[Event] First load with cluster filter:', clusterIdParam);
|
|
152
|
+
// Load with filter (will be applied in loadGraph)
|
|
153
|
+
} else {
|
|
154
|
+
console.log('[Event] First load, no filter');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
loadGraph();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Reload graph when dropdown settings change
|
|
163
|
+
const maxNodesSelect = document.getElementById('graph-max-nodes');
|
|
164
|
+
if (maxNodesSelect) {
|
|
165
|
+
maxNodesSelect.addEventListener('change', function() {
|
|
166
|
+
console.log('[Event] Max nodes changed to:', this.value);
|
|
167
|
+
|
|
168
|
+
// Clear any active filter when user manually changes settings
|
|
169
|
+
if (filterState.cluster_id || filterState.entity) {
|
|
170
|
+
console.log('[Event] Clearing filter due to dropdown change');
|
|
171
|
+
filterState = { cluster_id: null, entity: null };
|
|
172
|
+
|
|
173
|
+
// Clear URL
|
|
174
|
+
const cleanUrl = window.location.origin + window.location.pathname;
|
|
175
|
+
window.history.replaceState({}, '', cleanUrl);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Clear saved layout when changing node count (different # of nodes = different layout)
|
|
179
|
+
localStorage.removeItem('slm_graph_layout');
|
|
180
|
+
console.log('[Event] Cleared saved layout due to settings change');
|
|
181
|
+
|
|
182
|
+
loadGraph();
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const minImportanceSelect = document.getElementById('graph-min-importance');
|
|
187
|
+
if (minImportanceSelect) {
|
|
188
|
+
minImportanceSelect.addEventListener('change', function() {
|
|
189
|
+
console.log('[Event] Min importance changed to:', this.value);
|
|
190
|
+
|
|
191
|
+
// Clear any active filter when user manually changes settings
|
|
192
|
+
if (filterState.cluster_id || filterState.entity) {
|
|
193
|
+
console.log('[Event] Clearing filter due to dropdown change');
|
|
194
|
+
filterState = { cluster_id: null, entity: null };
|
|
195
|
+
|
|
196
|
+
// Clear URL
|
|
197
|
+
const cleanUrl = window.location.origin + window.location.pathname;
|
|
198
|
+
window.history.replaceState({}, '', cleanUrl);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Clear saved layout when changing importance (different nodes = different layout)
|
|
202
|
+
localStorage.removeItem('slm_graph_layout');
|
|
203
|
+
console.log('[Event] Cleared saved layout due to settings change');
|
|
204
|
+
|
|
205
|
+
loadGraph();
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
console.log('[Init] Graph event listeners setup complete');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ============================================================================
|
|
213
|
+
// INITIALIZATION
|
|
214
|
+
// ============================================================================
|
|
215
|
+
|
|
216
|
+
if (document.readyState === 'loading') {
|
|
217
|
+
document.addEventListener('DOMContentLoaded', setupGraphEventListeners);
|
|
218
|
+
} else {
|
|
219
|
+
setupGraphEventListeners();
|
|
220
|
+
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// SuperLocalMemory V2.6.5 - Interactive Knowledge Graph - Interactions Module
|
|
2
|
+
// Copyright (c) 2026 Varun Pratap Bhardwaj — MIT License
|
|
3
|
+
// Part of modular graph visualization system (split from monolithic graph-cytoscape.js)
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// CYTOSCAPE INTERACTIONS
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
function addCytoscapeInteractions() {
|
|
10
|
+
if (!cy) return;
|
|
11
|
+
|
|
12
|
+
// Hover: Show tooltip
|
|
13
|
+
cy.on('mouseover', 'node', function(evt) {
|
|
14
|
+
const node = evt.target;
|
|
15
|
+
const pos = evt.renderedPosition;
|
|
16
|
+
showTooltip(node, pos.x, pos.y);
|
|
17
|
+
|
|
18
|
+
// Highlight connected nodes
|
|
19
|
+
node.addClass('highlighted');
|
|
20
|
+
node.connectedEdges().addClass('highlighted');
|
|
21
|
+
node.neighborhood('node').addClass('highlighted');
|
|
22
|
+
|
|
23
|
+
// Dim others
|
|
24
|
+
cy.nodes().not(node).not(node.neighborhood('node')).addClass('dimmed');
|
|
25
|
+
cy.edges().not(node.connectedEdges()).addClass('dimmed');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
cy.on('mouseout', 'node', function(evt) {
|
|
29
|
+
hideTooltip();
|
|
30
|
+
|
|
31
|
+
// Remove highlighting
|
|
32
|
+
cy.elements().removeClass('highlighted').removeClass('dimmed');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Single click: Open modal preview
|
|
36
|
+
cy.on('tap', 'node', function(evt) {
|
|
37
|
+
const node = evt.target;
|
|
38
|
+
openMemoryModal(node);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Double click: Navigate to Memories tab
|
|
42
|
+
cy.on('dbltap', 'node', function(evt) {
|
|
43
|
+
const node = evt.target;
|
|
44
|
+
navigateToMemoryTab(node.data('id'));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Enable node dragging
|
|
48
|
+
cy.on('drag', 'node', function(evt) {
|
|
49
|
+
// Node position is automatically updated by Cytoscape
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Save layout when drag ends
|
|
53
|
+
cy.on('dragfree', 'node', function(evt) {
|
|
54
|
+
saveLayoutPositions();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Pan & zoom events (for performance monitoring)
|
|
58
|
+
let panZoomTimeout;
|
|
59
|
+
cy.on('pan zoom', function() {
|
|
60
|
+
clearTimeout(panZoomTimeout);
|
|
61
|
+
panZoomTimeout = setTimeout(() => {
|
|
62
|
+
saveLayoutPositions();
|
|
63
|
+
}, 1000);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Add keyboard navigation
|
|
67
|
+
setupKeyboardNavigation();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ============================================================================
|
|
71
|
+
// TOOLTIP
|
|
72
|
+
// ============================================================================
|
|
73
|
+
|
|
74
|
+
let tooltipTimeout;
|
|
75
|
+
function showTooltip(node, x, y) {
|
|
76
|
+
clearTimeout(tooltipTimeout);
|
|
77
|
+
tooltipTimeout = setTimeout(() => {
|
|
78
|
+
let tooltip = document.getElementById('graph-tooltip');
|
|
79
|
+
if (!tooltip) {
|
|
80
|
+
tooltip = document.createElement('div');
|
|
81
|
+
tooltip.id = 'graph-tooltip';
|
|
82
|
+
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);';
|
|
83
|
+
document.body.appendChild(tooltip);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Build tooltip content safely (no innerHTML)
|
|
87
|
+
tooltip.textContent = ''; // Clear
|
|
88
|
+
const data = node.data();
|
|
89
|
+
|
|
90
|
+
const title = document.createElement('strong');
|
|
91
|
+
title.textContent = data.label;
|
|
92
|
+
tooltip.appendChild(title);
|
|
93
|
+
|
|
94
|
+
tooltip.appendChild(document.createElement('br'));
|
|
95
|
+
|
|
96
|
+
const meta = document.createElement('span');
|
|
97
|
+
meta.style.color = '#aaa';
|
|
98
|
+
meta.textContent = `Cluster ${data.cluster_id} • Importance ${data.importance}`;
|
|
99
|
+
tooltip.appendChild(meta);
|
|
100
|
+
|
|
101
|
+
tooltip.appendChild(document.createElement('br'));
|
|
102
|
+
|
|
103
|
+
const preview = document.createElement('span');
|
|
104
|
+
preview.style.cssText = 'font-size:11px; color:#ccc;';
|
|
105
|
+
preview.textContent = data.content_preview;
|
|
106
|
+
tooltip.appendChild(preview);
|
|
107
|
+
|
|
108
|
+
tooltip.style.display = 'block';
|
|
109
|
+
tooltip.style.left = (x + 20) + 'px';
|
|
110
|
+
tooltip.style.top = (y - 20) + 'px';
|
|
111
|
+
}, 200);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function hideTooltip() {
|
|
115
|
+
clearTimeout(tooltipTimeout);
|
|
116
|
+
const tooltip = document.getElementById('graph-tooltip');
|
|
117
|
+
if (tooltip) {
|
|
118
|
+
tooltip.style.display = 'none';
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ============================================================================
|
|
123
|
+
// MODAL INTEGRATION
|
|
124
|
+
// ============================================================================
|
|
125
|
+
|
|
126
|
+
function openMemoryModal(node) {
|
|
127
|
+
const memoryData = {
|
|
128
|
+
id: node.data('id'),
|
|
129
|
+
content: node.data('content'),
|
|
130
|
+
summary: node.data('summary'),
|
|
131
|
+
category: node.data('category'),
|
|
132
|
+
project_name: node.data('project_name'),
|
|
133
|
+
cluster_id: node.data('cluster_id'),
|
|
134
|
+
importance: node.data('importance'),
|
|
135
|
+
tags: node.data('tags'),
|
|
136
|
+
created_at: node.data('created_at')
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Call existing openMemoryDetail function from modal.js
|
|
140
|
+
if (typeof openMemoryDetail === 'function') {
|
|
141
|
+
openMemoryDetail(memoryData);
|
|
142
|
+
} else {
|
|
143
|
+
console.error('openMemoryDetail function not found. Is modal.js loaded?');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function navigateToMemoryTab(memoryId) {
|
|
148
|
+
// Switch to Memories tab
|
|
149
|
+
const memoriesTab = document.querySelector('a[href="#memories"]');
|
|
150
|
+
if (memoriesTab) {
|
|
151
|
+
memoriesTab.click();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Scroll to memory after a short delay (for tab to load)
|
|
155
|
+
setTimeout(() => {
|
|
156
|
+
if (typeof scrollToMemory === 'function') {
|
|
157
|
+
scrollToMemory(memoryId);
|
|
158
|
+
} else {
|
|
159
|
+
console.warn('scrollToMemory function not found in memories.js');
|
|
160
|
+
}
|
|
161
|
+
}, 300);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ============================================================================
|
|
165
|
+
// KEYBOARD NAVIGATION
|
|
166
|
+
// ============================================================================
|
|
167
|
+
|
|
168
|
+
function setupKeyboardNavigation() {
|
|
169
|
+
if (!cy) return;
|
|
170
|
+
|
|
171
|
+
const container = document.getElementById('graph-container');
|
|
172
|
+
if (!container) return;
|
|
173
|
+
|
|
174
|
+
// Make container focusable
|
|
175
|
+
container.setAttribute('tabindex', '0');
|
|
176
|
+
|
|
177
|
+
// Focus handler - enable keyboard nav when container is focused
|
|
178
|
+
container.addEventListener('focus', function() {
|
|
179
|
+
keyboardNavigationEnabled = true;
|
|
180
|
+
if (cy.nodes().length > 0) {
|
|
181
|
+
focusNodeAtIndex(0);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
container.addEventListener('blur', function() {
|
|
186
|
+
keyboardNavigationEnabled = false;
|
|
187
|
+
cy.nodes().removeClass('keyboard-focused');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// Keyboard event handler
|
|
191
|
+
container.addEventListener('keydown', function(e) {
|
|
192
|
+
if (!keyboardNavigationEnabled || !cy) return;
|
|
193
|
+
|
|
194
|
+
const nodes = cy.nodes();
|
|
195
|
+
if (nodes.length === 0) return;
|
|
196
|
+
|
|
197
|
+
const currentNode = nodes[focusedNodeIndex];
|
|
198
|
+
|
|
199
|
+
switch(e.key) {
|
|
200
|
+
case 'Tab':
|
|
201
|
+
e.preventDefault();
|
|
202
|
+
if (e.shiftKey) {
|
|
203
|
+
// Shift+Tab: previous node
|
|
204
|
+
focusedNodeIndex = (focusedNodeIndex - 1 + nodes.length) % nodes.length;
|
|
205
|
+
} else {
|
|
206
|
+
// Tab: next node
|
|
207
|
+
focusedNodeIndex = (focusedNodeIndex + 1) % nodes.length;
|
|
208
|
+
}
|
|
209
|
+
focusNodeAtIndex(focusedNodeIndex);
|
|
210
|
+
announceNode(nodes[focusedNodeIndex]);
|
|
211
|
+
break;
|
|
212
|
+
|
|
213
|
+
case 'Enter':
|
|
214
|
+
case ' ':
|
|
215
|
+
e.preventDefault();
|
|
216
|
+
if (currentNode) {
|
|
217
|
+
lastFocusedElement = container;
|
|
218
|
+
openMemoryModal(currentNode);
|
|
219
|
+
}
|
|
220
|
+
break;
|
|
221
|
+
|
|
222
|
+
case 'ArrowRight':
|
|
223
|
+
e.preventDefault();
|
|
224
|
+
moveToAdjacentNode('right', currentNode);
|
|
225
|
+
break;
|
|
226
|
+
|
|
227
|
+
case 'ArrowLeft':
|
|
228
|
+
e.preventDefault();
|
|
229
|
+
moveToAdjacentNode('left', currentNode);
|
|
230
|
+
break;
|
|
231
|
+
|
|
232
|
+
case 'ArrowDown':
|
|
233
|
+
e.preventDefault();
|
|
234
|
+
moveToAdjacentNode('down', currentNode);
|
|
235
|
+
break;
|
|
236
|
+
|
|
237
|
+
case 'ArrowUp':
|
|
238
|
+
e.preventDefault();
|
|
239
|
+
moveToAdjacentNode('up', currentNode);
|
|
240
|
+
break;
|
|
241
|
+
|
|
242
|
+
case 'Escape':
|
|
243
|
+
e.preventDefault();
|
|
244
|
+
if (filterState.cluster_id || filterState.entity) {
|
|
245
|
+
clearGraphFilters();
|
|
246
|
+
updateScreenReaderStatus('Filters cleared, showing all memories');
|
|
247
|
+
} else {
|
|
248
|
+
container.blur();
|
|
249
|
+
keyboardNavigationEnabled = false;
|
|
250
|
+
}
|
|
251
|
+
break;
|
|
252
|
+
|
|
253
|
+
case 'Home':
|
|
254
|
+
e.preventDefault();
|
|
255
|
+
focusedNodeIndex = 0;
|
|
256
|
+
focusNodeAtIndex(0);
|
|
257
|
+
announceNode(nodes[0]);
|
|
258
|
+
break;
|
|
259
|
+
|
|
260
|
+
case 'End':
|
|
261
|
+
e.preventDefault();
|
|
262
|
+
focusedNodeIndex = nodes.length - 1;
|
|
263
|
+
focusNodeAtIndex(focusedNodeIndex);
|
|
264
|
+
announceNode(nodes[focusedNodeIndex]);
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function focusNodeAtIndex(index) {
|
|
271
|
+
if (!cy) return;
|
|
272
|
+
|
|
273
|
+
const nodes = cy.nodes();
|
|
274
|
+
if (index < 0 || index >= nodes.length) return;
|
|
275
|
+
|
|
276
|
+
// Remove focus from all nodes
|
|
277
|
+
cy.nodes().removeClass('keyboard-focused');
|
|
278
|
+
|
|
279
|
+
// Add focus to target node
|
|
280
|
+
const node = nodes[index];
|
|
281
|
+
node.addClass('keyboard-focused');
|
|
282
|
+
|
|
283
|
+
// Center node in viewport with smooth animation
|
|
284
|
+
cy.animate({
|
|
285
|
+
center: { eles: node },
|
|
286
|
+
zoom: Math.max(cy.zoom(), 1.0),
|
|
287
|
+
duration: 300,
|
|
288
|
+
easing: 'ease-in-out'
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function moveToAdjacentNode(direction, currentNode) {
|
|
293
|
+
if (!currentNode) return;
|
|
294
|
+
|
|
295
|
+
const nodes = cy.nodes();
|
|
296
|
+
const currentPos = currentNode.position();
|
|
297
|
+
let bestNode = null;
|
|
298
|
+
let bestScore = Infinity;
|
|
299
|
+
|
|
300
|
+
// Find adjacent nodes based on direction
|
|
301
|
+
nodes.forEach((node, index) => {
|
|
302
|
+
if (node.id() === currentNode.id()) return;
|
|
303
|
+
|
|
304
|
+
const pos = node.position();
|
|
305
|
+
const dx = pos.x - currentPos.x;
|
|
306
|
+
const dy = pos.y - currentPos.y;
|
|
307
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
308
|
+
|
|
309
|
+
let isCorrectDirection = false;
|
|
310
|
+
let directionScore = 0;
|
|
311
|
+
|
|
312
|
+
switch(direction) {
|
|
313
|
+
case 'right':
|
|
314
|
+
isCorrectDirection = dx > 0;
|
|
315
|
+
directionScore = dx;
|
|
316
|
+
break;
|
|
317
|
+
case 'left':
|
|
318
|
+
isCorrectDirection = dx < 0;
|
|
319
|
+
directionScore = -dx;
|
|
320
|
+
break;
|
|
321
|
+
case 'down':
|
|
322
|
+
isCorrectDirection = dy > 0;
|
|
323
|
+
directionScore = dy;
|
|
324
|
+
break;
|
|
325
|
+
case 'up':
|
|
326
|
+
isCorrectDirection = dy < 0;
|
|
327
|
+
directionScore = -dy;
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Combine distance with direction preference
|
|
332
|
+
if (isCorrectDirection) {
|
|
333
|
+
const score = distance - (directionScore * 0.5);
|
|
334
|
+
if (score < bestScore) {
|
|
335
|
+
bestScore = score;
|
|
336
|
+
bestNode = node;
|
|
337
|
+
focusedNodeIndex = index;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
if (bestNode) {
|
|
343
|
+
focusNodeAtIndex(focusedNodeIndex);
|
|
344
|
+
announceNode(bestNode);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function announceNode(node) {
|
|
349
|
+
if (!node) return;
|
|
350
|
+
|
|
351
|
+
const data = node.data();
|
|
352
|
+
const message = `Memory ${data.id}: ${data.label}, Cluster ${data.cluster_id}, Importance ${data.importance} out of 10`;
|
|
353
|
+
updateScreenReaderStatus(message);
|
|
354
|
+
}
|