- 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
292 lines
11 KiB
JavaScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
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 };
|