Files
the-nexus/nexus/components/memory-connections.js
Alexander Whitestone 58c75a29bd feat(mnemosyne): Memory Connection Panel — interactive connection management
- Browse all connections from a selected memory crystal
- Suggested connections from same region + nearby memories
- Add/remove connections with bidirectional sync
- Hover highlights connected crystals in 3D world
- Navigate to connected memories via click
- Clean slide-in panel UI matching Mnemosyne aesthetic
2026-04-11 21:46:47 -04:00

292 lines
11 KiB
JavaScript

// ═══════════════════════════════════════════════════════════
// MNEMOSYNE — Memory Connection Panel
// ═══════════════════════════════════════════════════════════
//
// Interactive panel for browsing, adding, and removing memory
// connections. Opens as a sub-panel from MemoryInspect when
// a memory crystal is selected.
//
// Usage from app.js:
// MemoryConnections.init({
// onNavigate: fn(memId), // fly to another memory
// onConnectionChange: fn(memId, newConnections) // update hooks
// });
// MemoryConnections.show(memData, allMemories);
// MemoryConnections.hide();
//
// Depends on: SpatialMemory (for updateMemory + highlightMemory)
// ═══════════════════════════════════════════════════════════
const MemoryConnections = (() => {
let _panel = null;
let _onNavigate = null;
let _onConnectionChange = null;
let _currentMemId = null;
let _hoveredConnId = null;
// ─── INIT ────────────────────────────────────────────────
function init(opts = {}) {
_onNavigate = opts.onNavigate || null;
_onConnectionChange = opts.onConnectionChange || null;
_panel = document.getElementById('memory-connections-panel');
if (!_panel) {
console.warn('[MemoryConnections] Panel element #memory-connections-panel not found in DOM');
}
}
// ─── SHOW ────────────────────────────────────────────────
function show(memData, allMemories) {
if (!_panel || !memData) return;
_currentMemId = memData.id;
const connections = memData.connections || [];
const connectedSet = new Set(connections);
// Build lookup for connected memories
const memLookup = {};
(allMemories || []).forEach(m => { memLookup[m.id] = m; });
// Connected memories list
let connectedHtml = '';
if (connections.length > 0) {
connectedHtml = connections.map(cid => {
const cm = memLookup[cid];
const label = cm ? _truncate(cm.content || cid, 40) : cid;
const cat = cm ? cm.category : '';
const strength = cm ? Math.round((cm.strength || 0.7) * 100) : 70;
return `
<div class="mc-conn-item" data-memid="${_esc(cid)}">
<div class="mc-conn-info">
<span class="mc-conn-label" title="${_esc(cid)}">${_esc(label)}</span>
<span class="mc-conn-meta">${_esc(cat)} · ${strength}%</span>
</div>
<div class="mc-conn-actions">
<button class="mc-btn mc-btn-nav" data-nav="${_esc(cid)}" title="Navigate to memory">⮞</button>
<button class="mc-btn mc-btn-remove" data-remove="${_esc(cid)}" title="Remove connection">✕</button>
</div>
</div>`;
}).join('');
} else {
connectedHtml = '<div class="mc-empty">No connections yet</div>';
}
// Find nearby unconnected memories (same region, then other regions)
const suggestions = _findSuggestions(memData, allMemories, connectedSet);
let suggestHtml = '';
if (suggestions.length > 0) {
suggestHtml = suggestions.map(s => {
const label = _truncate(s.content || s.id, 36);
const cat = s.category || '';
const proximity = s._proximity || '';
return `
<div class="mc-suggest-item" data-memid="${_esc(s.id)}">
<div class="mc-suggest-info">
<span class="mc-suggest-label" title="${_esc(s.id)}">${_esc(label)}</span>
<span class="mc-suggest-meta">${_esc(cat)} · ${_esc(proximity)}</span>
</div>
<button class="mc-btn mc-btn-add" data-add="${_esc(s.id)}" title="Add connection">+</button>
</div>`;
}).join('');
} else {
suggestHtml = '<div class="mc-empty">No nearby memories to connect</div>';
}
_panel.innerHTML = `
<div class="mc-header">
<span class="mc-title">⬡ Connections</span>
<button class="mc-close" id="mc-close-btn" aria-label="Close connections panel">✕</button>
</div>
<div class="mc-section">
<div class="mc-section-label">LINKED (${connections.length})</div>
<div class="mc-conn-list" id="mc-conn-list">${connectedHtml}</div>
</div>
<div class="mc-section">
<div class="mc-section-label">SUGGESTED</div>
<div class="mc-suggest-list" id="mc-suggest-list">${suggestHtml}</div>
</div>
`;
// Wire close button
_panel.querySelector('#mc-close-btn')?.addEventListener('click', hide);
// Wire navigation buttons
_panel.querySelectorAll('[data-nav]').forEach(btn => {
btn.addEventListener('click', () => {
if (_onNavigate) _onNavigate(btn.dataset.nav);
});
});
// Wire remove buttons
_panel.querySelectorAll('[data-remove]').forEach(btn => {
btn.addEventListener('click', () => _removeConnection(btn.dataset.remove));
});
// Wire add buttons
_panel.querySelectorAll('[data-add]').forEach(btn => {
btn.addEventListener('click', () => _addConnection(btn.dataset.add));
});
// Wire hover highlight for connection items
_panel.querySelectorAll('.mc-conn-item').forEach(item => {
item.addEventListener('mouseenter', () => _highlightConnection(item.dataset.memid));
item.addEventListener('mouseleave', _clearConnectionHighlight);
});
_panel.style.display = 'flex';
requestAnimationFrame(() => _panel.classList.add('mc-visible'));
}
// ─── HIDE ────────────────────────────────────────────────
function hide() {
if (!_panel) return;
_clearConnectionHighlight();
_panel.classList.remove('mc-visible');
const onEnd = () => {
_panel.style.display = 'none';
_panel.removeEventListener('transitionend', onEnd);
};
_panel.addEventListener('transitionend', onEnd);
setTimeout(() => { if (_panel) _panel.style.display = 'none'; }, 350);
_currentMemId = null;
}
// ─── SUGGESTION ENGINE ──────────────────────────────────
function _findSuggestions(memData, allMemories, connectedSet) {
if (!allMemories) return [];
const suggestions = [];
const pos = memData.position || [0, 0, 0];
const sameRegion = memData.category || 'working';
for (const m of allMemories) {
if (m.id === memData.id) continue;
if (connectedSet.has(m.id)) continue;
const mpos = m.position || [0, 0, 0];
const dist = Math.sqrt(
(pos[0] - mpos[0]) ** 2 +
(pos[1] - mpos[1]) ** 2 +
(pos[2] - mpos[2]) ** 2
);
// Categorize proximity
let proximity = 'nearby';
if (m.category === sameRegion) {
proximity = dist < 5 ? 'same region · close' : 'same region';
} else {
proximity = dist < 10 ? 'adjacent' : 'distant';
}
suggestions.push({ ...m, _dist: dist, _proximity: proximity });
}
// Sort: same region first, then by distance
suggestions.sort((a, b) => {
const aSame = a.category === sameRegion ? 0 : 1;
const bSame = b.category === sameRegion ? 0 : 1;
if (aSame !== bSame) return aSame - bSame;
return a._dist - b._dist;
});
return suggestions.slice(0, 8); // Cap at 8 suggestions
}
// ─── CONNECTION ACTIONS ─────────────────────────────────
function _addConnection(targetId) {
if (!_currentMemId) return;
// Get current memory data via SpatialMemory
const allMems = typeof SpatialMemory !== 'undefined' ? SpatialMemory.getAllMemories() : [];
const current = allMems.find(m => m.id === _currentMemId);
if (!current) return;
const conns = [...(current.connections || [])];
if (conns.includes(targetId)) return;
conns.push(targetId);
// Update SpatialMemory
if (typeof SpatialMemory !== 'undefined') {
SpatialMemory.updateMemory(_currentMemId, { connections: conns });
}
// Also create reverse connection on target
const target = allMems.find(m => m.id === targetId);
if (target) {
const targetConns = [...(target.connections || [])];
if (!targetConns.includes(_currentMemId)) {
targetConns.push(_currentMemId);
SpatialMemory.updateMemory(targetId, { connections: targetConns });
}
}
if (_onConnectionChange) _onConnectionChange(_currentMemId, conns);
// Re-render panel
const updatedMem = { ...current, connections: conns };
show(updatedMem, allMems);
}
function _removeConnection(targetId) {
if (!_currentMemId) return;
const allMems = typeof SpatialMemory !== 'undefined' ? SpatialMemory.getAllMemories() : [];
const current = allMems.find(m => m.id === _currentMemId);
if (!current) return;
const conns = (current.connections || []).filter(c => c !== targetId);
if (typeof SpatialMemory !== 'undefined') {
SpatialMemory.updateMemory(_currentMemId, { connections: conns });
}
// Also remove reverse connection
const target = allMems.find(m => m.id === targetId);
if (target) {
const targetConns = (target.connections || []).filter(c => c !== _currentMemId);
SpatialMemory.updateMemory(targetId, { connections: targetConns });
}
if (_onConnectionChange) _onConnectionChange(_currentMemId, conns);
const updatedMem = { ...current, connections: conns };
show(updatedMem, allMems);
}
// ─── 3D HIGHLIGHT ───────────────────────────────────────
function _highlightConnection(memId) {
_hoveredConnId = memId;
if (typeof SpatialMemory !== 'undefined') {
SpatialMemory.highlightMemory(memId);
}
}
function _clearConnectionHighlight() {
if (_hoveredConnId && typeof SpatialMemory !== 'undefined') {
SpatialMemory.clearHighlight();
}
_hoveredConnId = null;
}
// ─── HELPERS ────────────────────────────────────────────
function _esc(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function _truncate(str, n) {
return str.length > n ? str.slice(0, n - 1) + '\u2026' : str;
}
function isOpen() {
return _panel != null && _panel.style.display !== 'none';
}
return { init, show, hide, isOpen };
})();
export { MemoryConnections };