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>
459 lines
25 KiB
HTML
459 lines
25 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" 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 + ' ' + seg.start_at + ' → ' + seg.end_at + ' 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) + ' 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 %}
|