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 -->
|
<!-- Zoom controls -->
|
||||||
<div style="display:flex;align-items:center;gap:6px;margin-top:8px">
|
<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" type="button" id="tl-zoom-in">Zoom +</button>
|
||||||
<button class="b sm" onclick="tlZoom(0.7)">Zoom −</button>
|
<button class="b sm" type="button" id="tl-zoom-out">Zoom −</button>
|
||||||
<button class="b sm" onclick="tlResetView()">Reset View</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>
|
<span style="font-size:.64rem;color:var(--muted);margin-left:8px">Scroll to zoom · Drag to pan</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -203,46 +203,109 @@
|
|||||||
let tlData = jsonEl ? JSON.parse(jsonEl.textContent) : null;
|
let tlData = jsonEl ? JSON.parse(jsonEl.textContent) : null;
|
||||||
if (!tlData || !tlData.rows) return;
|
if (!tlData || !tlData.rows) return;
|
||||||
const DAY_MS = 86400000;
|
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 pad(n) { return String(n).padStart(2, '0'); }
|
||||||
function fmtDate(d) { return d.toISOString().slice(0,10); }
|
function parseISO(s) {
|
||||||
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; }
|
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
|
// Determine scale mode from range span
|
||||||
function autoScale(from, to) {
|
function autoScale(from, to) {
|
||||||
const days = (to - from) / DAY_MS;
|
const days = (to - from) / DAY_MS / Math.max(zoomLevel, 1);
|
||||||
if (days <= 60) return 'day';
|
if (days <= 120) return 'day';
|
||||||
if (days <= 730) return 'month';
|
if (days <= 365 * 4) return 'month';
|
||||||
return 'year';
|
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
|
// Build scale labels
|
||||||
function buildScaleLabels(from, to, scale) {
|
function buildScaleLabels(from, to, scale) {
|
||||||
let labels = [];
|
let labels = [];
|
||||||
|
let d = startOfBucket(from, scale);
|
||||||
|
const origin = new Date(d);
|
||||||
|
const boundary = startOfBucket(to, scale);
|
||||||
if (scale === 'day') {
|
if (scale === 'day') {
|
||||||
let d = new Date(from); d.setHours(0,0,0,0);
|
while (d <= boundary) { labels.push(fmtDisplay(d)); d = nextBucket(d, scale); }
|
||||||
while (d <= to) { labels.push(fmtDisplay(d)); d.setDate(d.getDate()+1); }
|
|
||||||
} else if (scale === 'month') {
|
} else if (scale === 'month') {
|
||||||
let d = new Date(from.getFullYear(), from.getMonth(), 1);
|
while (d <= boundary) {
|
||||||
while (d <= to) {
|
|
||||||
labels.push(d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0'));
|
labels.push(d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0'));
|
||||||
d.setMonth(d.getMonth()+1);
|
d = nextBucket(d, scale);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let y = from.getFullYear();
|
while (d <= boundary) {
|
||||||
while (y <= to.getFullYear()) { labels.push(String(y)); y++; }
|
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
|
// Client-side position recalculation from ISO timestamps
|
||||||
function calcPosition(isoStr, from, to, bucketCount) {
|
function calcPosition(isoStr, origin, scale, bucketCount) {
|
||||||
if (!isoStr) return 0;
|
if (!isoStr) return 0;
|
||||||
const t = parseISO(isoStr);
|
const t = parseISO(isoStr);
|
||||||
if (!t || t <= from) return 0;
|
if (!t || t <= origin) return 0;
|
||||||
if (t >= to) return bucketCount;
|
const bucketStart = startOfBucket(t, scale);
|
||||||
return ((t - from) / (to - from)) * bucketCount;
|
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) {
|
function bucketW(scale) {
|
||||||
@@ -251,12 +314,16 @@
|
|||||||
return 250;
|
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 items = segments.map(function(s) {
|
||||||
const sp = calcPosition(s.start_iso, from, to, bucketCount);
|
const sp = calcPosition(s.start_iso, origin, scale, bucketCount);
|
||||||
const ep = s.end_iso ? calcPosition(s.end_iso, from, to, bucketCount) : 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) };
|
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; });
|
}).sort(function(a, b) { return a.sp - b.sp || b.ep - a.ep; });
|
||||||
|
|
||||||
@@ -279,40 +346,63 @@
|
|||||||
return { items: items, laneCount: lanes.length };
|
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) {
|
function buildHTML(data) {
|
||||||
const from = parseISO(data.range_start);
|
const from = extentStart || parseISO(data.range_start);
|
||||||
const to = parseISO(data.range_end);
|
const to = extentEnd || parseISO(data.range_end);
|
||||||
const scale = autoScale(from, to);
|
const scale = resolveScale(from, to);
|
||||||
const sl = buildScaleLabels(from, to, scale);
|
const sl = buildScaleLabels(from, to, scale);
|
||||||
const bucketCount = sl.count;
|
const bucketCount = sl.count;
|
||||||
const EVT_H = 12;
|
const origin = sl.origin;
|
||||||
|
|
||||||
let html = '';
|
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="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">';
|
html += '<div style="flex:1;position:relative;min-width:0">';
|
||||||
for (let i = 0; i < bucketCount; i++) {
|
for (let i = 0; i < bucketCount; i++) {
|
||||||
const leftPct = (i / bucketCount * 100).toFixed(4);
|
const leftPct = (i / bucketCount * 100).toFixed(4);
|
||||||
const widthPct = (1 / 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>';
|
html += '</div></div>';
|
||||||
|
|
||||||
for (const row of data.rows) {
|
for (const row of data.rows) {
|
||||||
const visSegs = row.segments.filter(function(s) {
|
const visSegs = row.segments.filter(function(s) {
|
||||||
const sp = calcPosition(s.start_iso, from, to, bucketCount);
|
const sp = calcPosition(s.start_iso, origin, scale, bucketCount);
|
||||||
const ep = s.end_iso ? calcPosition(s.end_iso, from, to, bucketCount) : bucketCount;
|
const ep = s.end_iso ? calcPosition(s.end_iso, origin, scale, bucketCount) : bucketCount;
|
||||||
const vis = Math.min(ep, bucketCount) - Math.max(sp, 0);
|
|
||||||
return ep > 0 && sp < bucketCount;
|
return ep > 0 && sp < bucketCount;
|
||||||
});
|
});
|
||||||
const result = assignLanes(visSegs, from, to, bucketCount);
|
const result = assignLanes(visSegs, origin, scale, bucketCount);
|
||||||
const maxLane = Math.max(result.laneCount, 1);
|
const segmentById = new Map();
|
||||||
const segAreaH = maxLane * (SEG_H + SEG_GAP) + 4;
|
for (const item of result.items) segmentById.set(String(item.seg.entry_id), item);
|
||||||
const hasEvt = visSegs.length > 0 && row.events && row.events.length > 0;
|
const visibleEvents = (row.events || []).map(function(evt) {
|
||||||
const rowH = segAreaH + (hasEvt ? EVT_H : 0);
|
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="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">';
|
html += '<div style="flex:1;position:relative;height:' + rowH + 'px;min-width:0">';
|
||||||
|
|
||||||
for (const item of result.items) {
|
for (const item of result.items) {
|
||||||
@@ -321,16 +411,17 @@
|
|||||||
const leftPct = (visSp / bucketCount * 100).toFixed(4);
|
const leftPct = (visSp / bucketCount * 100).toFixed(4);
|
||||||
const widthPct = ((visEp - visSp) / bucketCount * 100).toFixed(4);
|
const widthPct = ((visEp - visSp) / bucketCount * 100).toFixed(4);
|
||||||
const w = Math.max(parseFloat(widthPct), 0.3);
|
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;
|
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>';
|
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 evtY = segAreaH + 8;
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
for (const evt of row.events) {
|
for (const item of visibleEvents) {
|
||||||
const ep = calcPosition(evt.at_iso, from, to, bucketCount);
|
const evt = item.evt;
|
||||||
|
const ep = item.ep;
|
||||||
const key = evt.label + '|' + evt.at_iso;
|
const key = evt.label + '|' + evt.at_iso;
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue;
|
||||||
seen.add(key);
|
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: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>';
|
: '<div style="width:5px;height:5px;background:var(--warning);transform:rotate(45deg)"></div>';
|
||||||
const epPct = (ep / bucketCount * 100).toFixed(4);
|
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>';
|
html += '</div></div>';
|
||||||
}
|
}
|
||||||
return { html: html, buckets: bucketCount };
|
return { html: html, buckets: bucketCount, scale: scale };
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAll() {
|
function renderAll() {
|
||||||
@@ -354,58 +450,57 @@
|
|||||||
const result = buildHTML(tlData);
|
const result = buildHTML(tlData);
|
||||||
canvas.innerHTML = result.html;
|
canvas.innerHTML = result.html;
|
||||||
const vp = document.getElementById('tl-viewport');
|
const vp = document.getElementById('tl-viewport');
|
||||||
const vpW = vp ? vp.offsetWidth - 130 : 1200;
|
const vpW = vp ? vp.offsetWidth : 1200;
|
||||||
const scale = autoScale(parseISO(tlData.range_start), parseISO(tlData.range_end));
|
const continuousTrackW = overviewTrackWidth() * zoomLevel;
|
||||||
const naturalW = result.buckets * bucketW(scale);
|
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';
|
canvas.style.width = Math.max(vpW, naturalW) + 'px';
|
||||||
const fromInput = document.getElementById('tl-from');
|
const fromInput = document.getElementById('tl-from');
|
||||||
const toInput = document.getElementById('tl-to');
|
const toInput = document.getElementById('tl-to');
|
||||||
if (fromInput) fromInput.value = tlData.range_start.slice(0,10);
|
if (fromInput) fromInput.value = String(initialRangeStart || '').slice(0,10);
|
||||||
if (toInput) toInput.value = tlData.range_end.slice(0,10);
|
if (toInput) toInput.value = String(initialRangeEnd || '').slice(0,10);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderAll();
|
renderAll();
|
||||||
|
|
||||||
// === Zoom functions ===
|
// === Zoom functions ===
|
||||||
const ZOOM_MIN = new Date(2020, 0, 1).getTime();
|
const ZOOM_MIN_LEVEL = 1;
|
||||||
const ZOOM_MAX = new Date(2150, 0, 1).getTime();
|
const ZOOM_MAX_LEVEL = 96;
|
||||||
|
|
||||||
function tlZoom(factor, mouseX) {
|
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 vp = document.getElementById('tl-viewport');
|
||||||
const canvas = document.getElementById('tl-canvas');
|
const canvas = document.getElementById('tl-canvas');
|
||||||
let anchorRatio = 0.5;
|
let anchorRatio = 0.5;
|
||||||
if (mouseX !== undefined && vp && canvas) {
|
let pointerOffset = null;
|
||||||
const nameW = 130;
|
if (vp && canvas) {
|
||||||
const trackScreenLeft = vp.getBoundingClientRect().left + nameW;
|
const nameW = NAME_W;
|
||||||
const trackW = canvas.offsetWidth - nameW;
|
const rect = vp.getBoundingClientRect();
|
||||||
const mouseInTrack = mouseX - trackScreenLeft + vp.scrollLeft;
|
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));
|
anchorRatio = Math.max(0, Math.min(1, mouseInTrack / trackW));
|
||||||
}
|
}
|
||||||
const anchor = from.getTime() + span * anchorRatio;
|
zoomLevel = Math.max(ZOOM_MIN_LEVEL, Math.min(ZOOM_MAX_LEVEL, zoomLevel / factor));
|
||||||
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();
|
renderAll();
|
||||||
if (mouseX !== undefined && vp && canvas) {
|
if (vp && canvas && pointerOffset !== null) {
|
||||||
const nameW = 130;
|
const nameW = NAME_W;
|
||||||
const newTrackW = canvas.offsetWidth - nameW;
|
const newTrackW = Math.max(1, canvas.offsetWidth - nameW);
|
||||||
const anchorPx = newTrackW * anchorRatio;
|
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() {
|
function tlResetView() {
|
||||||
location.href = location.pathname;
|
zoomLevel = 1;
|
||||||
|
renderAll();
|
||||||
|
viewport.scrollLeft = 0;
|
||||||
}
|
}
|
||||||
window.tlZoom = tlZoom;
|
document.getElementById('tl-zoom-in')?.addEventListener('click', function() { tlZoom(0.75); });
|
||||||
window.tlResetView = tlResetView;
|
document.getElementById('tl-zoom-out')?.addEventListener('click', function() { tlZoom(1.25); });
|
||||||
|
document.getElementById('tl-reset-view')?.addEventListener('click', tlResetView);
|
||||||
|
|
||||||
// === Wheel zoom ===
|
// === Wheel zoom ===
|
||||||
let wheelRAF = null;
|
let wheelRAF = null;
|
||||||
@@ -413,9 +508,10 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (wheelRAF) return;
|
if (wheelRAF) return;
|
||||||
const mx = e.clientX;
|
const mx = e.clientX;
|
||||||
|
const dy = e.deltaY;
|
||||||
wheelRAF = requestAnimationFrame(function() {
|
wheelRAF = requestAnimationFrame(function() {
|
||||||
wheelRAF = null;
|
wheelRAF = null;
|
||||||
tlZoom(e.deltaY < 0 ? 0.85 : 1 / 0.85, mx);
|
tlZoom(dy < 0 ? 0.85 : 1 / 0.85, mx);
|
||||||
});
|
});
|
||||||
}, {passive: false});
|
}, {passive: false});
|
||||||
|
|
||||||
@@ -436,23 +532,32 @@
|
|||||||
// === Tooltip ===
|
// === Tooltip ===
|
||||||
let tip = null;
|
let tip = null;
|
||||||
function ensureTip() {
|
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) {
|
document.getElementById('tl-canvas').addEventListener('mouseover', function(e) {
|
||||||
const seg = e.target.closest('.tl-fl div');
|
const seg = e.target.closest('.tl-fl div');
|
||||||
const evt = e.target.closest('.tl-ev,.tl-sn');
|
const evt = e.target.closest('.tl-ev,.tl-sn');
|
||||||
|
const stateNode = e.target.closest('.tl-sn');
|
||||||
ensureTip();
|
ensureTip();
|
||||||
if (seg && seg.dataset.url) {
|
if (seg && seg.dataset.url) {
|
||||||
seg.style.filter = 'brightness(1.3)';
|
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.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';
|
tip.style.display = 'block';
|
||||||
} else if (evt) {
|
} 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.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';
|
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('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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user