Files
KSP_project/app/templates/mission_timeline_preview.html
T

564 lines
30 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" type="button" id="tl-zoom-in">Zoom +</button>
<button class="b sm" type="button" id="tl-zoom-out">Zoom </button>
<button class="b sm" type="button" id="tl-reset-view">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_data|tojson }}</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 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 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Determine scale mode from range span
function autoScale(from, to) {
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') {
while (d <= boundary) { labels.push(fmtDisplay(d)); d = nextBucket(d, scale); }
} else if (scale === 'month') {
while (d <= boundary) {
labels.push(d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0'));
d = nextBucket(d, scale);
}
} else {
while (d <= boundary) {
labels.push(String(d.getFullYear()));
d = nextBucket(d, scale);
}
}
return { labels: labels, count: labels.length, origin: origin };
}
// Client-side position recalculation from ISO timestamps
function calcPosition(isoStr, origin, scale, bucketCount) {
if (!isoStr) return 0;
const t = parseISO(isoStr);
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) {
if (scale === 'day') return 50;
if (scale === 'month') return 60;
return 250;
}
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, origin, scale, bucketCount) {
const items = segments.map(function(s) {
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; });
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 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 = 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 origin = sl.origin;
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:' + NAME_W + 'px;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">' + esc(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, 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, 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 += '<div style="display:flex;border-bottom:1px solid rgba(148,163,184,.06)">';
html += '<div style="width:' + NAME_W + 'px;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="' + esc(row.asset_url) + '" style="color:var(--accent);text-decoration:none;overflow:hidden;text-overflow:ellipsis" title="' + esc(row.asset_name) + '">' + esc(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 = TRACK_TOP + 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>';
}
{
const evtY = segAreaH + 8;
const seen = new Set();
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);
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);
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 = '<span class="' + labelClass + '" style="' + labelStyle + '">' + esc(evt.label) + '</span>';
html += '<div class="' + cls + '" tabindex="0" data-parent-entry-id="' + esc(evt.parent_entry_id || '') + '" data-parent-track="' + (parentItem ? parentItem.lane : '') + '" style="position:absolute;left:calc(' + epPct + '% - 3px);top:' + eventTop + 'px;display:flex;align-items:center;gap:3px;max-width:150px;cursor:pointer;z-index:3" data-url="' + esc(evt.url) + '" data-label="' + esc(evt.label) + '" data-full="' + esc(evt.full_label||evt.label) + '" title="' + esc(evt.full_label||evt.label) + '&#10;Click to edit" onclick="if(this.dataset.url)window.location.href=this.dataset.url">' + shape + eventLabel + '</div>';
}
}
html += '</div></div>';
}
return { html: html, buckets: bucketCount, scale: scale };
}
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 : 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 = String(initialRangeStart || '').slice(0,10);
if (toInput) toInput.value = String(initialRangeEnd || '').slice(0,10);
}
renderAll();
// === Zoom functions ===
const ZOOM_MIN_LEVEL = 1;
const ZOOM_MAX_LEVEL = 96;
function tlZoom(factor, mouseX) {
const vp = document.getElementById('tl-viewport');
const canvas = document.getElementById('tl-canvas');
let anchorRatio = 0.5;
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));
}
zoomLevel = Math.max(ZOOM_MIN_LEVEL, Math.min(ZOOM_MAX_LEVEL, zoomLevel / factor));
renderAll();
if (vp && canvas && pointerOffset !== null) {
const nameW = NAME_W;
const newTrackW = Math.max(1, canvas.offsetWidth - nameW);
const anchorPx = newTrackW * anchorRatio;
const maxScroll = Math.max(0, canvas.offsetWidth - vp.clientWidth);
vp.scrollLeft = Math.max(0, Math.min(maxScroll, anchorPx - pointerOffset));
}
}
function tlResetView() {
zoomLevel = 1;
renderAll();
viewport.scrollLeft = 0;
}
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;
viewport.addEventListener('wheel', function(e) {
e.preventDefault();
if (wheelRAF) return;
const mx = e.clientX;
const dy = e.deltaY;
wheelRAF = requestAnimationFrame(function() {
wheelRAF = null;
tlZoom(dy < 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.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 = '<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) {
revealStateLabel(stateNode, true);
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=''; revealStateLabel(e.target.closest('.tl-sn'), false); if (tip) tip.style.display='none'; });
})();
</script>
{% endblock %}