Co-authored-by: Alexander Whitestone <alexander@alexanderwhitestone.com> Co-committed-by: Alexander Whitestone <alexander@alexanderwhitestone.com>
206 lines
7.0 KiB
JavaScript
206 lines
7.0 KiB
JavaScript
// ═══════════════════════════════════════════
|
|
// 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 };
|