Draw the bracket as a real tree with connectors; fit-to-screen and list views for phones

Matches are positioned per round with SVG connector lines between feeder and
next match, winners in bold, live matches tagged with their court, empty slots
labelled with what fills them (W R1.4, L R2.1), and the viewer's team highlighted
along its path. Double elimination renders winners, losers and finals trees.
Narrow screens default to a scaled fit-to-width view; a round-by-round list is
also available. Seed script gains --stage=bracket|double. Release 0.3.0.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi
This commit is contained in:
2026-09-03 21:41:35 +00:00
co-authored by Claude Fable 5.1
parent 3d241949f0
commit 62efa4d127
6 changed files with 160 additions and 18 deletions
+21 -9
View File
@@ -63,14 +63,26 @@ ul.plain,ol.plain{list-style:none;padding:0;margin:8px 0}ol.plain{counter-reset:
.matches li.live,.sched li.live{background:var(--accent-soft)}.won{font-weight:600}.matches li.mine{outline:1px solid var(--sand)}
.mine.card{border-color:var(--sand);border-width:2px}.big{font-size:20px}
/* bracket */
.bracketwrap{overflow-x:auto}.bstage{margin-top:12px}
.bracket{display:flex;gap:16px;align-items:stretch;min-width:max-content;padding-bottom:6px}
.round{display:flex;flex-direction:column;justify-content:space-around;gap:10px;min-width:190px}
.rlbl{font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.1em;font-size:12px;color:var(--muted);text-align:center}
.bm{border:1px solid var(--line);border-radius:6px;background:var(--bg);font-size:15px}
.bm>div{display:flex;justify-content:space-between;padding:5px 10px;gap:8px}.bm>div+div{border-top:1px solid var(--line)}
.bm .mlbl{font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.08em;font-size:11px;color:var(--muted);padding:3px 10px;justify-content:center}
.bm .mlbl.live{background:var(--good);color:#fff}.bm.live{border-color:var(--good)}.bm.mine{border-color:var(--sand);border-width:2px}
.bracketwrap .bhead{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px}
.bviews{display:flex;gap:4px}.bviews .cur{background:var(--accent);color:var(--accent-ink);border-color:var(--accent)}
.bstage{margin-top:14px}.bhint{margin:8px 0 0}
.btree-scroll{overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;padding-bottom:6px}
.btree{position:relative}.rlbls{position:absolute;top:0;left:0;right:0;height:22px}
.rlbl{position:absolute;font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.1em;font-size:12px;color:var(--muted);text-align:center;white-space:nowrap}
.bcanvas{position:absolute;left:0}.bcanvas svg{position:absolute;left:0;top:0;overflow:visible}
.bcanvas path{fill:none;stroke:var(--line);stroke-width:2}.bcanvas path.decided{stroke:var(--muted)}.bcanvas path.mine{stroke:var(--sand);stroke-width:3}
.bm{position:absolute;border:1px solid var(--line);border-radius:6px;background:var(--surface);font-size:14px;display:flex;flex-direction:column;justify-content:center;overflow:visible}
.bm.list{position:static;width:auto;height:auto;margin:8px 0}
.brow{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:4px 9px;line-height:1.25;min-height:50%}
.brow+.brow{border-top:1px solid var(--line)}
.brow .bn{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.brow .bs{flex:none;min-width:1.5em;text-align:right;color:var(--muted)}
.brow.won .bn{font-weight:600}.brow.won .bs{color:var(--ink);font-weight:600}.brow.mine{background:var(--sand-soft)}
.bm.live{border-color:var(--good);box-shadow:0 0 0 1px var(--good)}.bm.on_deck{border-style:dashed}.bm.mine{border-color:var(--sand);border-width:2px}
.btag{position:absolute;left:50%;transform:translateX(-50%);bottom:-9px;white-space:nowrap;font-family:"Barlow Condensed",sans-serif;text-transform:uppercase;letter-spacing:.08em;font-size:10px;padding:0 6px;border-radius:3px;background:var(--bg);border:1px solid var(--line);color:var(--muted);line-height:1.5}
.btag.live{background:var(--good);color:#fff;border-color:var(--good)}
.bm.list .btag{position:static;transform:none;display:inline-block;align-self:flex-start;margin:4px 9px 6px}
.blist{margin-top:10px}.blist .rlbl{position:static;text-align:left}
.bracketwrap.fit .btree-scroll{overflow:hidden}
.toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);background:#0F1418;color:#fff;padding:12px 18px;border-radius:8px;max-width:90vw;box-shadow:0 8px 30px rgba(0,0,0,.3);z-index:9;font-weight:600}
.loading{color:var(--muted);padding:24px}
/* display (TV) mode */
@@ -78,7 +90,7 @@ body.display{font-size:22px;background:#0F1418;color:#F2F5F7}
body.display .top,body.display .foot{display:none}body.display main{max-width:none;padding:20px 28px}
body.display .display-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}body.display h1{font-size:56px}
body.display .court .t{font-size:40px}body.display .court .s{font-size:52px}body.display .court{min-height:150px}
body.display .card{background:#1A222B;border-color:#2B3640;color:#F2F5F7}body.display td{border-color:#2B3640}body.display .bm{background:#12181F;border-color:#2B3640}
body.display .card{background:#1A222B;border-color:#2B3640;color:#F2F5F7}body.display td{border-color:#2B3640}body.display .bm{background:#12181F;border-color:#2B3640}body.display .brow+.brow{border-color:#2B3640}body.display .bcanvas path{stroke:#3A4652}body.display .btag{background:#1A222B;border-color:#2B3640}
body.display .queue{font-size:26px}body.display details{display:none}
/* admin */
.grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:12px}
+98 -7
View File
@@ -30,6 +30,7 @@
if (!s.pools.length && !s.bracket.length) parts.push(renderRoster());
if (s.withdrawn.length && !display) parts.push(`<p class="muted small">Withdrawn: ${s.withdrawn.map(t => esc(t.name)).join(', ')}</p>`);
app.innerHTML = parts.join('');
fitBracket();
notifyMine();
}
@@ -77,17 +78,107 @@
</div>`).join('')}</section>`;
}
// ---- bracket: a real tree with connector lines, plus a list view for small screens ----
// default: full tree on wide screens, scaled-to-fit on phones; the viewer's choice is remembered
let bracketView = window.innerWidth < 700 ? 'fit' : 'tree';
try { bracketView = localStorage.getItem('courtside.bracketView') || bracketView; } catch {}
const BOX_W = 176, BOX_H = 58, COL_GAP = 44, ROW_GAP = 14;
function renderBracket() {
const groups = [['bracket', 'Bracket'], ['losers', 'Losers bracket'], ['final', 'Finals']];
return `<section class="card bracketwrap"><h2>${state.bracket.some(m => m.stage === 'losers') ? 'Double elimination' : 'Bracket'}</h2>${groups.map(([stage, title]) => {
const ms = state.bracket.filter(m => m.stage === stage && !(m.conditional && m.status !== 'final' && !m.a));
const isDouble = state.bracket.some(m => m.stage === 'losers');
const toggle = display ? '' : `<div class="bviews" role="tablist">${[['tree', 'Bracket'], ['fit', 'Fit to screen'], ['list', 'List']].map(([v, l]) => `<button type="button" class="small ${bracketView === v ? 'cur' : ''}" data-bview="${v}">${l}</button>`).join('')}</div>`;
const stages = [['bracket', isDouble ? 'Winners bracket' : 'Bracket'], ['losers', 'Losers bracket'], ['final', 'Finals']];
const body = stages.map(([stage, title]) => {
const ms = state.bracket.filter(m => m.stage === stage && !(m.conditional && !m.a));
if (!ms.length) return '';
const rounds = [...new Set(ms.map(m => m.round))].sort((a, b) => a - b);
return `<div class="bstage"><h3>${title}</h3><div class="bracket">${rounds.map(r => `<div class="round"><div class="rlbl">${roundName(stage, r, rounds.length, ms)}</div>${ms.filter(m => m.round === r).map(m => `<div class="bm ${m.status} ${isMine(m) ? 'mine' : ''}">${m.label ? `<div class="mlbl">${esc(m.label)}</div>` : ''}<div class="${m.winner && m.winner === m.a?.id ? 'won' : ''}">${name(m.a)}<span class="mono">${m.sets[0] ? m.sets.map(s => s[0]).join(' ') : ''}</span></div><div class="${m.winner && m.winner === m.b?.id ? 'won' : ''}">${name(m.b)}<span class="mono">${m.sets[0] ? m.sets.map(s => s[1]).join(' ') : ''}</span></div>${m.status === 'live' ? `<div class="mlbl live">Court ${m.court}${m.live ? ` · ${m.live[0]}${m.live[1]}` : ''}</div>` : m.status === 'forfeit' ? '<div class="mlbl">forfeit</div>' : ''}</div>`).join('')}</div>`).join('')}</div></div>`;
}).join('')}</section>`;
return `<div class="bstage">${isDouble ? `<h3>${title}</h3>` : ''}${bracketView === 'list' ? bracketList(stage, ms) : bracketTree(stage, ms)}</div>`;
}).join('');
return `<section class="card bracketwrap ${bracketView === 'fit' ? 'fit' : ''}"><div class="bhead"><h2>${isDouble ? 'Double elimination' : 'Bracket'}</h2>${toggle}</div>${body}${bracketView === 'tree' && !display ? '<p class="muted small bhint">Swipe sideways to see later rounds.</p>' : ''}</section>`;
}
function bracketTree(stage, ms) {
const third = ms.find(m => m.label === '3rd place');
const tree = ms.filter(m => m !== third);
const rounds = [...new Set(tree.map(m => m.round))].sort((a, b) => a - b);
const pos = new Map();
const feedersOf = m => tree.filter(f => f.feeds === m.id);
rounds.forEach((r, ri) => {
const inRound = tree.filter(m => m.round === r).sort((a, b) => (a.slot ?? 0) - (b.slot ?? 0));
const x = ri * (BOX_W + COL_GAP);
let cursor = 0;
inRound.forEach(m => {
const fs = feedersOf(m).map(f => pos.get(f.id)).filter(Boolean);
let y;
if (fs.length) y = fs.reduce((a, p) => a + p.y, 0) / fs.length;
else y = cursor;
y = Math.max(y, cursor);
pos.set(m.id, { x, y });
cursor = y + BOX_H + ROW_GAP;
});
});
let height = Math.max(...[...pos.values()].map(p => p.y)) + BOX_H;
let width = rounds.length * (BOX_W + COL_GAP) - COL_GAP;
if (third) { const f = pos.get(tree.find(m => m.round === rounds.at(-1))?.id) ?? { x: 0, y: 0 }; pos.set(third.id, { x: f.x, y: height + ROW_GAP * 2 }); height += ROW_GAP * 2 + BOX_H; }
const paths = tree.filter(m => m.feeds && pos.has(m.feeds)).map(m => {
const a = pos.get(m.id), b = pos.get(m.feeds);
const x1 = a.x + BOX_W, y1 = a.y + BOX_H / 2, x2 = b.x, y2 = b.y + BOX_H / 2, xm = x1 + COL_GAP / 2;
const hot = m.winner && isMineId(m.winner);
return `<path d="M${x1} ${y1} H${xm} V${y2} H${x2}" class="${m.winner ? 'decided' : ''} ${hot ? 'mine' : ''}"/>`;
}).join('');
const boxes = [...pos.entries()].map(([id, p]) => matchBox(ms.find(m => m.id === id), p)).join('');
const labels = rounds.map((r, ri) => `<div class="rlbl" style="left:${ri * (BOX_W + COL_GAP)}px;width:${BOX_W}px">${roundName(stage, r, rounds.length, tree)}</div>`).join('');
return `<div class="btree-scroll"><div class="btree" style="width:${width}px;height:${height + 22}px"><div class="rlbls">${labels}</div><div class="bcanvas" style="top:22px"><svg width="${width}" height="${height}" aria-hidden="true">${paths}</svg>${boxes}</div></div></div>`;
}
function matchBox(m, p) {
const row = (t, side) => {
const won = m.winner && t && m.winner === t.id;
const pts = m.sets[0] ? m.sets.map(s => s[side]).join(' ') : (m.live && m.status === 'live' ? m.live[side] : '');
return `<div class="brow ${won ? 'won' : ''} ${t && isMineId(t.id) ? 'mine' : ''}"><span class="bn">${t ? esc(t.name) : `<span class="tbd">${feedLabel(m, side)}</span>`}</span><span class="bs mono">${pts}</span></div>`;
};
const tag = m.status === 'live' ? `<span class="btag live">Court ${m.court}</span>` : m.status === 'on_deck' ? `<span class="btag">On deck${m.court ? ` · C${m.court}` : ''}</span>` : m.status === 'forfeit' ? '<span class="btag">Forfeit</span>' : m.label ? `<span class="btag">${esc(m.label)}</span>` : '';
return `<div class="bm ${m.status} ${isMine(m) ? 'mine' : ''}" style="left:${p.x}px;top:${p.y}px;width:${BOX_W}px;height:${BOX_H}px">${row(m.a, 0)}${row(m.b, 1)}${tag}</div>`;
}
function feedLabel(m, side) {
// what fills this slot later: winner/loser of which match
const wantSide = side === 0 ? 'A' : 'B';
const w = state.bracket.find(f => f.feeds === m.id && f.feedsSide === wantSide);
if (w) return `W ${shortRef(w)}`;
const l = state.bracket.find(f => f.loserFeeds === m.id && f.loserFeedsSide === wantSide);
if (l) return `L ${shortRef(l)}`;
return 'TBD';
}
const shortRef = m => m.stage === 'losers' ? `LB${m.round}.${m.slot}` : m.stage === 'final' ? 'final' : `R${m.round}.${m.slot}`;
function bracketList(stage, ms) {
const rounds = [...new Set(ms.map(m => m.round))].sort((a, b) => a - b);
return rounds.map(r => `<div class="blist"><div class="rlbl">${roundName(stage, r, rounds.length, ms)}</div>${ms.filter(m => m.round === r).map(m => `<div class="bm list ${m.status} ${isMine(m) ? 'mine' : ''}">${['a', 'b'].map((k, i) => { const t = m[k]; const won = m.winner && t && m.winner === t.id; return `<div class="brow ${won ? 'won' : ''} ${t && isMineId(t.id) ? 'mine' : ''}"><span class="bn">${t ? esc(t.name) : `<span class="tbd">${feedLabel(m, i)}</span>`}</span><span class="bs mono">${m.sets[0] ? m.sets.map(s => s[i]).join(' ') : (m.live && m.status === 'live' ? m.live[i] : '')}</span></div>`; }).join('')}${m.status === 'live' ? `<span class="btag live">Court ${m.court}</span>` : m.status === 'on_deck' ? `<span class="btag">On deck${m.court ? ` · C${m.court}` : ''}</span>` : m.status === 'forfeit' ? '<span class="btag">Forfeit</span>' : m.label ? `<span class="btag">${esc(m.label)}</span>` : ''}</div>`).join('')}</div>`).join('');
}
const isMineId = id => myTeam && id === myTeam;
function fitBracket() {
if (bracketView !== 'fit') return;
document.querySelectorAll('.btree-scroll').forEach(sc => {
const tree = sc.firstElementChild;
const w = parseFloat(tree.style.width), h = parseFloat(tree.style.height);
const scale = Math.min(1, (sc.clientWidth - 2) / w);
tree.style.transform = `scale(${scale})`;
tree.style.transformOrigin = '0 0';
sc.style.height = `${h * scale}px`;
});
}
document.addEventListener('click', e => {
const b = e.target.closest('[data-bview]');
if (!b) return;
bracketView = b.dataset.bview;
try { localStorage.setItem('courtside.bracketView', bracketView); } catch {}
render();
});
window.addEventListener('resize', fitBracket);
function roundName(stage, r, total, ms) {
if (stage === 'final') return r === 1 ? 'Grand final' : 'Reset';
if (stage === 'final') return r === 1 ? 'Grand final' : 'Bracket reset';
if (stage === 'losers') return `LB round ${r}`;
const left = total - r;
const inRound = ms.filter(m => m.round === r && m.label !== '3rd place').length;