diff --git a/nexus/components/timeline-scrubber.js b/nexus/components/timeline-scrubber.js new file mode 100644 index 00000000..0035f102 --- /dev/null +++ b/nexus/components/timeline-scrubber.js @@ -0,0 +1,205 @@ +// ═══════════════════════════════════════════ +// 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 };