564 lines
30 KiB
HTML
564 lines
30 KiB
HTML
{% 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 }} {{ seg.start_at }} → {{ seg.end_at }} {{ seg.state_node_count }} state node(s) 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) }} 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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
// 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 + ' ' + seg.start_at + ' → ' + seg.end_at + ' 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) + ' 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 %}
|