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:
2026-06-05 16:19:44 +08:00
co-authored by Claude Opus 4.7
parent e0c608b7f3
commit 84e5027874
+186 -81
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// 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 + '&#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>';
}
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) + '&#10;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) + '&#10;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 %}