From 728fff185a0d65b6ab2f619c78cc667f9d9f5021 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Wed, 18 Feb 2026 23:23:08 -0700 Subject: [PATCH] Center block titles between open/close events with compacted title rows --- cmd/qrunweb/static/index.html | 122 +++++++++++++++++++++++++++------- 1 file changed, 99 insertions(+), 23 deletions(-) diff --git a/cmd/qrunweb/static/index.html b/cmd/qrunweb/static/index.html index f944949..f91a579 100644 --- a/cmd/qrunweb/static/index.html +++ b/cmd/qrunweb/static/index.html @@ -192,8 +192,7 @@ function render(data) { const active = new Map(); const pendingEnds = new Set(); - const pendingTitles = new Set(); - const titled = new Set(); + const noTitleAfter = new Set(); const rows = []; function mid(tid) { @@ -205,23 +204,7 @@ function render(data) { rows.push({ cells, rowClass: rowClass || '' }); } - function wantTitle(bid) { - if (!titled.has(bid)) pendingTitles.add(bid); - } - - function emitTitles() { - if (pendingTitles.size === 0) return; - const cells = trackIds.map(tid => { - const t = [...pendingTitles].find(bid => blockTrack(bid) === tid); - return t ? { blockId: t, segment: 'mid', title: blockMap.get(t).name } : mid(tid); - }); - addRow(cells); - pendingTitles.forEach(bid => titled.add(bid)); - pendingTitles.clear(); - } - function flush() { - emitTitles(); if (pendingEnds.size === 0) return; const toEnd = [...pendingEnds].filter(bid => !triggerSourceSet.has(ref(bid, 'END'))); @@ -250,7 +233,6 @@ function render(data) { const isHook = !isCue; if (hook === 'START') { active.set(tid, bid); - wantTitle(bid); return { blockId: bid, segment: 'start', event: 'START', isHook }; } if (hook === 'END') return { blockId: bid, segment: 'end', event: 'END', isHook, directEnd: true }; @@ -280,13 +262,13 @@ function render(data) { active.delete(tid); } active.set(tid, tgt); - wantTitle(tgt); const targetMap = new Map(); const extras = startSignalMap.get(tgt); if (extras) extras.forEach(et => targetMap.set(et.block, et.hook)); expandTargets(targetMap); const hasTargets = targetMap.size > 0; const directEnds = []; + noTitleAfter.add(rows.length - 1); const startCells = trackIds.map(t => { if (t === tid) return { blockId: tgt, segment: 'start', event: 'START', isSignal: hasTargets }; const cell = applyTargets(targetMap, t, false); @@ -299,7 +281,6 @@ function render(data) { } flush(); - emitTitles(); const rowClass = isCue ? 'cue-row' : 'sig-row'; @@ -339,7 +320,6 @@ function render(data) { data.triggers.forEach(t => processTrigger(t)); flush(); - emitTitles(); const stillActive = []; for (const [tid, bid] of active) { @@ -353,6 +333,102 @@ function render(data) { addRow(cells); } + const blockOpenEnd = new Map(); + const blockCloseStart = new Map(); + const blockRange = new Map(); + rows.forEach((row, idx) => { + row.cells.forEach(cell => { + if (!cell.blockId) return; + const bid = cell.blockId; + if (!blockRange.has(bid)) blockRange.set(bid, { first: idx, last: idx }); + else blockRange.get(bid).last = idx; + if (cell.event === 'START') blockOpenEnd.set(bid, Math.max(blockOpenEnd.get(bid) || -1, idx)); + if (cell.event === 'FADE_OUT' || cell.event === 'END') blockCloseStart.set(bid, Math.min(blockCloseStart.get(bid) || Infinity, idx)); + }); + }); + + const titlesToPlace = []; + for (const [bid, range] of blockRange) { + if (blockMap.get(bid).type === 'cue') continue; + const openEnd = blockOpenEnd.get(bid) || range.first; + const closeStart = blockCloseStart.has(bid) ? blockCloseStart.get(bid) : Infinity; + let afterRow; + if (closeStart === Infinity) { + afterRow = openEnd; + } else if (openEnd + 1 <= closeStart - 1) { + afterRow = Math.floor((openEnd + closeStart - 1) / 2); + } else { + afterRow = openEnd; + } + titlesToPlace.push({ bid, track: blockTrack(bid), afterRow, validFrom: openEnd, validTo: closeStart === Infinity ? range.last : closeStart - 1 }); + } + titlesToPlace.sort((a, b) => a.afterRow - b.afterRow); + + const titleGroups = []; + titlesToPlace.forEach(t => { + let best = -1; + let bestDist = Infinity; + for (let i = 0; i < titleGroups.length; i++) { + const group = titleGroups[i]; + const iFrom = Math.max(group.validFrom, t.validFrom); + const iTo = Math.min(group.validTo, t.validTo); + if (iFrom > iTo) continue; + if (group.titles.some(gt => gt.track === t.track)) continue; + let candidate = group.afterRow; + if (candidate < iFrom || candidate > iTo) candidate = Math.floor((iFrom + iTo) / 2); + if (noTitleAfter.has(candidate)) { + let found = false; + for (let r = candidate + 1; r <= iTo; r++) { + if (!noTitleAfter.has(r)) { candidate = r; found = true; break; } + } + if (!found) continue; + } + const dist = Math.abs(candidate - t.afterRow); + if (dist < bestDist) { best = i; bestDist = dist; } + } + if (best >= 0) { + const group = titleGroups[best]; + group.validFrom = Math.max(group.validFrom, t.validFrom); + group.validTo = Math.min(group.validTo, t.validTo); + if (group.afterRow < group.validFrom || group.afterRow > group.validTo) { + group.afterRow = Math.floor((group.validFrom + group.validTo) / 2); + } + if (noTitleAfter.has(group.afterRow)) { + for (let r = group.afterRow + 1; r <= group.validTo; r++) { + if (!noTitleAfter.has(r)) { group.afterRow = r; break; } + } + } + group.titles.push({ bid: t.bid, track: t.track }); + } else { + let pos = t.afterRow; + if (noTitleAfter.has(pos)) { + for (let r = pos + 1; r <= t.validTo; r++) { + if (!noTitleAfter.has(r)) { pos = r; break; } + } + } + titleGroups.push({ afterRow: pos, validFrom: t.validFrom, validTo: t.validTo, titles: [{ bid: t.bid, track: t.track }] }); + } + }); + titleGroups.sort((a, b) => a.afterRow - b.afterRow); + + const finalRows = []; + let tgIdx = 0; + rows.forEach((row, idx) => { + finalRows.push(row); + while (tgIdx < titleGroups.length && titleGroups[tgIdx].afterRow === idx) { + const group = titleGroups[tgIdx]; + const titleCells = trackIds.map((tid, colIdx) => { + const t = group.titles.find(gt => gt.track === tid); + if (t) return { blockId: t.bid, segment: 'mid', title: blockMap.get(t.bid).name }; + const prev = row.cells[colIdx]; + if (prev.blockId && prev.segment !== 'end') return { blockId: prev.blockId, segment: 'mid' }; + return { empty: true }; + }); + finalRows.push({ cells: titleCells, rowClass: '' }); + tgIdx++; + } + }); + const timeline = document.getElementById('timeline'); const trackNames = { [CUE_TRACK]: 'Cue' }; data.tracks.forEach(t => { trackNames[t.id] = t.name; }); @@ -365,7 +441,7 @@ function render(data) { timeline.appendChild(th); }); - rows.forEach(row => { + finalRows.forEach(row => { row.cells.forEach(cell => { const div = document.createElement('div'); let cls = 'cell';