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>
This commit is contained in:
2026-06-01 15:29:10 +08:00
co-authored by Claude Opus 4.7
parent cc4c460840
commit 2fef743e83
15 changed files with 1791 additions and 1966 deletions
+434 -216
View File
@@ -1,240 +1,458 @@
{% extends "base.html" %}
{% from "_datetime_picker_field.html" import datetime_picker_field %}
{% block content %}
{% include "_mission_ops_tabs.html" %}
{% include "_mission_preview_header.html" %}
<div class="ph fi">
<div class="ey">Mission Operations</div>
<h1>时间线</h1>
</div>
<section class="panel mission-control-panel top-gap">
<form method="get" action="{{ url_for('web.mission_timeline_preview') }}" class="mission-toolbar-grid">
<input type="hidden" name="range_mode" value="{{ timeline_range_mode }}" id="timeline-range-mode">
<div class="mission-toolbar-block">
<span class="field-label">时间范围</span>
<div class="mission-toolbar-inline">
{{ datetime_picker_field('开始', 'start', timeline_range_start, field_id='timeline-start') }}
{{ datetime_picker_field('结束', 'end', timeline_range_end, field_id='timeline-end') }}
<label>
<span class="field-label">尺度</span>
<select name="scale">
{% for option in timeline_scale_options %}
<option value="{{ option.value }}" {% if option.value == timeline_scale_mode %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
</label>
</div>
</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);
<div class="mission-toolbar-block">
<span class="field-label">时间线筛选</span>
<div class="mission-filter-grid">
<label>
<span class="field-label">资产类型</span>
<select name="asset_type">
<option value="">全部类型</option>
{% for option in asset_type_options %}
<option value="{{ option }}" {% if option == asset_type_filter %}selected{% endif %}>{{ option }}</option>
{% endfor %}
</select>
</label>
<label>
<span class="field-label">地点</span>
<select name="location">
<option value="">全部地点</option>
{% for option in location_options %}
<option value="{{ option }}" {% if option == location_filter %}selected{% endif %}>{{ option }}</option>
{% endfor %}
</select>
</label>
<label>
<span class="field-label">记录范围</span>
<select name="record_scope">
{% for option in record_scope_options %}
<option value="{{ option.value }}" {% if option.value == record_scope %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
</label>
<button class="primary-button" type="submit">应用</button>
<a class="secondary-button" href="{{ reset_url }}">重置</a>
</div>
</div>
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(); });
});
<div class="mission-toolbar-block mission-toolbar-span-two">
<span class="field-label">显示资产</span>
{% if timeline_asset_options %}
<div class="mission-asset-picker" data-asset-picker>
<div class="mission-asset-picker-toolbar">
<label class="mission-asset-picker-search">
<span class="field-label">按名称搜索</span>
<input type="search" placeholder="搜索资产名称" data-asset-picker-search>
</label>
<div class="mission-asset-picker-actions">
<button class="secondary-button" type="button" data-asset-picker-select-all>全选可见</button>
<button class="secondary-button" type="button" data-asset-picker-clear>清空选择</button>
<span class="small-muted" data-asset-picker-summary></span>
</div>
</div>
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';
}
}
});
<div class="mission-asset-picker-grid">
{% for option in timeline_asset_options %}
<label class="mission-asset-picker-item" data-asset-picker-item data-asset-picker-name="{{ option.label|lower }}">
<input type="checkbox" name="asset_ids" value="{{ option.value }}" data-asset-picker-checkbox {% if option.selected %}checked{% endif %}>
<span>
<strong>{{ option.label }}</strong>
<small>{{ option.asset_type }}</small>
</span>
</label>
{% endfor %}
</div>
</div>
<p class="field-hint">未勾选任何 Asset 时,默认显示当前筛选命中的全部资产。</p>
{% else %}
<p class="small-muted">当前筛选条件下没有可选资产。</p>
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>
</form>
</section>
<section class="panel mission-timeline-panel top-gap">
<div class="mission-timeline-toolbar">
<div>
<p class="eyebrow">Timeline</p>
<h2>多资产时间线</h2>
</div>
<div class="mission-legend">
<span class="legend-item"><span class="legend-swatch warning"></span>转移段</span>
<span class="legend-item"><span class="legend-swatch primary"></span>持续运行</span>
<span class="legend-item"><span class="legend-swatch neutral"></span>停泊 / 地表 / 待命</span>
<span class="legend-item"><span class="legend-swatch current"></span>当前活动状态</span>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="mission-filter-pill-row">
<span class="mission-filter-pill"><strong>范围:</strong> {{ timeline_range_display }}</span>
<span class="mission-filter-pill"><strong>尺度:</strong> {{ timeline_scale_mode|title }}</span>
<span class="mission-filter-pill"><strong>资产:</strong> {% if selected_asset_count %}已选 {{ selected_asset_count }} 个{% else %}显示 {{ timeline_rows|length }} 条{% endif %}</span>
<span class="mission-filter-pill"><strong>模式:</strong> {{ record_scope_options|selectattr('value', 'equalto', record_scope)|map(attribute='label')|first }}</span>
</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>
{% if timeline_rows %}
<div class="mission-timeline-scroll">
<div class="mission-timeline-frame" style="min-width: {{ 320 + (timeline_scale|length * timeline_column_width) }}px;">
<div class="mission-timeline-scale">
<div class="mission-timeline-corner"></div>
<div class="mission-scale-labels" style="--timeline-columns: {{ timeline_scale|length }}; --timeline-column-width: {{ timeline_column_width }}px;">
{% for label in timeline_scale %}
<span>{{ label }}</span>
{% endfor %}
</div>
</div>
<div class="mission-timeline-rows">
{% for row in timeline_rows %}
<div class="mission-timeline-row">
<a class="mission-timeline-label mission-timeline-label-link" href="{{ url_for('web.asset_detail', asset_id=row.asset_id, sim_time=simulation_time_input) }}">
<h3>{{ row.name }}</h3>
<p class="small-muted mission-timeline-meta">{{ row.asset_type }}</p>
<p class="small-muted mission-timeline-status">Current Status: {{ row.current_state }}</p>
</a>
<div class="mission-timeline-track-stack">
<div class="mission-timeline-lane mission-timeline-state-lane {% if timeline_scale_mode == 'year' %}mission-timeline-lane-year{% endif %}" style="--timeline-columns: {{ timeline_scale|length }}; --timeline-column-width: {{ timeline_column_width }}px; --timeline-track-count: {{ row.state_track_count }};">
{% for segment in row.segments %}
<a class="mission-segment mission-timeline-link {% if segment.compact %}mission-segment-compact {% endif %}{% for tone in segment.tone.split() %}mission-tone-{{ tone }} {% endfor %}" style="left: {{ segment.left_pct }}; width: {{ segment.track_width_pct }}; --timeline-track-index: {{ segment.track }};" title="{{ segment.label }}" data-full-label="{{ segment.label }}" href="{{ segment.url }}">
<span class="mission-segment-body" style="width: {{ segment.body_width_pct }};"></span>
<span class="mission-segment-text">{{ segment.label }}</span>
</a>
{% endfor %}
</div>
<div class="mission-timeline-lane mission-timeline-event-lane {% if timeline_scale_mode == 'year' %}mission-timeline-lane-year{% endif %}" style="--timeline-columns: {{ timeline_scale|length }}; --timeline-column-width: {{ timeline_column_width }}px; --timeline-track-count: {{ row.event_track_count }};">
{% for event in row.events %}
<a class="mission-event-node mission-timeline-link {% if event.state_node %}mission-event-node-state{% endif %}{% if event.label_side == 'left' %} mission-event-node-label-left{% endif %}" style="left: {{ event.left_pct }}; --timeline-track-index: {{ event.track }}; --event-label-width: {{ event.label_width_px }}px;{% if event.connector_height_px %} --state-node-connector-height: {{ event.connector_height_px }};{% endif %}" title="{{ event.label }}" data-full-label="{{ event.full_label }}" href="{{ event.url }}">
{% if event.state_node %}
<span class="mission-state-node-connector" aria-hidden="true"></span>
{% endif %}
<span class="mission-event-node-label">{{ event.label }}</span>
</a>
{% endfor %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% else %}
<section class="empty-state compact-empty">
<h2>当前时间范围内没有时间线条目</h2>
</section>
{% endif %}
</section>
{% endblock %}
{% block scripts %}
<script id="tl-json" type="application/json">{{ timeline_json|safe }}</script>
<script>
(() => {
const picker = document.querySelector('[data-asset-picker]');
if (!picker) {
return;
(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 };
}
const searchInput = picker.querySelector('[data-asset-picker-search]');
const summary = picker.querySelector('[data-asset-picker-summary]');
const items = Array.from(picker.querySelectorAll('[data-asset-picker-item]'));
const rangeModeField = document.getElementById('timeline-range-mode');
const rangeInputs = [document.getElementById('timeline-start'), document.getElementById('timeline-end')].filter(Boolean);
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;
const checkboxFor = (item) => item.querySelector('[data-asset-picker-checkbox]');
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>';
const updateSummary = () => {
const visibleCount = items.filter((item) => !item.hidden).length;
const selectedCount = items.filter((item) => checkboxFor(item)?.checked).length;
summary.textContent = `已选 ${selectedCount} 个 · 可见 ${visibleCount}`;
};
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);
const setRangeModeManual = () => {
if (rangeModeField) {
rangeModeField.value = 'manual';
}
};
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">';
const applySearch = () => {
const term = (searchInput?.value || '').trim().toLowerCase();
for (const item of items) {
const assetName = item.dataset.assetPickerName || '';
item.hidden = Boolean(term) && !assetName.includes(term);
}
updateSummary();
};
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>';
}
picker.querySelector('[data-asset-picker-select-all]')?.addEventListener('click', () => {
for (const item of items) {
const checkbox = checkboxFor(item);
if (!item.hidden && checkbox) {
checkbox.checked = true;
}
}
updateSummary();
});
picker.querySelector('[data-asset-picker-clear]')?.addEventListener('click', () => {
for (const item of items) {
const checkbox = checkboxFor(item);
if (checkbox) {
checkbox.checked = false;
}
}
updateSummary();
});
searchInput?.addEventListener('input', applySearch);
for (const item of items) {
checkboxFor(item)?.addEventListener('change', updateSummary);
}
for (const input of rangeInputs) {
input.addEventListener('input', setRangeModeManual);
input.addEventListener('change', setRangeModeManual);
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>';
}
}
applySearch();
})();
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 %}
{% endblock %}