diff --git a/app/templates/mission_timeline_preview.html b/app/templates/mission_timeline_preview.html index 5ad74f6..f24b95f 100644 --- a/app/templates/mission_timeline_preview.html +++ b/app/templates/mission_timeline_preview.html @@ -185,9 +185,9 @@
- - - + + + Scroll to zoom · Drag to pan
@@ -203,46 +203,109 @@ let tlData = jsonEl ? JSON.parse(jsonEl.textContent) : null; if (!tlData || !tlData.rows) return; const DAY_MS = 86400000; - const MIN_BUCKETS = 3, MAX_BUCKETS = 200; + const NAME_W = 130; + const TRACK_TOP = 10; + const SEG_H = 18; + const SEG_GAP = 4; + const EVENT_H = 18; + const EVENT_GAP = 4; + const FLOAT_LABEL_STYLE = 'font-size:.52rem;line-height:1;color:var(--text);background:var(--surface);border:1px solid var(--border);border-radius:3px;padding:2px 4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:120px;box-shadow:0 6px 16px rgba(0,0,0,.18)'; + const initialRangeStart = tlData.range_start; + const initialRangeEnd = tlData.range_end; + const extentStart = parseISO(initialRangeStart); + const extentEnd = parseISO(initialRangeEnd); + let zoomLevel = 1; - function parseISO(s) { if(!s) return null; return new Date(s.replace(' ','T')+(s.includes('T')?'':':00')); } - function fmtDate(d) { return d.toISOString().slice(0,10); } - function fmtDisplay(d) { const y=d.getFullYear(); const m=String(d.getMonth()+1).padStart(2,'0'); const dd=String(d.getDate()).padStart(2,'0'); return y+'-'+m+'-'+dd; } + function pad(n) { return String(n).padStart(2, '0'); } + function parseISO(s) { + if (!s) return null; + let value = String(s).replace(' ', 'T'); + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) value += 'T00:00'; + return new Date(value); + } + function fmtDate(d) { return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); } + function fmtDisplay(d) { return fmtDate(d); } + function esc(value) { + return String(value == null ? '' : value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } // Determine scale mode from range span function autoScale(from, to) { - const days = (to - from) / DAY_MS; - if (days <= 60) return 'day'; - if (days <= 730) return 'month'; + const days = (to - from) / DAY_MS / Math.max(zoomLevel, 1); + if (days <= 120) return 'day'; + if (days <= 365 * 4) return 'month'; return 'year'; } + function explicitScale() { + const params = new URLSearchParams(window.location.search); + const select = document.getElementById('tl-scale'); + const value = select ? select.value : ''; + return params.has('scale') && value ? value : ''; + } + + function resolveScale(from, to) { + return explicitScale() || autoScale(from, to); + } + + function startOfBucket(date, scale) { + if (scale === 'year') return new Date(date.getFullYear(), 0, 1); + if (scale === 'month') return new Date(date.getFullYear(), date.getMonth(), 1); + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); + } + + function nextBucket(date, scale) { + const d = startOfBucket(date, scale); + if (scale === 'year') return new Date(d.getFullYear() + 1, 0, 1); + if (scale === 'month') return new Date(d.getFullYear(), d.getMonth() + 1, 1); + return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1); + } + + function bucketDelta(origin, bucketStart, scale) { + if (scale === 'year') return bucketStart.getFullYear() - origin.getFullYear(); + if (scale === 'month') { + return (bucketStart.getFullYear() - origin.getFullYear()) * 12 + bucketStart.getMonth() - origin.getMonth(); + } + return Math.round((bucketStart - origin) / DAY_MS); + } + // Build scale labels function buildScaleLabels(from, to, scale) { let labels = []; + let d = startOfBucket(from, scale); + const origin = new Date(d); + const boundary = startOfBucket(to, scale); if (scale === 'day') { - let d = new Date(from); d.setHours(0,0,0,0); - while (d <= to) { labels.push(fmtDisplay(d)); d.setDate(d.getDate()+1); } + while (d <= boundary) { labels.push(fmtDisplay(d)); d = nextBucket(d, scale); } } else if (scale === 'month') { - let d = new Date(from.getFullYear(), from.getMonth(), 1); - while (d <= to) { + while (d <= boundary) { labels.push(d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')); - d.setMonth(d.getMonth()+1); + d = nextBucket(d, scale); } } else { - let y = from.getFullYear(); - while (y <= to.getFullYear()) { labels.push(String(y)); y++; } + while (d <= boundary) { + labels.push(String(d.getFullYear())); + d = nextBucket(d, scale); + } } - return { labels: labels, count: labels.length }; + return { labels: labels, count: labels.length, origin: origin }; } // Client-side position recalculation from ISO timestamps - function calcPosition(isoStr, from, to, bucketCount) { + function calcPosition(isoStr, origin, scale, bucketCount) { if (!isoStr) return 0; const t = parseISO(isoStr); - if (!t || t <= from) return 0; - if (t >= to) return bucketCount; - return ((t - from) / (to - from)) * bucketCount; + if (!t || t <= origin) return 0; + const bucketStart = startOfBucket(t, scale); + const bucketEnd = nextBucket(bucketStart, scale); + const index = bucketDelta(origin, bucketStart, scale); + const fraction = Math.max(0, Math.min(1, (t - bucketStart) / Math.max(bucketEnd - bucketStart, 1))); + return Math.max(0, Math.min(bucketCount, index + fraction)); } function bucketW(scale) { @@ -251,12 +314,16 @@ return 250; } - const SEG_H = 18, SEG_GAP = 2; + function overviewTrackWidth() { + const from = extentStart || parseISO(tlData.range_start); + const to = extentEnd || parseISO(tlData.range_end); + return buildScaleLabels(from, to, 'year').count * bucketW('year'); + } - function assignLanes(segments, from, to, bucketCount) { + function assignLanes(segments, origin, scale, bucketCount) { const items = segments.map(function(s) { - const sp = calcPosition(s.start_iso, from, to, bucketCount); - const ep = s.end_iso ? calcPosition(s.end_iso, from, to, bucketCount) : bucketCount; + const sp = calcPosition(s.start_iso, origin, scale, bucketCount); + const ep = s.end_iso ? calcPosition(s.end_iso, origin, scale, bucketCount) : bucketCount; return { seg: s, sp: sp, ep: Math.max(ep, sp + 0.05) }; }).sort(function(a, b) { return a.sp - b.sp || b.ep - a.ep; }); @@ -279,40 +346,63 @@ return { items: items, laneCount: lanes.length }; } + function assignEventLanes(events, gap) { + const lanes = []; + for (const item of events) { + let lane = 0; + while (lane < lanes.length && item.ep < lanes[lane] + gap) lane++; + if (lane === lanes.length) lanes.push(item.ep); + else lanes[lane] = item.ep; + item.lane = lane; + } + return lanes.length; + } + function buildHTML(data) { - const from = parseISO(data.range_start); - const to = parseISO(data.range_end); - const scale = autoScale(from, to); + const from = extentStart || parseISO(data.range_start); + const to = extentEnd || parseISO(data.range_end); + const scale = resolveScale(from, to); const sl = buildScaleLabels(from, to, scale); const bucketCount = sl.count; - const EVT_H = 12; + const origin = sl.origin; let html = ''; html += '
'; - html += '
' + scale.toUpperCase() + '
'; + html += '
' + scale.toUpperCase() + '
'; html += '
'; for (let i = 0; i < bucketCount; i++) { const leftPct = (i / bucketCount * 100).toFixed(4); const widthPct = (1 / bucketCount * 100).toFixed(4); - html += '
' + sl.labels[i] + '
'; + html += '
' + esc(sl.labels[i]) + '
'; } html += '
'; for (const row of data.rows) { const visSegs = row.segments.filter(function(s) { - const sp = calcPosition(s.start_iso, from, to, bucketCount); - const ep = s.end_iso ? calcPosition(s.end_iso, from, to, bucketCount) : bucketCount; - const vis = Math.min(ep, bucketCount) - Math.max(sp, 0); + const sp = calcPosition(s.start_iso, origin, scale, bucketCount); + const ep = s.end_iso ? calcPosition(s.end_iso, origin, scale, bucketCount) : bucketCount; return ep > 0 && sp < bucketCount; }); - const result = assignLanes(visSegs, from, to, bucketCount); - const maxLane = Math.max(result.laneCount, 1); - const segAreaH = maxLane * (SEG_H + SEG_GAP) + 4; - const hasEvt = visSegs.length > 0 && row.events && row.events.length > 0; - const rowH = segAreaH + (hasEvt ? EVT_H : 0); + const result = assignLanes(visSegs, origin, scale, bucketCount); + const segmentById = new Map(); + for (const item of result.items) segmentById.set(String(item.seg.entry_id), item); + const visibleEvents = (row.events || []).map(function(evt) { + const at = parseISO(evt.at_iso); + return { evt: evt, at: at, ep: calcPosition(evt.at_iso, origin, scale, bucketCount) }; + }).filter(function(item) { + return item.at && item.at >= from && item.at <= to && item.ep >= 0 && item.ep <= bucketCount; + }); + const detachedEvents = visibleEvents.filter(function(item) { + return !item.evt.is_state_node || !segmentById.has(String(item.evt.parent_entry_id || '')); + }).sort(function(a, b) { return a.ep - b.ep; }); + const detachedLaneCount = assignEventLanes(detachedEvents, scale === 'year' ? 0.42 : 0.9); + const maxLane = Math.max(result.laneCount, visSegs.length ? 1 : 0); + const segAreaH = TRACK_TOP + Math.max(maxLane, 1) * (SEG_H + SEG_GAP) + 4; + const eventAreaH = detachedLaneCount ? detachedLaneCount * (EVENT_H + EVENT_GAP) + 8 : 0; + const rowH = Math.max(36, segAreaH + eventAreaH); html += '
'; - html += '
' + row.asset_name + '
'; + html += '
' + esc(row.asset_name) + '
'; html += '
'; for (const item of result.items) { @@ -321,16 +411,17 @@ const leftPct = (visSp / bucketCount * 100).toFixed(4); const widthPct = ((visEp - visSp) / bucketCount * 100).toFixed(4); const w = Math.max(parseFloat(widthPct), 0.3); - const top = 10 + item.lane * (SEG_H + SEG_GAP); + const top = TRACK_TOP + item.lane * (SEG_H + SEG_GAP); const seg = item.seg; html += '
' + seg.label + '
'; } - if (hasEvt) { + { const evtY = segAreaH + 8; const seen = new Set(); - for (const evt of row.events) { - const ep = calcPosition(evt.at_iso, from, to, bucketCount); + for (const item of visibleEvents) { + const evt = item.evt; + const ep = item.ep; const key = evt.label + '|' + evt.at_iso; if (seen.has(key)) continue; seen.add(key); @@ -339,13 +430,18 @@ ? '
' : '
'; const epPct = (ep / bucketCount * 100).toFixed(4); - html += '
' + shape + '
'; + const parentItem = evt.is_state_node ? segmentById.get(String(evt.parent_entry_id || '')) : null; + const eventTop = parentItem ? TRACK_TOP + parentItem.lane * (SEG_H + SEG_GAP) + SEG_H - 2 : segAreaH + (item.lane || 0) * (EVENT_H + EVENT_GAP); + const labelClass = evt.is_state_node ? 'tl-state-label' : 'tl-event-label'; + const labelStyle = FLOAT_LABEL_STYLE + (evt.is_state_node ? ';opacity:0;visibility:hidden;transform:translateY(-2px);transition:opacity .12s,visibility .12s,transform .12s' : ''); + const eventLabel = '' + esc(evt.label) + ''; + html += '
' + shape + eventLabel + '
'; } } html += '
'; } - return { html: html, buckets: bucketCount }; + return { html: html, buckets: bucketCount, scale: scale }; } function renderAll() { @@ -354,58 +450,57 @@ const result = buildHTML(tlData); canvas.innerHTML = result.html; const vp = document.getElementById('tl-viewport'); - const vpW = vp ? vp.offsetWidth - 130 : 1200; - const scale = autoScale(parseISO(tlData.range_start), parseISO(tlData.range_end)); - const naturalW = result.buckets * bucketW(scale); + const vpW = vp ? vp.offsetWidth : 1200; + const continuousTrackW = overviewTrackWidth() * zoomLevel; + const explicitTrackW = result.buckets * bucketW(result.scale); + const naturalTrackW = explicitScale() ? Math.max(continuousTrackW, explicitTrackW) : continuousTrackW; + const naturalW = NAME_W + naturalTrackW; canvas.style.width = Math.max(vpW, naturalW) + 'px'; const fromInput = document.getElementById('tl-from'); const toInput = document.getElementById('tl-to'); - if (fromInput) fromInput.value = tlData.range_start.slice(0,10); - if (toInput) toInput.value = tlData.range_end.slice(0,10); + if (fromInput) fromInput.value = String(initialRangeStart || '').slice(0,10); + if (toInput) toInput.value = String(initialRangeEnd || '').slice(0,10); } renderAll(); // === Zoom functions === - const ZOOM_MIN = new Date(2020, 0, 1).getTime(); - const ZOOM_MAX = new Date(2150, 0, 1).getTime(); + const ZOOM_MIN_LEVEL = 1; + const ZOOM_MAX_LEVEL = 96; function tlZoom(factor, mouseX) { - const from = parseISO(tlData.range_start); - const to = parseISO(tlData.range_end); - const span = to - from; const vp = document.getElementById('tl-viewport'); const canvas = document.getElementById('tl-canvas'); let anchorRatio = 0.5; - if (mouseX !== undefined && vp && canvas) { - const nameW = 130; - const trackScreenLeft = vp.getBoundingClientRect().left + nameW; - const trackW = canvas.offsetWidth - nameW; - const mouseInTrack = mouseX - trackScreenLeft + vp.scrollLeft; + let pointerOffset = null; + if (vp && canvas) { + const nameW = NAME_W; + const rect = vp.getBoundingClientRect(); + const pointerX = mouseX === undefined ? rect.left + Math.min(vp.clientWidth, rect.width) * 0.5 : mouseX; + const trackScreenLeft = rect.left + nameW; + const trackW = Math.max(1, canvas.offsetWidth - nameW); + pointerOffset = pointerX - rect.left - nameW; + const mouseInTrack = pointerX - trackScreenLeft + vp.scrollLeft; anchorRatio = Math.max(0, Math.min(1, mouseInTrack / trackW)); } - const anchor = from.getTime() + span * anchorRatio; - const newSpan = Math.max(DAY_MS * 7, Math.min(DAY_MS * 40000, span * factor)); - let start = anchor - newSpan * anchorRatio; - let end = anchor + newSpan * (1 - anchorRatio); - if (start < ZOOM_MIN) { start = ZOOM_MIN; end = start + newSpan; } - if (end > ZOOM_MAX) { end = ZOOM_MAX; start = end - newSpan; } - if (start < ZOOM_MIN) start = ZOOM_MIN; - tlData.range_start = fmtDate(new Date(start)) + ' 00:00'; - tlData.range_end = fmtDate(new Date(end)) + ' 00:00'; + zoomLevel = Math.max(ZOOM_MIN_LEVEL, Math.min(ZOOM_MAX_LEVEL, zoomLevel / factor)); renderAll(); - if (mouseX !== undefined && vp && canvas) { - const nameW = 130; - const newTrackW = canvas.offsetWidth - nameW; + if (vp && canvas && pointerOffset !== null) { + const nameW = NAME_W; + const newTrackW = Math.max(1, canvas.offsetWidth - nameW); const anchorPx = newTrackW * anchorRatio; - vp.scrollLeft = Math.max(0, anchorPx - (mouseX - vp.getBoundingClientRect().left - nameW)); + const maxScroll = Math.max(0, canvas.offsetWidth - vp.clientWidth); + vp.scrollLeft = Math.max(0, Math.min(maxScroll, anchorPx - pointerOffset)); } } function tlResetView() { - location.href = location.pathname; + zoomLevel = 1; + renderAll(); + viewport.scrollLeft = 0; } - window.tlZoom = tlZoom; - window.tlResetView = tlResetView; + document.getElementById('tl-zoom-in')?.addEventListener('click', function() { tlZoom(0.75); }); + document.getElementById('tl-zoom-out')?.addEventListener('click', function() { tlZoom(1.25); }); + document.getElementById('tl-reset-view')?.addEventListener('click', tlResetView); // === Wheel zoom === let wheelRAF = null; @@ -413,9 +508,10 @@ e.preventDefault(); if (wheelRAF) return; const mx = e.clientX; + const dy = e.deltaY; wheelRAF = requestAnimationFrame(function() { wheelRAF = null; - tlZoom(e.deltaY < 0 ? 0.85 : 1 / 0.85, mx); + tlZoom(dy < 0 ? 0.85 : 1 / 0.85, mx); }); }, {passive: false}); @@ -436,23 +532,32 @@ // === Tooltip === let tip = null; function ensureTip() { - if (!tip) { tip = document.createElement('div'); tip.style.cssText = 'position:fixed;z-index:100;background:#1a1a22;border:1px solid rgba(248,250,252,.15);border-radius:6px;padding:8px 12px;font-size:.74rem;pointer-events:none;display:none;max-width:350px;box-shadow:0 8px 24px rgba(0,0,0,.5)'; document.body.appendChild(tip); } + if (!tip) { tip = document.createElement('div'); tip.id = 'tl-tip'; tip.style.cssText = 'position:fixed;z-index:100;background:var(--surface);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:8px 12px;font-size:.74rem;pointer-events:none;display:none;max-width:350px;box-shadow:0 8px 24px rgba(0,0,0,.24)'; document.body.appendChild(tip); } + } + function revealStateLabel(node, visible) { + const label = node ? node.querySelector('.tl-state-label') : null; + if (!label) return; + label.style.opacity = visible ? '1' : '0'; + label.style.visibility = visible ? 'visible' : 'hidden'; + label.style.transform = visible ? 'translateY(0)' : 'translateY(-2px)'; } document.getElementById('tl-canvas').addEventListener('mouseover', function(e) { const seg = e.target.closest('.tl-fl div'); const evt = e.target.closest('.tl-ev,.tl-sn'); + const stateNode = e.target.closest('.tl-sn'); ensureTip(); if (seg && seg.dataset.url) { seg.style.filter = 'brightness(1.3)'; tip.innerHTML = '' + (seg.dataset.label||'') + '
' + (seg.dataset.start||'') + ' → ' + (seg.dataset.end||'') + '
Click to edit'; tip.style.display = 'block'; } else if (evt) { + revealStateLabel(stateNode, true); tip.innerHTML = '' + (evt.dataset.label||'') + '
' + (evt.dataset.full||evt.dataset.label||'') + '
Click to edit'; tip.style.display = 'block'; } }); document.getElementById('tl-canvas').addEventListener('mousemove', function(e) { if (tip && tip.style.display==='block') { tip.style.left=(e.clientX+12)+'px'; tip.style.top=(e.clientY-30)+'px'; }}); - document.getElementById('tl-canvas').addEventListener('mouseout', function(e) { if (e.target.closest('.tl-fl div')) e.target.closest('.tl-fl div').style.filter=''; if (tip) tip.style.display='none'; }); + document.getElementById('tl-canvas').addEventListener('mouseout', function(e) { if (e.target.closest('.tl-fl div')) e.target.closest('.tl-fl div').style.filter=''; revealStateLabel(e.target.closest('.tl-sn'), false); if (tip) tip.style.display='none'; }); })(); {% endblock %}