refactor: client-side timeline rendering with lane stacking, zoom, drag, event positioning
- Client-side buildHTML() replaces server-side Jinja loop rendering - Greedy lane assignment algorithm (assignLanes) to prevent segment overlap - assignEventLanes for detached events stacking - Wheel zoom with cursor-position anchor; drag pan with user-select:none - Bucket-based positioning: startOfBucket/nextBucket/bucketDelta for precision - Events positioned on parent segment track lane; detached events in separate row - resolveScale with explicit ?scale= URL param support - Backend: add bucket_origin, track, parent_entry_id, parent_track to JSON data Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -185,9 +185,9 @@
|
||||
|
||||
<!-- 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>
|
||||
<button class="b sm" type="button" id="tl-zoom-in">Zoom +</button>
|
||||
<button class="b sm" type="button" id="tl-zoom-out">Zoom −</button>
|
||||
<button class="b sm" type="button" id="tl-reset-view">Reset View</button>
|
||||
<span style="font-size:.64rem;color:var(--muted);margin-left:8px">Scroll to zoom · Drag to pan</span>
|
||||
</div>
|
||||
|
||||
@@ -203,46 +203,109 @@
|
||||
let tlData = jsonEl ? JSON.parse(jsonEl.textContent) : null;
|
||||
if (!tlData || !tlData.rows) return;
|
||||
const DAY_MS = 86400000;
|
||||
const MIN_BUCKETS = 3, MAX_BUCKETS = 200;
|
||||
const NAME_W = 130;
|
||||
const TRACK_TOP = 10;
|
||||
const SEG_H = 18;
|
||||
const SEG_GAP = 4;
|
||||
const EVENT_H = 18;
|
||||
const EVENT_GAP = 4;
|
||||
const FLOAT_LABEL_STYLE = 'font-size:.52rem;line-height:1;color:var(--text);background:var(--surface);border:1px solid var(--border);border-radius:3px;padding:2px 4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:120px;box-shadow:0 6px 16px rgba(0,0,0,.18)';
|
||||
const initialRangeStart = tlData.range_start;
|
||||
const initialRangeEnd = tlData.range_end;
|
||||
const extentStart = parseISO(initialRangeStart);
|
||||
const extentEnd = parseISO(initialRangeEnd);
|
||||
let zoomLevel = 1;
|
||||
|
||||
function 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; }
|
||||
function pad(n) { return String(n).padStart(2, '0'); }
|
||||
function parseISO(s) {
|
||||
if (!s) return null;
|
||||
let value = String(s).replace(' ', 'T');
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) value += 'T00:00';
|
||||
return new Date(value);
|
||||
}
|
||||
function fmtDate(d) { return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); }
|
||||
function fmtDisplay(d) { return fmtDate(d); }
|
||||
function esc(value) {
|
||||
return String(value == null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Determine scale mode from range span
|
||||
function autoScale(from, to) {
|
||||
const days = (to - from) / DAY_MS;
|
||||
if (days <= 60) return 'day';
|
||||
if (days <= 730) return 'month';
|
||||
const days = (to - from) / DAY_MS / Math.max(zoomLevel, 1);
|
||||
if (days <= 120) return 'day';
|
||||
if (days <= 365 * 4) return 'month';
|
||||
return 'year';
|
||||
}
|
||||
|
||||
function explicitScale() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const select = document.getElementById('tl-scale');
|
||||
const value = select ? select.value : '';
|
||||
return params.has('scale') && value ? value : '';
|
||||
}
|
||||
|
||||
function resolveScale(from, to) {
|
||||
return explicitScale() || autoScale(from, to);
|
||||
}
|
||||
|
||||
function startOfBucket(date, scale) {
|
||||
if (scale === 'year') return new Date(date.getFullYear(), 0, 1);
|
||||
if (scale === 'month') return new Date(date.getFullYear(), date.getMonth(), 1);
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
}
|
||||
|
||||
function nextBucket(date, scale) {
|
||||
const d = startOfBucket(date, scale);
|
||||
if (scale === 'year') return new Date(d.getFullYear() + 1, 0, 1);
|
||||
if (scale === 'month') return new Date(d.getFullYear(), d.getMonth() + 1, 1);
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1);
|
||||
}
|
||||
|
||||
function bucketDelta(origin, bucketStart, scale) {
|
||||
if (scale === 'year') return bucketStart.getFullYear() - origin.getFullYear();
|
||||
if (scale === 'month') {
|
||||
return (bucketStart.getFullYear() - origin.getFullYear()) * 12 + bucketStart.getMonth() - origin.getMonth();
|
||||
}
|
||||
return Math.round((bucketStart - origin) / DAY_MS);
|
||||
}
|
||||
|
||||
// Build scale labels
|
||||
function buildScaleLabels(from, to, scale) {
|
||||
let labels = [];
|
||||
let d = startOfBucket(from, scale);
|
||||
const origin = new Date(d);
|
||||
const boundary = startOfBucket(to, scale);
|
||||
if (scale === 'day') {
|
||||
let d = new Date(from); d.setHours(0,0,0,0);
|
||||
while (d <= to) { labels.push(fmtDisplay(d)); d.setDate(d.getDate()+1); }
|
||||
while (d <= boundary) { labels.push(fmtDisplay(d)); d = nextBucket(d, scale); }
|
||||
} else if (scale === 'month') {
|
||||
let d = new Date(from.getFullYear(), from.getMonth(), 1);
|
||||
while (d <= to) {
|
||||
while (d <= boundary) {
|
||||
labels.push(d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0'));
|
||||
d.setMonth(d.getMonth()+1);
|
||||
d = nextBucket(d, scale);
|
||||
}
|
||||
} else {
|
||||
let y = from.getFullYear();
|
||||
while (y <= to.getFullYear()) { labels.push(String(y)); y++; }
|
||||
while (d <= boundary) {
|
||||
labels.push(String(d.getFullYear()));
|
||||
d = nextBucket(d, scale);
|
||||
}
|
||||
}
|
||||
return { labels: labels, count: labels.length };
|
||||
return { labels: labels, count: labels.length, origin: origin };
|
||||
}
|
||||
|
||||
// Client-side position recalculation from ISO timestamps
|
||||
function calcPosition(isoStr, from, to, bucketCount) {
|
||||
function calcPosition(isoStr, origin, scale, 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;
|
||||
if (!t || t <= origin) return 0;
|
||||
const bucketStart = startOfBucket(t, scale);
|
||||
const bucketEnd = nextBucket(bucketStart, scale);
|
||||
const index = bucketDelta(origin, bucketStart, scale);
|
||||
const fraction = Math.max(0, Math.min(1, (t - bucketStart) / Math.max(bucketEnd - bucketStart, 1)));
|
||||
return Math.max(0, Math.min(bucketCount, index + fraction));
|
||||
}
|
||||
|
||||
function bucketW(scale) {
|
||||
@@ -251,12 +314,16 @@
|
||||
return 250;
|
||||
}
|
||||
|
||||
const SEG_H = 18, SEG_GAP = 2;
|
||||
function overviewTrackWidth() {
|
||||
const from = extentStart || parseISO(tlData.range_start);
|
||||
const to = extentEnd || parseISO(tlData.range_end);
|
||||
return buildScaleLabels(from, to, 'year').count * bucketW('year');
|
||||
}
|
||||
|
||||
function assignLanes(segments, from, to, bucketCount) {
|
||||
function assignLanes(segments, origin, scale, 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;
|
||||
const sp = calcPosition(s.start_iso, origin, scale, bucketCount);
|
||||
const ep = s.end_iso ? calcPosition(s.end_iso, origin, scale, bucketCount) : bucketCount;
|
||||
return { seg: s, sp: sp, ep: Math.max(ep, sp + 0.05) };
|
||||
}).sort(function(a, b) { return a.sp - b.sp || b.ep - a.ep; });
|
||||
|
||||
@@ -279,40 +346,63 @@
|
||||
return { items: items, laneCount: lanes.length };
|
||||
}
|
||||
|
||||
function assignEventLanes(events, gap) {
|
||||
const lanes = [];
|
||||
for (const item of events) {
|
||||
let lane = 0;
|
||||
while (lane < lanes.length && item.ep < lanes[lane] + gap) lane++;
|
||||
if (lane === lanes.length) lanes.push(item.ep);
|
||||
else lanes[lane] = item.ep;
|
||||
item.lane = lane;
|
||||
}
|
||||
return lanes.length;
|
||||
}
|
||||
|
||||
function buildHTML(data) {
|
||||
const from = parseISO(data.range_start);
|
||||
const to = parseISO(data.range_end);
|
||||
const scale = autoScale(from, to);
|
||||
const from = extentStart || parseISO(data.range_start);
|
||||
const to = extentEnd || parseISO(data.range_end);
|
||||
const scale = resolveScale(from, to);
|
||||
const sl = buildScaleLabels(from, to, scale);
|
||||
const bucketCount = sl.count;
|
||||
const EVT_H = 12;
|
||||
const origin = sl.origin;
|
||||
|
||||
let html = '';
|
||||
html += '<div style="display:flex;height:36px;border-bottom:1px solid var(--border);font-size:.6rem;color:var(--muted);align-items:flex-end;padding-bottom:4px">';
|
||||
html += '<div style="width: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="width:' + NAME_W + 'px;flex-shrink:0;position:sticky;left:0;background:var(--bg);z-index:2;border-right:1px solid var(--border);padding:0 8px">' + scale.toUpperCase() + '</div>';
|
||||
html += '<div style="flex:1;position:relative;min-width:0">';
|
||||
for (let i = 0; i < bucketCount; i++) {
|
||||
const leftPct = (i / bucketCount * 100).toFixed(4);
|
||||
const widthPct = (1 / bucketCount * 100).toFixed(4);
|
||||
html += '<div style="position:absolute;left:' + leftPct + '%;width:' + widthPct + '%;text-align:center;white-space:nowrap;overflow:hidden">' + sl.labels[i] + '</div>';
|
||||
html += '<div style="position:absolute;left:' + leftPct + '%;width:' + widthPct + '%;text-align:center;white-space:nowrap;overflow:hidden">' + esc(sl.labels[i]) + '</div>';
|
||||
}
|
||||
html += '</div></div>';
|
||||
|
||||
for (const row of data.rows) {
|
||||
const visSegs = row.segments.filter(function(s) {
|
||||
const sp = calcPosition(s.start_iso, 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);
|
||||
const sp = calcPosition(s.start_iso, origin, scale, bucketCount);
|
||||
const ep = s.end_iso ? calcPosition(s.end_iso, origin, scale, bucketCount) : bucketCount;
|
||||
return ep > 0 && sp < bucketCount;
|
||||
});
|
||||
const result = assignLanes(visSegs, 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 result = assignLanes(visSegs, origin, scale, bucketCount);
|
||||
const segmentById = new Map();
|
||||
for (const item of result.items) segmentById.set(String(item.seg.entry_id), item);
|
||||
const visibleEvents = (row.events || []).map(function(evt) {
|
||||
const at = parseISO(evt.at_iso);
|
||||
return { evt: evt, at: at, ep: calcPosition(evt.at_iso, origin, scale, bucketCount) };
|
||||
}).filter(function(item) {
|
||||
return item.at && item.at >= from && item.at <= to && item.ep >= 0 && item.ep <= bucketCount;
|
||||
});
|
||||
const detachedEvents = visibleEvents.filter(function(item) {
|
||||
return !item.evt.is_state_node || !segmentById.has(String(item.evt.parent_entry_id || ''));
|
||||
}).sort(function(a, b) { return a.ep - b.ep; });
|
||||
const detachedLaneCount = assignEventLanes(detachedEvents, scale === 'year' ? 0.42 : 0.9);
|
||||
const maxLane = Math.max(result.laneCount, visSegs.length ? 1 : 0);
|
||||
const segAreaH = TRACK_TOP + Math.max(maxLane, 1) * (SEG_H + SEG_GAP) + 4;
|
||||
const eventAreaH = detachedLaneCount ? detachedLaneCount * (EVENT_H + EVENT_GAP) + 8 : 0;
|
||||
const rowH = Math.max(36, segAreaH + eventAreaH);
|
||||
|
||||
html += '<div style="display:flex;border-bottom:1px solid rgba(148,163,184,.06)">';
|
||||
html += '<div style="width: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="width:' + NAME_W + 'px;flex-shrink:0;display:flex;align-items:center;padding:0 8px;font-size:.68rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500;position:sticky;left:0;background:var(--bg);z-index:2;border-right:1px solid var(--border);height:' + rowH + 'px"><a href="' + esc(row.asset_url) + '" style="color:var(--accent);text-decoration:none;overflow:hidden;text-overflow:ellipsis" title="' + esc(row.asset_name) + '">' + esc(row.asset_name) + '</a></div>';
|
||||
html += '<div style="flex:1;position:relative;height:' + rowH + 'px;min-width:0">';
|
||||
|
||||
for (const item of result.items) {
|
||||
@@ -321,16 +411,17 @@
|
||||
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 top = TRACK_TOP + item.lane * (SEG_H + SEG_GAP);
|
||||
const seg = item.seg;
|
||||
html += '<div class="tl-fl" style="position:absolute;left:' + leftPct + '%;width:' + w.toFixed(4) + '%;top:' + top + 'px;height:' + SEG_H + 'px"><div style="width:100%;height:100%;cursor:pointer;display:flex;align-items:center;padding:0 4px;font-size:.56rem;color:rgba(255,255,255,.9);overflow:hidden;white-space:nowrap;border-radius:3px;background:' + seg.body_color + ';opacity:.9" data-url="' + seg.url + '" data-label="' + seg.label + '" data-start="' + seg.start_at + '" data-end="' + seg.end_at + '" title="' + seg.label + ' ' + seg.start_at + ' → ' + seg.end_at + ' Click to edit" onclick="if(this.dataset.url)window.location.href=this.dataset.url">' + seg.label + '</div></div>';
|
||||
}
|
||||
|
||||
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);
|
||||
for (const item of visibleEvents) {
|
||||
const evt = item.evt;
|
||||
const ep = item.ep;
|
||||
const key = evt.label + '|' + evt.at_iso;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
@@ -339,13 +430,18 @@
|
||||
? '<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>';
|
||||
const parentItem = evt.is_state_node ? segmentById.get(String(evt.parent_entry_id || '')) : null;
|
||||
const eventTop = parentItem ? TRACK_TOP + parentItem.lane * (SEG_H + SEG_GAP) + SEG_H - 2 : segAreaH + (item.lane || 0) * (EVENT_H + EVENT_GAP);
|
||||
const labelClass = evt.is_state_node ? 'tl-state-label' : 'tl-event-label';
|
||||
const labelStyle = FLOAT_LABEL_STYLE + (evt.is_state_node ? ';opacity:0;visibility:hidden;transform:translateY(-2px);transition:opacity .12s,visibility .12s,transform .12s' : '');
|
||||
const eventLabel = '<span class="' + labelClass + '" style="' + labelStyle + '">' + esc(evt.label) + '</span>';
|
||||
html += '<div class="' + cls + '" tabindex="0" data-parent-entry-id="' + esc(evt.parent_entry_id || '') + '" data-parent-track="' + (parentItem ? parentItem.lane : '') + '" style="position:absolute;left:calc(' + epPct + '% - 3px);top:' + eventTop + 'px;display:flex;align-items:center;gap:3px;max-width:150px;cursor:pointer;z-index:3" data-url="' + esc(evt.url) + '" data-label="' + esc(evt.label) + '" data-full="' + esc(evt.full_label||evt.label) + '" title="' + esc(evt.full_label||evt.label) + ' Click to edit" onclick="if(this.dataset.url)window.location.href=this.dataset.url">' + shape + eventLabel + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
html += '</div></div>';
|
||||
}
|
||||
return { html: html, buckets: bucketCount };
|
||||
return { html: html, buckets: bucketCount, scale: scale };
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
@@ -354,58 +450,57 @@
|
||||
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);
|
||||
const vpW = vp ? vp.offsetWidth : 1200;
|
||||
const continuousTrackW = overviewTrackWidth() * zoomLevel;
|
||||
const explicitTrackW = result.buckets * bucketW(result.scale);
|
||||
const naturalTrackW = explicitScale() ? Math.max(continuousTrackW, explicitTrackW) : continuousTrackW;
|
||||
const naturalW = NAME_W + naturalTrackW;
|
||||
canvas.style.width = Math.max(vpW, naturalW) + 'px';
|
||||
const fromInput = document.getElementById('tl-from');
|
||||
const toInput = document.getElementById('tl-to');
|
||||
if (fromInput) fromInput.value = tlData.range_start.slice(0,10);
|
||||
if (toInput) toInput.value = tlData.range_end.slice(0,10);
|
||||
if (fromInput) fromInput.value = String(initialRangeStart || '').slice(0,10);
|
||||
if (toInput) toInput.value = String(initialRangeEnd || '').slice(0,10);
|
||||
}
|
||||
|
||||
renderAll();
|
||||
|
||||
// === Zoom functions ===
|
||||
const ZOOM_MIN = new Date(2020, 0, 1).getTime();
|
||||
const ZOOM_MAX = new Date(2150, 0, 1).getTime();
|
||||
const ZOOM_MIN_LEVEL = 1;
|
||||
const ZOOM_MAX_LEVEL = 96;
|
||||
|
||||
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;
|
||||
let pointerOffset = null;
|
||||
if (vp && canvas) {
|
||||
const nameW = NAME_W;
|
||||
const rect = vp.getBoundingClientRect();
|
||||
const pointerX = mouseX === undefined ? rect.left + Math.min(vp.clientWidth, rect.width) * 0.5 : mouseX;
|
||||
const trackScreenLeft = rect.left + nameW;
|
||||
const trackW = Math.max(1, canvas.offsetWidth - nameW);
|
||||
pointerOffset = pointerX - rect.left - nameW;
|
||||
const mouseInTrack = pointerX - trackScreenLeft + vp.scrollLeft;
|
||||
anchorRatio = Math.max(0, Math.min(1, mouseInTrack / trackW));
|
||||
}
|
||||
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';
|
||||
zoomLevel = Math.max(ZOOM_MIN_LEVEL, Math.min(ZOOM_MAX_LEVEL, zoomLevel / factor));
|
||||
renderAll();
|
||||
if (mouseX !== undefined && vp && canvas) {
|
||||
const nameW = 130;
|
||||
const newTrackW = canvas.offsetWidth - nameW;
|
||||
if (vp && canvas && pointerOffset !== null) {
|
||||
const nameW = NAME_W;
|
||||
const newTrackW = Math.max(1, canvas.offsetWidth - nameW);
|
||||
const anchorPx = newTrackW * anchorRatio;
|
||||
vp.scrollLeft = Math.max(0, anchorPx - (mouseX - vp.getBoundingClientRect().left - nameW));
|
||||
const maxScroll = Math.max(0, canvas.offsetWidth - vp.clientWidth);
|
||||
vp.scrollLeft = Math.max(0, Math.min(maxScroll, anchorPx - pointerOffset));
|
||||
}
|
||||
}
|
||||
function tlResetView() {
|
||||
location.href = location.pathname;
|
||||
zoomLevel = 1;
|
||||
renderAll();
|
||||
viewport.scrollLeft = 0;
|
||||
}
|
||||
window.tlZoom = tlZoom;
|
||||
window.tlResetView = tlResetView;
|
||||
document.getElementById('tl-zoom-in')?.addEventListener('click', function() { tlZoom(0.75); });
|
||||
document.getElementById('tl-zoom-out')?.addEventListener('click', function() { tlZoom(1.25); });
|
||||
document.getElementById('tl-reset-view')?.addEventListener('click', tlResetView);
|
||||
|
||||
// === Wheel zoom ===
|
||||
let wheelRAF = null;
|
||||
@@ -413,9 +508,10 @@
|
||||
e.preventDefault();
|
||||
if (wheelRAF) return;
|
||||
const mx = e.clientX;
|
||||
const dy = e.deltaY;
|
||||
wheelRAF = requestAnimationFrame(function() {
|
||||
wheelRAF = null;
|
||||
tlZoom(e.deltaY < 0 ? 0.85 : 1 / 0.85, mx);
|
||||
tlZoom(dy < 0 ? 0.85 : 1 / 0.85, mx);
|
||||
});
|
||||
}, {passive: false});
|
||||
|
||||
@@ -436,23 +532,32 @@
|
||||
// === 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); }
|
||||
if (!tip) { tip = document.createElement('div'); tip.id = 'tl-tip'; tip.style.cssText = 'position:fixed;z-index:100;background:var(--surface);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:8px 12px;font-size:.74rem;pointer-events:none;display:none;max-width:350px;box-shadow:0 8px 24px rgba(0,0,0,.24)'; document.body.appendChild(tip); }
|
||||
}
|
||||
function revealStateLabel(node, visible) {
|
||||
const label = node ? node.querySelector('.tl-state-label') : null;
|
||||
if (!label) return;
|
||||
label.style.opacity = visible ? '1' : '0';
|
||||
label.style.visibility = visible ? 'visible' : 'hidden';
|
||||
label.style.transform = visible ? 'translateY(0)' : 'translateY(-2px)';
|
||||
}
|
||||
document.getElementById('tl-canvas').addEventListener('mouseover', function(e) {
|
||||
const seg = e.target.closest('.tl-fl div');
|
||||
const evt = e.target.closest('.tl-ev,.tl-sn');
|
||||
const stateNode = e.target.closest('.tl-sn');
|
||||
ensureTip();
|
||||
if (seg && seg.dataset.url) {
|
||||
seg.style.filter = 'brightness(1.3)';
|
||||
tip.innerHTML = '<strong>' + (seg.dataset.label||'') + '</strong><br><span style="color:#94A3B8;font-size:.66rem">' + (seg.dataset.start||'') + ' → ' + (seg.dataset.end||'') + '</span><br><span style="color:#94A3B8;font-size:.66rem">Click to edit</span>';
|
||||
tip.style.display = 'block';
|
||||
} else if (evt) {
|
||||
revealStateLabel(stateNode, true);
|
||||
tip.innerHTML = '<strong>' + (evt.dataset.label||'') + '</strong><br><span style="color:#94A3B8;font-size:.66rem">' + (evt.dataset.full||evt.dataset.label||'') + '</span><br><span style="color:#94A3B8;font-size:.66rem">Click to edit</span>';
|
||||
tip.style.display = 'block';
|
||||
}
|
||||
});
|
||||
document.getElementById('tl-canvas').addEventListener('mousemove', function(e) { if (tip && tip.style.display==='block') { tip.style.left=(e.clientX+12)+'px'; tip.style.top=(e.clientY-30)+'px'; }});
|
||||
document.getElementById('tl-canvas').addEventListener('mouseout', function(e) { if (e.target.closest('.tl-fl div')) e.target.closest('.tl-fl div').style.filter=''; if (tip) tip.style.display='none'; });
|
||||
document.getElementById('tl-canvas').addEventListener('mouseout', function(e) { if (e.target.closest('.tl-fl div')) e.target.closest('.tl-fl div').style.filter=''; revealStateLabel(e.target.closest('.tl-sn'), false); if (tip) tip.style.display='none'; });
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user