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:
@@ -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 }} {{ 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>
|
||||
</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 + ' ' + seg.start_at + ' → ' + seg.end_at + ' 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) + ' 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 %}
|
||||
|
||||
Reference in New Issue
Block a user