Files
KSP_project/app/templates/mission_timeline_preview.html
T
ArmorandClaude Opus 4.7 2fef743e83 feat: timeline refactor, docking UI, location/state dropdowns, sim_time cleanup
Templates:
- mission_timeline_preview: client-side rendering with lane stacking, wheel zoom,
  drag pan, percentage-based layout, event dedup, scale labels
- asset_detail: dock/undock modals with time selection, future event warning,
  Mission Label column replacing Location/State
- asset_entry_form: all_locations expanded to 72 items (draft board hierarchy),
  all_states dynamic from DB, event point dropdown uses same list
- Remove sim_time from all template url_for links
- base.html: remove Today button

JS:
- app.js: sim_time controls use POST /api/v1/sim-time API instead of URL param

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 15:29:10 +08:00

459 lines
25 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block content %}
<div class="ph fi">
<div class="ey">Mission Operations</div>
<h1>时间线</h1>
</div>
<!-- Filter bar row 1: search + presets + scale -->
<div class="fb fi">
<form id="tl-form" method="get" action="{{ url_for('web.mission_timeline_preview') }}" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;flex:1">
<input type="hidden" name="sim_time" value="{{ simulation_time_input }}">
<input type="hidden" name="start" id="tl-from" value="{{ timeline_range_start[:10] if timeline_range_start else '' }}">
<input type="hidden" name="end" id="tl-to" value="{{ timeline_range_end[:10] if timeline_range_end else '' }}">
<input type="text" name="q" id="tl-q" placeholder="Search assets..." style="width:150px" value="{{ search_term|default('',true) }}">
<div style="display:flex;gap:3px">
<button class="b sm{% if record_scope == 'active' %} ac{% endif %}" type="submit" name="record_scope" value="active">Active</button>
<button class="b sm" type="submit" name="record_scope" value="all">All</button>
</div>
<label style="font-size:.66rem;color:var(--muted)">Type:</label>
<select name="asset_type" onchange="resetAssetsAndSubmit()" style="padding:3px 6px;border-radius:4px;background:var(--surface2);color:var(--text);border:1px solid var(--border);font-size:.66rem">
<option value="">All</option>
{% for t in asset_type_options|default([],true) %}<option value="{{ t }}" {% if t == asset_type_filter %}selected{% endif %}>{{ t }}</option>{% endfor %}
</select>
<label style="font-size:.66rem;color:var(--muted)">Location:</label>
<select name="location" onchange="resetAssetsAndSubmit()" style="padding:3px 6px;border-radius:4px;background:var(--surface2);color:var(--text);border:1px solid var(--border);font-size:.66rem">
<option value="">All</option>
{% for l in location_options|default([],true) %}<option value="{{ l }}" {% if l == location_filter %}selected{% endif %}>{{ l }}</option>{% endfor %}
</select>
<label style="font-size:.66rem;color:var(--muted)">State:</label>
<select name="state" onchange="resetAssetsAndSubmit()" style="padding:3px 6px;border-radius:4px;background:var(--surface2);color:var(--text);border:1px solid var(--border);font-size:.66rem">
<option value="">All</option>
{% for s in ['start-transit','transit-complete','Exploration','Maintenance','Docked','Construction','Testing','Crew Handoff','Surface Expedition','Planetary Operations'] %}
<option value="{{ s }}" {% if state_filter == s %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
<label style="font-size:.66rem;color:var(--muted)">Assets:</label>
<button type="button" id="tl-asset-btn" class="b sm" style="padding:3px 8px;font-size:.66rem;min-width:100px;text-align:left">{% if selected_asset_ids %}{{ selected_asset_count|default(0) }} selected{% else %}All assets{% endif %} ▾</button>
<style>
#tl-asset-dd{display:none;position:fixed;z-index:99999;background:#1e2030;border:1px solid #3b4261;border-radius:6px;max-height:260px;overflow-y:auto;min-width:180px;padding:4px;box-shadow:0 8px 24px rgba(0,0,0,.7);color:#e2e8f0;font-size:.66rem}
#tl-asset-dd.open{display:block}
#tl-asset-dd label{display:flex;align-items:center;gap:4px;padding:2px 6px;cursor:pointer;white-space:nowrap}
#tl-asset-dd label:hover{background:rgba(255,255,255,.06);border-radius:3px}
</style>
<script>
// Build dropdown dynamically and inject into <body>
(function(){
var dd = document.createElement('div');
dd.id = 'tl-asset-dd';
dd.innerHTML =
'<label><input type="checkbox" id="asset-select-all" style="margin:0"> Select All</label>' +
'<label><input type="checkbox" id="asset-clear" style="margin:0"> Clear</label>' +
'<div style="border-top:1px solid #3b4261;margin:4px 0"></div>' +
'{% for opt in timeline_asset_options|default([],true) %}' +
'<label><input type="checkbox" class="asset-cb" name="asset_ids" value="{{ opt.value }}" {% if opt.selected or not selected_asset_ids %}checked{% endif %}>{{ opt.label }}</label>' +
'{% endfor %}';
document.body.appendChild(dd);
document.getElementById('asset-select-all').addEventListener('change', function(){
dd.querySelectorAll('.asset-cb').forEach(function(cb){cb.checked = true});
updateAssetCount();
});
document.getElementById('asset-clear').addEventListener('change', function(){
dd.querySelectorAll('.asset-cb').forEach(function(cb){cb.checked = false});
updateAssetCount();
});
dd.querySelectorAll('.asset-cb').forEach(function(cb){
cb.addEventListener('change', function(){ updateAssetCount(); });
});
var btn = document.getElementById('tl-asset-btn');
btn.addEventListener('click', function(e){
e.stopPropagation();
var r = btn.getBoundingClientRect();
dd.style.left = Math.max(4, r.left) + 'px';
var top = r.bottom + 4;
dd.style.top = top + 'px';
dd.classList.toggle('open');
if (dd.classList.contains('open')) {
var maxB = window.innerHeight - 8;
if (dd.getBoundingClientRect().bottom > maxB) {
dd.style.top = (r.top - dd.getBoundingClientRect().height - 4) + 'px';
}
}
});
var hasExplicit = {{ 'true' if selected_asset_ids else 'false' }};
if (!hasExplicit) {
dd.querySelectorAll('.asset-cb').forEach(function(cb){cb.checked = true});
updateAssetCount();
}
})();
function updateAssetCount() {
var dd = document.getElementById('tl-asset-dd');
var cbs = dd.querySelectorAll('.asset-cb');
var total = cbs.length;
var checked = Array.from(cbs).filter(function(cb){return cb.checked}).length;
var btn = document.getElementById('tl-asset-btn');
btn.textContent = (checked === total ? 'All assets' : checked + ' selected') + ' ▾';
}
function syncAssetIdsToForm() {
var form = document.getElementById('tl-form');
form.querySelectorAll('input[name=asset_ids]').forEach(function(el){el.remove()});
document.querySelectorAll('#tl-asset-dd .asset-cb:checked').forEach(function(cb){
var h = document.createElement('input');
h.type = 'hidden'; h.name = 'asset_ids'; h.value = cb.value;
form.appendChild(h);
});
}
function resetAssetsAndSubmit() {
document.querySelectorAll('#tl-asset-dd .asset-cb').forEach(function(cb){cb.checked = true;});
syncAssetIdsToForm();
document.getElementById('tl-form').submit();
}
document.getElementById('tl-form').addEventListener('submit', function(){ syncAssetIdsToForm(); });
document.addEventListener('click', function(e) {
var dd = document.getElementById('tl-asset-dd');
if (dd && !dd.contains(e.target) && e.target.id !== 'tl-asset-btn') {
dd.classList.remove('open');
}
});
</script>
<div style="margin-left:auto;display:flex;gap:4px;align-items:center">
<label style="font-size:.66rem;color:var(--muted)">Scale:</label>
<select name="scale" id="tl-scale" onchange="this.form.submit()" style="padding:3px 6px;border-radius:4px;background:var(--surface2);color:var(--text);border:1px solid var(--border);font-size:.66rem">
<option value="">Auto</option>
<option value="year" {% if timeline_scale_mode == 'year' %}selected{% endif %}>Year</option>
<option value="month" {% if timeline_scale_mode == 'month' %}selected{% endif %}>Month</option>
<option value="day" {% if timeline_scale_mode == 'day' %}selected{% endif %}>Day</option>
</select>
</div>
<button class="b" type="submit" style="font-size:.66rem">Apply</button>
</form>
</div>
<!-- Timeline: single scrollable container with sticky names -->
<div id="tl-viewport" style="flex:1;overflow:auto;min-height:calc(100vh - 180px);cursor:grab;user-select:none">
<div id="tl-canvas">
<!-- Scale labels row -->
<div style="display:flex;height:26px;border-bottom:1px solid var(--border)">
<div style="width:130px;flex-shrink:0;position:sticky;left:0;background:var(--bg);z-index:2;border-right:1px solid var(--border)"></div>
<div style="flex:1;display:flex;font-size:.58rem;color:var(--muted);text-align:center;align-items:center">
{% for label in timeline_scale %}<div style="flex:1">{{ label }}</div>{% endfor %}
</div>
</div>
<!-- Timeline rows -->
{% for row in timeline_rows %}
<div style="display:flex;height:26px;border-bottom:1px solid rgba(148,163,184,.04)">
<!-- Fixed asset name -->
<div style="width:130px;flex-shrink:0;display:flex;align-items:center;padding:0 8px;font-size:.68rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500;position:sticky;left:0;background:var(--bg);z-index:2;border-right:1px solid var(--border)"><a href="{{ row.asset_url }}" style="color:var(--accent);text-decoration:none;overflow:hidden;text-overflow:ellipsis" title="{{ row.name }}">{{ row.name }}</a></div>
<!-- Scrollable bars -->
<div style="flex:1;position:relative;height:26px;overflow:visible">
{% for seg in row.segments %}
<div class="tl-fl" style="position:absolute;left:{{ seg.left_pct }};width:{{ seg.track_width_pct }};top:3px;height:20px"><div style="width:{{ seg.body_width_pct }};height:100%;cursor:pointer;display:flex;align-items:center;justify-content:space-between;padding:0 4px;font-size:.56rem;color:rgba(255,255,255,.85);overflow:hidden;white-space:nowrap;border-radius:2px;background:{{ seg.body_color }};opacity:.85"
data-url="{{ seg.url|default('#',true) }}"
data-label="{{ seg.label }}"
data-start="{{ seg.start_at }}"
data-end="{{ seg.end_at }}"
data-nodes="{{ seg.state_node_count }}"
title="{{ seg.label }}&#10;{{ seg.start_at }} → {{ seg.end_at }}&#10;{{ seg.state_node_count }} state node(s)&#10;Click to edit"
onclick="if(this.dataset.url&&this.dataset.url!=='#') window.location.href=this.dataset.url">
<span style="overflow:hidden;text-overflow:ellipsis;flex-shrink:1">{{ seg.label }}</span>
{% if seg.state_node_count > 0 %}
<span style="flex-shrink:0;margin-left:2px;font-size:.5rem;opacity:.7">●{{ seg.state_node_count }}</span>
{% endif %}
</div></div>
{% endfor %}
{% for evt in row.events %}
<div class="tl-evt" style="position:absolute;left:{{ evt.left_pct }};top:3px;height:20px;display:flex;align-items:flex-start;justify-content:center;cursor:pointer"
data-url="{{ evt.url|default('#',true) }}"
data-label="{{ evt.label }}"
data-full="{{ evt.full_label|default(evt.label,true) }}"
title="{{ evt.full_label|default(evt.label,true) }}&#10;Click to edit"
onclick="if(this.dataset.url&&this.dataset.url!=='#') window.location.href=this.dataset.url">
<div style="width:7px;height:7px;border-radius:50%;background:{{ 'rgba(59,130,246,.95)' if evt.state_node else 'rgba(248,250,252,.85)' }};box-shadow:{{ '0 0 6px rgba(59,130,246,.6)' if evt.state_node else '0 0 4px rgba(248,250,252,.4)' }};flex-shrink:0;border:1px solid rgba(255,255,255,.2)"></div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<!-- Zoom controls -->
<div style="display:flex;align-items:center;gap:6px;margin-top:8px">
<button class="b sm" onclick="tlZoom(1.3)">Zoom +</button>
<button class="b sm" onclick="tlZoom(0.7)">Zoom </button>
<button class="b sm" onclick="tlResetView()">Reset View</button>
<span style="font-size:.64rem;color:var(--muted);margin-left:8px">Scroll to zoom · Drag to pan</span>
</div>
{% endblock %}
{% block scripts %}
<script id="tl-json" type="application/json">{{ timeline_json|safe }}</script>
<script>
(function(){
const viewport = document.getElementById('tl-viewport');
if (!viewport) return;
const jsonEl = document.getElementById('tl-json');
let tlData = jsonEl ? JSON.parse(jsonEl.textContent) : null;
if (!tlData || !tlData.rows) return;
const DAY_MS = 86400000;
const MIN_BUCKETS = 3, MAX_BUCKETS = 200;
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; }
// 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';
return 'year';
}
// Build scale labels
function buildScaleLabels(from, to, scale) {
let labels = [];
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); }
} else if (scale === 'month') {
let d = new Date(from.getFullYear(), from.getMonth(), 1);
while (d <= to) {
labels.push(d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0'));
d.setMonth(d.getMonth()+1);
}
} else {
let y = from.getFullYear();
while (y <= to.getFullYear()) { labels.push(String(y)); y++; }
}
return { labels: labels, count: labels.length };
}
// Client-side position recalculation from ISO timestamps
function calcPosition(isoStr, from, to, 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;
}
function bucketW(scale) {
if (scale === 'day') return 50;
if (scale === 'month') return 60;
return 250;
}
const SEG_H = 18, SEG_GAP = 2;
function assignLanes(segments, from, to, 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;
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; });
const lanes = [];
for (let i = 0; i < items.length; i++) {
var placed = false;
for (let l = 0; l < lanes.length; l++) {
if (items[i].sp >= lanes[l]) {
lanes[l] = items[i].ep;
items[i].lane = l;
placed = true;
break;
}
}
if (!placed) {
items[i].lane = lanes.length;
lanes.push(items[i].ep);
}
}
return { items: items, laneCount: lanes.length };
}
function buildHTML(data) {
const from = parseISO(data.range_start);
const to = parseISO(data.range_end);
const scale = autoScale(from, to);
const sl = buildScaleLabels(from, to, scale);
const bucketCount = sl.count;
const EVT_H = 12;
let html = '';
html += '<div style="display:flex;height:36px;border-bottom:1px solid var(--border);font-size:.6rem;color:var(--muted);align-items:flex-end;padding-bottom:4px">';
html += '<div style="width:130px;flex-shrink:0;position:sticky;left:0;background:var(--bg);z-index:2;border-right:1px solid var(--border);padding:0 8px">' + scale.toUpperCase() + '</div>';
html += '<div style="flex:1;position:relative;min-width:0">';
for (let i = 0; i < bucketCount; i++) {
const leftPct = (i / bucketCount * 100).toFixed(4);
const widthPct = (1 / bucketCount * 100).toFixed(4);
html += '<div style="position:absolute;left:' + leftPct + '%;width:' + widthPct + '%;text-align:center;white-space:nowrap;overflow:hidden">' + sl.labels[i] + '</div>';
}
html += '</div></div>';
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);
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);
html += '<div style="display:flex;border-bottom:1px solid rgba(148,163,184,.06)">';
html += '<div style="width:130px;flex-shrink:0;display:flex;align-items:center;padding:0 8px;font-size:.68rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500;position:sticky;left:0;background:var(--bg);z-index:2;border-right:1px solid var(--border);height:' + rowH + 'px"><a href="' + row.asset_url + '" style="color:var(--accent);text-decoration:none;overflow:hidden;text-overflow:ellipsis" title="' + row.asset_name + '">' + row.asset_name + '</a></div>';
html += '<div style="flex:1;position:relative;height:' + rowH + 'px;min-width:0">';
for (const item of result.items) {
const visSp = Math.max(item.sp, 0);
const visEp = Math.min(item.ep, bucketCount);
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 seg = item.seg;
html += '<div class="tl-fl" style="position:absolute;left:' + leftPct + '%;width:' + w.toFixed(4) + '%;top:' + top + 'px;height:' + SEG_H + 'px"><div style="width:100%;height:100%;cursor:pointer;display:flex;align-items:center;padding:0 4px;font-size:.56rem;color:rgba(255,255,255,.9);overflow:hidden;white-space:nowrap;border-radius:3px;background:' + seg.body_color + ';opacity:.9" data-url="' + seg.url + '" data-label="' + seg.label + '" data-start="' + seg.start_at + '" data-end="' + seg.end_at + '" title="' + seg.label + '&#10;' + seg.start_at + ' → ' + seg.end_at + '&#10;Click to edit" onclick="if(this.dataset.url)window.location.href=this.dataset.url">' + seg.label + '</div></div>';
}
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);
const key = evt.label + '|' + evt.at_iso;
if (seen.has(key)) continue;
seen.add(key);
const cls = evt.is_state_node ? 'tl-sn' : 'tl-ev';
const shape = evt.is_state_node
? '<div style="width:0;height:0;border-left:4px solid transparent;border-right:4px solid transparent;border-bottom:6px solid var(--accent)"></div>'
: '<div style="width:5px;height:5px;background:var(--warning);transform:rotate(45deg)"></div>';
const epPct = (ep / bucketCount * 100).toFixed(4);
html += '<div class="' + cls + '" style="position:absolute;left:calc(' + epPct + '% - 3px);top:' + evtY + 'px;width:7px;height:' + EVT_H + 'px;display:flex;align-items:center;justify-content:center;cursor:pointer;z-index:3" data-url="' + evt.url + '" data-label="' + evt.label + '" data-full="' + (evt.full_label||evt.label) + '" title="' + (evt.full_label||evt.label) + '&#10;Click to edit" onclick="if(this.dataset.url)window.location.href=this.dataset.url">' + shape + '</div>';
}
}
html += '</div></div>';
}
return { html: html, buckets: bucketCount };
}
function renderAll() {
const canvas = document.getElementById('tl-canvas');
if (!canvas) return;
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);
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);
}
renderAll();
// === Zoom functions ===
const ZOOM_MIN = new Date(2020, 0, 1).getTime();
const ZOOM_MAX = new Date(2150, 0, 1).getTime();
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;
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';
renderAll();
if (mouseX !== undefined && vp && canvas) {
const nameW = 130;
const newTrackW = canvas.offsetWidth - nameW;
const anchorPx = newTrackW * anchorRatio;
vp.scrollLeft = Math.max(0, anchorPx - (mouseX - vp.getBoundingClientRect().left - nameW));
}
}
function tlResetView() {
location.href = location.pathname;
}
window.tlZoom = tlZoom;
window.tlResetView = tlResetView;
// === Wheel zoom ===
let wheelRAF = null;
viewport.addEventListener('wheel', function(e) {
e.preventDefault();
if (wheelRAF) return;
const mx = e.clientX;
wheelRAF = requestAnimationFrame(function() {
wheelRAF = null;
tlZoom(e.deltaY < 0 ? 0.85 : 1 / 0.85, mx);
});
}, {passive: false});
// === Drag pan ===
let dragging = false, dragX = 0, scrollX = 0;
viewport.addEventListener('mousedown', function(e) {
if (e.target.closest('.tl-fl') || e.target.closest('.tl-evt') || e.target.closest('.tl-sn') || e.target.closest('a')) return;
dragging = true; dragX = e.clientX; scrollX = viewport.scrollLeft; viewport.style.cursor = 'grabbing';
viewport.style.userSelect = 'none';
document.body.style.userSelect = 'none';
e.preventDefault();
});
window.addEventListener('mousemove', function(e) { if (dragging) { e.preventDefault(); viewport.scrollLeft = scrollX - (e.clientX - dragX); } });
function endDrag() { dragging = false; viewport.style.cursor = 'grab'; viewport.style.userSelect = ''; document.body.style.userSelect = ''; }
window.addEventListener('mouseup', endDrag);
viewport.addEventListener('mouseleave', function() { if (dragging) endDrag(); });
// === 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); }
}
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');
ensureTip();
if (seg && seg.dataset.url) {
seg.style.filter = 'brightness(1.3)';
tip.innerHTML = '<strong>' + (seg.dataset.label||'') + '</strong><br><span style="color:#94A3B8;font-size:.66rem">' + (seg.dataset.start||'') + ' → ' + (seg.dataset.end||'') + '</span><br><span style="color:#94A3B8;font-size:.66rem">Click to edit</span>';
tip.style.display = 'block';
} else if (evt) {
tip.innerHTML = '<strong>' + (evt.dataset.label||'') + '</strong><br><span style="color:#94A3B8;font-size:.66rem">' + (evt.dataset.full||evt.dataset.label||'') + '</span><br><span style="color:#94A3B8;font-size:.66rem">Click to edit</span>';
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'; });
})();
</script>
{% endblock %}