// ═══════════════════════════════════════════ // PROJECT MNEMOSYNE — TIMELINE SCRUBBER // ═══════════════════════════════════════════ // // Horizontal timeline bar overlay for scrolling through fact history. // Crystals outside the visible time window fade out. // // Issue: #1169 // ═══════════════════════════════════════════ const TimelineScrubber = (() => { let _container = null; let _bar = null; let _handle = null; let _labels = null; let _spatialMemory = null; let _rangeStart = 0; // 0-1 normalized let _rangeEnd = 1; // 0-1 normalized let _minTimestamp = null; let _maxTimestamp = null; let _active = false; const PRESETS = { 'hour': { label: 'Last Hour', ms: 3600000 }, 'day': { label: 'Last Day', ms: 86400000 }, 'week': { label: 'Last Week', ms: 604800000 }, 'all': { label: 'All Time', ms: Infinity } }; // ─── INIT ────────────────────────────────────────── function init(spatialMemory) { _spatialMemory = spatialMemory; _buildDOM(); _computeTimeRange(); console.info('[Mnemosyne] Timeline scrubber initialized'); } function _buildDOM() { _container = document.createElement('div'); _container.id = 'mnemosyne-timeline'; _container.style.cssText = ` position: fixed; bottom: 0; left: 0; right: 0; height: 48px; background: rgba(5, 5, 16, 0.85); border-top: 1px solid #1a2a4a; z-index: 1000; display: flex; align-items: center; padding: 0 16px; font-family: monospace; font-size: 12px; color: #8899aa; backdrop-filter: blur(8px); transition: opacity 0.3s; `; // Preset buttons const presetDiv = document.createElement('div'); presetDiv.style.cssText = 'display: flex; gap: 8px; margin-right: 16px;'; Object.entries(PRESETS).forEach(([key, preset]) => { const btn = document.createElement('button'); btn.textContent = preset.label; btn.style.cssText = ` background: #0a0f28; border: 1px solid #1a2a4a; color: #4af0c0; padding: 4px 8px; cursor: pointer; font-family: monospace; font-size: 11px; border-radius: 3px; transition: background 0.2s; `; btn.onmouseenter = () => btn.style.background = '#1a2a4a'; btn.onmouseleave = () => btn.style.background = '#0a0f28'; btn.onclick = () => _applyPreset(key); presetDiv.appendChild(btn); }); _container.appendChild(presetDiv); // Timeline bar _bar = document.createElement('div'); _bar.style.cssText = ` flex: 1; height: 20px; background: #0a0f28; border: 1px solid #1a2a4a; border-radius: 3px; position: relative; cursor: pointer; margin: 0 8px; `; // Handle (draggable range selector) _handle = document.createElement('div'); _handle.style.cssText = ` position: absolute; top: 0; left: 0%; width: 100%; height: 100%; background: rgba(74, 240, 192, 0.15); border-left: 2px solid #4af0c0; border-right: 2px solid #4af0c0; cursor: ew-resize; `; _bar.appendChild(_handle); _container.appendChild(_bar); // Labels _labels = document.createElement('div'); _labels.style.cssText = 'min-width: 200px; text-align: right; font-size: 11px;'; _labels.textContent = 'All Time'; _container.appendChild(_labels); // Drag handling let dragging = null; _handle.addEventListener('mousedown', (e) => { dragging = { startX: e.clientX, startLeft: parseFloat(_handle.style.left) || 0, startWidth: parseFloat(_handle.style.width) || 100 }; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!dragging) return; const barRect = _bar.getBoundingClientRect(); const dx = (e.clientX - dragging.startX) / barRect.width * 100; let newLeft = Math.max(0, Math.min(100 - dragging.startWidth, dragging.startLeft + dx)); _handle.style.left = newLeft + '%'; _rangeStart = newLeft / 100; _rangeEnd = (newLeft + dragging.startWidth) / 100; _applyFilter(); }); document.addEventListener('mouseup', () => { dragging = null; }); document.body.appendChild(_container); } function _computeTimeRange() { if (!_spatialMemory) return; const memories = _spatialMemory.getAllMemories(); if (memories.length === 0) return; let min = Infinity, max = -Infinity; memories.forEach(m => { const t = new Date(m.timestamp || 0).getTime(); if (t < min) min = t; if (t > max) max = t; }); _minTimestamp = min; _maxTimestamp = max; } function _applyPreset(key) { const preset = PRESETS[key]; if (!preset) return; if (preset.ms === Infinity) { _rangeStart = 0; _rangeEnd = 1; } else { const now = Date.now(); const range = _maxTimestamp - _minTimestamp; if (range <= 0) return; const cutoff = now - preset.ms; _rangeStart = Math.max(0, (cutoff - _minTimestamp) / range); _rangeEnd = 1; } _handle.style.left = (_rangeStart * 100) + '%'; _handle.style.width = ((_rangeEnd - _rangeStart) * 100) + '%'; _labels.textContent = preset.label; _applyFilter(); } function _applyFilter() { if (!_spatialMemory) return; const range = _maxTimestamp - _minTimestamp; if (range <= 0) return; const startMs = _minTimestamp + range * _rangeStart; const endMs = _minTimestamp + range * _rangeEnd; _spatialMemory.getCrystalMeshes().forEach(mesh => { const ts = new Date(mesh.userData.createdAt || 0).getTime(); if (ts >= startMs && ts <= endMs) { mesh.visible = true; // Smooth restore if (mesh.material) mesh.material.opacity = mesh.userData._savedOpacity || mesh.material.opacity; } else { // Fade out if (mesh.material) { mesh.userData._savedOpacity = mesh.userData._savedOpacity || mesh.material.opacity; mesh.material.opacity = 0.02; } } }); // Update label with date range const startStr = new Date(startMs).toLocaleDateString(); const endStr = new Date(endMs).toLocaleDateString(); _labels.textContent = startStr + ' — ' + endStr; } function update() { _computeTimeRange(); } function show() { if (_container) _container.style.display = 'flex'; _active = true; } function hide() { if (_container) _container.style.display = 'none'; _active = false; // Restore all crystals if (_spatialMemory) { _spatialMemory.getCrystalMeshes().forEach(mesh => { mesh.visible = true; if (mesh.material && mesh.userData._savedOpacity) { mesh.material.opacity = mesh.userData._savedOpacity; } }); } } function isActive() { return _active; } return { init, update, show, hide, isActive }; })(); export { TimelineScrubber };