diff --git a/assets/css/bracket.css b/assets/css/bracket.css index ab8ab4b..ee30c6e 100644 --- a/assets/css/bracket.css +++ b/assets/css/bracket.css @@ -1,8 +1,11 @@ -/* bracket.css — knockout bracket: horizontal round columns, CSS connectors, - match nodes, champion box. Geometry note: every column has the same height - and slots are flex:1, so a pair's children sit at 25% / 75% of the pair and - the next round's node at its 50% — connector lines meet exactly. - Interactions (hover path, zoom, drag) land in step 8. */ +/* bracket.css — knockout wallchart: center-out mirrored layout, absolute- + positioned cards + generated SVG connectors (geometry lives in bracket.js + computeWallchartLayout(); cards and lines share the same numbers). + Art direction: "stadium at night" — spotlight gradients on the wrap, faint + pitch geometry behind the chart, rounds heating up toward the Final. + All motion is gated behind prefers-reduced-motion. */ + +/* ------------------------------------------------------------ toolbar */ .bracket-toolbar { display: flex; @@ -15,6 +18,7 @@ .bracket-tools-left, .bracket-tools-right { display: flex; + flex-wrap: wrap; /* six controls on the left — must wrap on narrow screens */ gap: 0.4rem; } @@ -62,12 +66,58 @@ color: var(--text-secondary); } +/* view switch — Rounds | Wallchart | Radial segmented control */ +.bk-viewswitch { + display: flex; + overflow: hidden; + border: 1px solid var(--glass-border); + border-radius: var(--radius-sm); + background: rgba(8, 20, 33, 0.6); +} + +.view-btn { + height: 34px; + padding-inline: 0.7rem; + font-size: 0.78rem; + font-weight: 600; + color: var(--text-secondary); + border-right: 1px solid var(--glass-border); + transition: color 0.2s, background-color 0.2s; +} + +.view-btn:last-child { + border-right: 0; +} + +.view-btn:hover { + color: var(--text-primary); +} + +.view-btn.active { + background: rgba(212, 175, 55, 0.14); + color: var(--accent-gold); +} + +/* ------------------------------------------------- stage (wrap + zoom) */ + +/* height follows the chart's aspect ratio (inline style from the layout + engine) so the fit view is never letterboxed; viewport-capped for safety */ .bracket-wrap { + display: flex; + width: 100%; /* definite width — keeps min-height from transferring through the aspect-ratio into overflow */ + min-height: 300px; + max-height: min(80vh, 840px); overflow: auto; - max-height: min(78vh, 900px); border: 1px solid var(--glass-border); border-radius: var(--radius); - background: rgba(8, 20, 33, 0.45); + /* stadium night: two spotlight pools from the top corners over a deep + navy floor — backgrounds don't scroll with overflow content, so the + light stays put while the chart pans underneath */ + background: + radial-gradient(55% 60% at 16% -8%, rgba(30, 136, 229, 0.13), transparent 65%), + radial-gradient(55% 60% at 84% -8%, rgba(212, 175, 55, 0.10), transparent 65%), + radial-gradient(90% 60% at 50% 115%, rgba(30, 136, 229, 0.06), transparent 70%), + rgba(5, 13, 23, 0.82); -webkit-overflow-scrolling: touch; cursor: grab; touch-action: none; /* we implement pan + pinch ourselves via pointer events */ @@ -78,113 +128,105 @@ user-select: none; } +/* margins collapse to 0 when the scaled chart overflows, center it when not */ .bracket-zoom { position: relative; - width: max-content; + flex: none; + margin: auto; } .bracket { + position: relative; transform-origin: 0 0; + /* soft gold halo pooled around the centerpiece (percent-based → scales) */ + background: radial-gradient(34% 42% at 50% 48%, rgba(212, 175, 55, 0.06), transparent 70%); } -.bracket { - --node-w: 200px; - --line: rgba(255, 255, 255, 0.18); - display: flex; - gap: 44px; - width: max-content; - min-height: 1150px; - padding: 1.25rem 1.5rem 1.5rem; +/* every laid-out element inside a chart canvas is absolutely positioned by + the engine (scoped: the pager reuses these components in normal flow) */ +.bracket .bracket-match, +.bracket .bk-champion, +.bracket .bk-title, +.bracket .bk-third-title, +.bracket .bk-token, +.bracket .bk-center, +.bracket .bk-third-group { + position: absolute; } -.bracket-round { - display: flex; - flex-direction: column; - width: var(--node-w); +/* --------------------------------------------------- scenery + links */ + +.bk-scenery, +.bk-links { + position: absolute; + inset: 0; + pointer-events: none; } -.bracket-round-title { - margin: 0 0 0.75rem; +.bk-scenery line, +.bk-scenery circle, +.bk-scenery ellipse { + fill: none; + stroke: rgba(255, 255, 255, 0.05); + stroke-width: 1.5; +} + +.bk-scenery .bk-scenery-dot { + fill: rgba(255, 255, 255, 0.07); + stroke: none; +} + +.bk-links path { + fill: none; + stroke-width: 2; + stroke-linecap: round; + transition: opacity 0.25s ease, stroke 0.25s ease; +} + +/* connectors heat up round by round toward the center */ +.bk-links .bk-l-r16 { stroke: rgba(255, 255, 255, 0.15); } +.bk-links .bk-l-qf { stroke: rgba(255, 255, 255, 0.20); } +.bk-links .bk-l-sf { stroke: rgba(212, 175, 55, 0.28); } +.bk-links .bk-l-final { stroke: rgba(212, 175, 55, 0.45); } + +.bk-links .bk-stem { + stroke: rgba(212, 175, 55, 0.55); + stroke-width: 2.5; +} + +/* ------------------------------------------------------- round titles */ + +.bk-title { text-align: center; - font-size: 0.78rem; + font-size: 0.72rem; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.08em; + letter-spacing: 0.09em; + color: var(--text-secondary); +} + +.bk-title-qf { color: rgba(240, 207, 106, 0.75); } +.bk-title-sf { color: var(--accent-gold-soft); } + +.bk-title-final { color: var(--accent-gold); + font-size: 0.82rem; + letter-spacing: 0.14em; + text-shadow: 0 0 14px rgba(212, 175, 55, 0.45); } -.bracket-matches { - flex: 1; - display: flex; - flex-direction: column; -} - -.bracket-pair { - flex: 1; - display: flex; - flex-direction: column; - position: relative; -} - -.bracket-slot { - flex: 1; - display: flex; - align-items: center; - position: relative; -} - -/* ----------------------------------------------------------- connectors */ - -/* out of a node toward the next column (half the 44px gap) */ -.bracket-round:not(.bracket-final-col) .bracket-slot::after { - content: ""; - position: absolute; - top: 50%; - right: -22px; - width: 22px; - height: 2px; - background: var(--line); -} - -/* vertical join between a pair's two out-stubs */ -.bracket-pair::after { - content: ""; - position: absolute; - top: 25%; - right: -22px; - width: 2px; - height: 50%; - background: var(--line); -} - -/* into a node from the previous column */ -.bracket-round:not(:first-child) .bracket-slot::before { - content: ""; - position: absolute; - top: 50%; - left: -22px; - width: 22px; - height: 2px; - background: var(--line); -} - -.bracket-champion-slot::before, -.bracket-third-slot::before { - display: none; -} - -/* --------------------------------------------------------- match nodes */ +/* --------------------------------------------------------- match cards */ .bracket-match { - position: relative; z-index: 1; - width: 100%; + display: flex; + flex-direction: column; overflow: hidden; - background: rgba(16, 36, 59, 0.85); - border: 1px solid var(--glass-border); + background: rgba(13, 29, 48, 0.88); + border: 1px solid rgba(255, 255, 255, 0.10); border-radius: var(--radius-sm); cursor: pointer; - transition: border-color 0.2s, box-shadow 0.2s; + transition: border-color 0.2s, box-shadow 0.2s, opacity 0.25s ease; } .bracket-match:hover { @@ -196,18 +238,59 @@ border-color: rgba(255, 77, 90, 0.6); } +/* heat: cards get warmer as the rounds close in on the Final */ +.bracket-match.bk-r16 { border-color: rgba(255, 255, 255, 0.14); } +.bracket-match.bk-qf { border-color: rgba(212, 175, 55, 0.18); } + +.bracket-match.bk-sf, +.bracket-match.bk-third { + border-color: rgba(212, 175, 55, 0.30); + background: rgba(18, 34, 52, 0.9); +} + +.bracket-match.bk-final { + border-color: rgba(212, 175, 55, 0.55); + background: linear-gradient(170deg, rgba(30, 48, 70, 0.95), rgba(16, 32, 50, 0.95) 55%, rgba(38, 32, 16, 0.9)); + box-shadow: 0 0 26px rgba(212, 175, 55, 0.16); +} + +.bk-teams { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; +} + .bracket-team { + flex: 1; display: flex; align-items: center; gap: 0.45rem; - padding: 0.42rem 0.6rem; - font-size: 0.78rem; + padding: 0.2rem 0.6rem; + font-size: 0.74rem; } .bracket-team + .bracket-team { border-top: 1px solid rgba(255, 255, 255, 0.07); } +.bk-qf .bracket-team, +.bk-sf .bracket-team, +.bk-third .bracket-team { + font-size: 0.78rem; +} + +.bk-final .bracket-team { + font-size: 0.92rem; + gap: 0.55rem; + padding-inline: 0.9rem; +} + +.bk-final .bracket-team .flag { + width: 27px; + height: 18px; +} + .bracket-team .flag { flex: none; } @@ -220,6 +303,11 @@ background: rgba(255, 255, 255, 0.08); } +.bk-final .bt-flag-ph { + width: 27px; + height: 18px; +} + .bt-name { flex: 1; min-width: 0; @@ -253,83 +341,121 @@ color: var(--text-secondary); } -/* --------------------------------------------------- final column extras */ +/* microline: kickoff / LIVE / FT (+ venue on the Final hero card) */ +.bk-meta { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + flex: none; + padding: 0.18rem 0.5rem 0.26rem; + border-top: 1px solid rgba(255, 255, 255, 0.06); + font-size: 0.62rem; + letter-spacing: 0.04em; + color: var(--text-secondary); + white-space: nowrap; +} -.champion-box { +.bk-final .bk-meta { + flex-direction: column; + gap: 0.1rem; + padding-block: 0.3rem 0.4rem; + font-size: 0.66rem; +} + +.bk-venue { + overflow: hidden; + max-width: 100%; + text-overflow: ellipsis; + color: rgba(240, 207, 106, 0.8); +} + +.bk-meta-ft { + font-weight: 700; + letter-spacing: 0.08em; + color: var(--text-secondary); +} + +.bk-meta-live { + display: flex; + align-items: center; + gap: 0.3rem; + font-weight: 700; + letter-spacing: 0.08em; + color: var(--live); +} + +.bk-meta-live::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--live); +} + +/* --------------------------------------------------- champion + third */ + +.bk-champion { + z-index: 1; display: flex; flex-direction: column; align-items: center; - gap: 0.25rem; - width: 100%; - padding: 0.9rem 0.6rem; + justify-content: center; + gap: 0.2rem; + overflow: hidden; + padding: 0.5rem; text-align: center; + background: rgba(13, 29, 48, 0.75); border: 1px dashed rgba(212, 175, 55, 0.5); border-radius: var(--radius-sm); } -.champion-box.has-champion { +.bk-champion.has-champion { border-style: solid; - box-shadow: 0 0 18px rgba(212, 175, 55, 0.25); + background: linear-gradient(180deg, rgba(60, 48, 18, 0.55), rgba(13, 29, 48, 0.9)); + box-shadow: 0 0 26px rgba(212, 175, 55, 0.3); +} + +/* a simulated champion is a dream, not a result — sim-blue, never gold glow */ +.bk-champion.is-sim { + border-color: rgba(30, 136, 229, 0.65); + background: linear-gradient(180deg, rgba(15, 45, 78, 0.6), rgba(13, 29, 48, 0.9)); + box-shadow: 0 0 20px rgba(30, 136, 229, 0.25); +} + +.bk-champion.is-sim .champion-name { + color: var(--accent-blue); } .champion-trophy { - font-size: 1.4rem; + font-size: 1.5rem; } .champion-label { - font-size: 0.68rem; + font-size: 0.66rem; text-transform: uppercase; - letter-spacing: 0.08em; + letter-spacing: 0.12em; color: var(--text-secondary); } .champion-name { + display: flex; + align-items: center; + gap: 0.45rem; + font-size: 0.95rem; font-weight: 700; color: var(--accent-gold); } -.third-place-block { - width: 100%; -} - -.third-place-block h4 { - margin: 0 0 0.4rem; +.bk-third-title { text-align: center; - font-size: 0.68rem; + font-size: 0.66rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-secondary); } -/* mobile: narrower nodes and gap — connector stubs must stay at gap/2 */ -@media (max-width: 767px) { - .bracket { - --node-w: 168px; - gap: 36px; - min-height: 1000px; - padding: 1rem 1rem 1.25rem; - } - - .bracket-round:not(.bracket-final-col) .bracket-slot::after { - right: -18px; - width: 18px; - } - - .bracket-pair::after { - right: -18px; - } - - .bracket-round:not(:first-child) .bracket-slot::before { - left: -18px; - width: 18px; - } - - .bracket-wrap { - max-height: 72vh; - } -} - /* ----------------------------------------------------- challenge card */ .challenge-card { @@ -457,27 +583,480 @@ gap: 0.75rem; } -/* -------------------------------------------------- path highlight */ +/* ------------------------------------------------------ rounds pager */ -.bracket-match, -.bracket-slot::before, -.bracket-slot::after, -.bracket-pair::after { - transition: opacity 0.25s ease, background-color 0.25s ease, - border-color 0.25s ease, box-shadow 0.25s ease; +.bk-pager-nav { + display: flex; + gap: 0.4rem; + overflow-x: auto; + padding-bottom: 0.45rem; + margin-bottom: 0.5rem; } -.bracket.has-path .bracket-match:not(.path-on) { +.bk-page-btn { + flex: none; + padding: 0.32rem 0.85rem; + border: 1px solid var(--glass-border); + border-radius: 999px; + background: rgba(8, 20, 33, 0.6); + font-size: 0.76rem; + font-weight: 600; + color: var(--text-secondary); + white-space: nowrap; + transition: color 0.2s, border-color 0.2s, background-color 0.2s; +} + +.bk-page-btn:hover { + color: var(--text-primary); +} + +.bk-page-btn.active { + border-color: rgba(212, 175, 55, 0.55); + background: rgba(212, 175, 55, 0.12); + color: var(--accent-gold); +} + +/* pages switch via the chips only — one visible at a time, no side scroll */ +.bk-page { + padding: 0.2rem 0.15rem; +} + +.bk-page-list { + display: grid; + gap: 0.65rem; + grid-template-columns: 1fr; +} + +/* two wide columns max — cards stay comfortably readable on desktop */ +@media (min-width: 700px) { + .bk-page-list { + grid-template-columns: repeat(2, 1fr); + } +} + +/* Finals page: champion banner + final + third, a single centered column */ +.bk-page-finals { + grid-template-columns: 1fr; + max-width: 560px; + margin-inline: auto; +} + +.bk-page .bk-champion.bk-flow { + padding: 1.1rem; +} + +.bk-page-sub { + margin: 0.4rem 0 0; + text-align: center; + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-secondary); +} + +/* pager card — the fuller variant (venue + city), touch-friendly */ +.bk-pcard { + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; + background: rgba(13, 29, 48, 0.88); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: var(--radius-sm); + cursor: pointer; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.bk-pcard:hover { + border-color: rgba(212, 175, 55, 0.55); + box-shadow: 0 0 14px rgba(212, 175, 55, 0.18); +} + +.bk-pcard.is-live { + border-color: rgba(255, 77, 90, 0.6); +} + +.bk-pcard.has-fav { + border-left: 3px solid var(--accent-gold); +} + +.sim-on .bk-pcard.simulatable { + border-style: dashed; + border-color: rgba(30, 136, 229, 0.65); +} + +.sim-on .bk-pcard.simulatable:hover { + border-color: var(--accent-blue); + box-shadow: 0 0 14px rgba(30, 136, 229, 0.3); +} + +.bk-pcard .bracket-team { + flex: none; + padding: 0.5rem 0.85rem; + font-size: 0.86rem; +} + +.bk-pcard-hero { + border-color: rgba(212, 175, 55, 0.55); + background: linear-gradient(170deg, rgba(30, 48, 70, 0.95), rgba(16, 32, 50, 0.95) 55%, rgba(38, 32, 16, 0.9)); + box-shadow: 0 0 26px rgba(212, 175, 55, 0.16); +} + +.bk-pcard-hero .bracket-team { + font-size: 0.95rem; +} + +.bk-pcard-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.6rem; + padding: 0.4rem 0.85rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + font-size: 0.66rem; + letter-spacing: 0.03em; + color: var(--text-secondary); + white-space: nowrap; +} + +.bk-pcard-venue { + overflow: hidden; + text-overflow: ellipsis; +} + +/* ------------------------------------------------ radial (orbit) view */ + +.bk-token { + z-index: 1; + border-radius: 50%; + background: rgba(19, 36, 56, 0.95); + border: 2px solid rgba(255, 255, 255, 0.18); + cursor: pointer; + transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s, opacity 0.25s ease; +} + +.bk-token img, +.bk-center img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; +} + +.bk-token:hover { + z-index: 3; + border-color: var(--accent-gold); + transform: scale(1.14); +} + +/* TBD slot — striped disc, like an unlit stadium seat block */ +.bk-token.tk-tbd { + background: repeating-linear-gradient(45deg, + rgba(255, 255, 255, 0.10) 0 4px, rgba(255, 255, 255, 0.03) 4px 9px); + border-color: rgba(255, 255, 255, 0.12); +} + +/* eliminated entrant — greyed out, still consultable */ +.bk-token.tk-out { + opacity: 0.45; +} + +.bk-token.tk-out img { + filter: grayscale(1); +} + +/* filled winner slot: gold = real result, dashed blue = simulated pick */ +.bk-token.tk-winner { + border-color: var(--accent-gold); + box-shadow: 0 0 12px rgba(212, 175, 55, 0.35); +} + +.bk-token.tk-sim { + border-style: dashed; + border-color: var(--accent-blue); + box-shadow: 0 0 12px rgba(30, 136, 229, 0.3); +} + +.bk-token.tk-live { + border-color: var(--live); + box-shadow: 0 0 10px rgba(255, 77, 90, 0.4); +} + +/* favorite team — small gold dot pinned to the token's rim */ +.bk-token.tk-fav::after { + content: ""; + position: absolute; + top: -3px; + right: -3px; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--accent-gold); + border: 2px solid var(--bg-primary); +} + +.sim-on .bk-token.simulatable, +.sim-on .bk-center.simulatable { + border-style: dashed; + border-color: rgba(30, 136, 229, 0.8); +} + +/* trophy centerpiece — also the Final's winner slot */ +.bk-center { + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: rgba(19, 36, 56, 0.9); + border: 2px dashed rgba(212, 175, 55, 0.5); + cursor: pointer; + transition: border-color 0.2s, box-shadow 0.2s, opacity 0.25s ease; +} + +.bk-center .bk-center-trophy { + font-size: 2.3rem; + line-height: 1; + filter: drop-shadow(0 0 12px rgba(212, 175, 55, 0.65)); +} + +.bk-center:hover { + border-color: var(--accent-gold); + box-shadow: 0 0 18px rgba(212, 175, 55, 0.35); +} + +.bk-center.has-champion { + border-style: solid; + border-color: var(--accent-gold); + box-shadow: 0 0 26px rgba(212, 175, 55, 0.45); +} + +/* champion known: flag fills the circle, trophy floats above, name below */ +.bk-center.has-champion .bk-center-trophy { + position: absolute; + top: -34px; + left: 50%; + transform: translateX(-50%); + font-size: 1.5rem; +} + +.bk-center-name { + position: absolute; + top: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + width: max-content; + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--accent-gold); +} + +.bk-center.is-sim { + border-color: var(--accent-blue); + box-shadow: 0 0 20px rgba(30, 136, 229, 0.35); +} + +.bk-center.is-sim .bk-center-name { + color: var(--accent-blue); +} + +/* third place — small labeled pair below the circle */ +.bk-third-group { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.45rem; + cursor: pointer; + transition: opacity 0.25s ease; +} + +.bk-third-group .bk-token, +.bracket .bk-third-group .bk-third-title { + position: static; + display: inline-block; +} + +.bk-third-tokens { + display: flex; + gap: 1.1rem; +} + +/* orbit scenery + route lines */ +.bk-orbit { + fill: none; + stroke: rgba(255, 255, 255, 0.045); + stroke-width: 1.5; +} + +.bk-orbit-accent { + fill: none; + stroke: rgba(212, 175, 55, 0.20); + stroke-width: 1.5; + stroke-dasharray: 3 8; +} + +.bk-links .bk-le { + fill: none; + stroke: rgba(255, 255, 255, 0.13); + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +.bk-links .bk-le.is-adv { + stroke: rgba(212, 175, 55, 0.65); + stroke-width: 1.8; +} + +.bk-links .bk-le.is-sim { + stroke: rgba(30, 136, 229, 0.75); + stroke-width: 1.8; + stroke-dasharray: 4 4; +} + +.bk-dot { + fill: rgba(255, 255, 255, 0.25); +} + +.bk-dot.is-adv { + fill: rgba(212, 175, 55, 0.85); +} + +.bk-dot.is-sim { + fill: rgba(30, 136, 229, 0.85); +} + +/* -------------------------------------------------- path highlight */ + +.bracket.has-path .bracket-match:not(.path-on), +.bracket.has-path .bk-champion:not(.path-on), +.bracket.has-path .bk-token:not(.path-on), +.bracket.has-path .bk-center:not(.path-on), +.bracket.has-path .bk-third-group:not(.path-on) { opacity: 0.3; } +.bk-token.path-on, +.bk-center.path-on { + border-color: var(--accent-gold); + box-shadow: 0 0 14px rgba(212, 175, 55, 0.4); +} + +.bracket.has-path .bk-links path:not(.path-on) { + opacity: 0.2; +} + .bracket-match.path-on { border-color: rgba(212, 175, 55, 0.75); box-shadow: 0 0 16px rgba(212, 175, 55, 0.28); } -.bracket-slot.path-on::before, -.bracket-slot.path-on::after, -.bracket-pair.path-on::after { - background: var(--accent-gold); +.bk-links path.path-on { + stroke: var(--accent-gold); + stroke-width: 2.5; + filter: drop-shadow(0 0 3px rgba(212, 175, 55, 0.8)); +} + +/* ---------------------------------------------------------- motion */ + +@media (prefers-reduced-motion: no-preference) { + /* cards rise in, connectors draw themselves — staggered by round so the + chart assembles outside-in each time the tab is opened */ + .bracket-match, + .bk-champion { + animation: bk-card-in 0.45s ease-out backwards; + } + + .bracket-match.bk-r32 { animation-delay: 0.05s; } + .bracket-match.bk-r16 { animation-delay: 0.25s; } + .bracket-match.bk-qf { animation-delay: 0.45s; } + .bracket-match.bk-sf { animation-delay: 0.65s; } + + .bracket-match.bk-final, + .bracket-match.bk-third, + .bk-champion { + animation-delay: 0.85s; + } + + .wallchart .bk-links path { + stroke-dasharray: 1; /* paths carry pathLength="1" */ + animation: bk-draw 0.5s ease-out backwards; + } + + .bk-links .bk-l-r16 { animation-delay: 0.3s; } + .bk-links .bk-l-qf { animation-delay: 0.5s; } + .bk-links .bk-l-sf { animation-delay: 0.7s; } + .bk-links .bk-l-final { animation-delay: 0.9s; } + .bk-links .bk-stem { animation-delay: 1.05s; } + + .bk-meta-live::before { + animation: bk-live-pulse 1.4s ease-in-out infinite; + } + + .bk-pcard { + animation: bk-card-in 0.35s ease-out backwards; + } + + /* orbit view: tokens pop in ring by ring, route lines draw themselves + (sim lines keep their dash pattern, so they skip the draw effect) */ + .bk-token, + .bk-center { + animation: bk-card-in 0.4s ease-out backwards; + } + + .bk-token.tk-ent { animation-delay: 0.05s; } + .bk-token.tk-r32 { animation-delay: 0.3s; } + .bk-token.tk-r16 { animation-delay: 0.5s; } + .bk-token.tk-qf { animation-delay: 0.65s; } + .bk-token.tk-sf { animation-delay: 0.8s; } + .bk-center { animation-delay: 0.9s; } + + .bk-links .bk-le:not(.is-sim) { + stroke-dasharray: 1; + animation: bk-draw 0.6s ease-out 0.25s backwards; + } + + .bk-token.tk-live { + animation: bk-live-ring 1.4s ease-in-out infinite; + } +} + +@keyframes bk-live-ring { + 50% { + box-shadow: 0 0 14px rgba(255, 77, 90, 0.55); + } +} + +@keyframes bk-card-in { + from { + opacity: 0; + transform: translateY(10px); + } +} + +@keyframes bk-draw { + from { + stroke-dashoffset: 1; + } + to { + stroke-dashoffset: 0; + } +} + +@keyframes bk-live-pulse { + 50% { + opacity: 0.35; + } +} + +/* ------------------------------------------------------------ mobile */ + +@media (max-width: 767px) { + /* interim until the mobile round pager lands — pinch-zoom canvas */ + .bracket-wrap { + min-height: 220px; + max-height: 70vh; + } } diff --git a/assets/js/bracket.js b/assets/js/bracket.js index be80afd..088eb26 100644 --- a/assets/js/bracket.js +++ b/assets/js/bracket.js @@ -5,10 +5,10 @@ // third-place match. Unresolvable slots render as placeholders, unplayed // matches as TBD. Interactions land in step 8, simulation in step 9. -import { getData, flagSrc } from './app.js'; +import { getData, flagSrc, matchDateUTC } from './app.js'; import { get as storageGet, set as storageSet, getFavorites } from './storage.js'; import { computeStandings, isGroupFinished } from './groups.js'; -import { t, translatePhase } from './i18n.js'; +import { t, translatePhase, getLocale } from './i18n.js'; import { openMatchModal } from './modal.js'; const ROUNDS = [ @@ -269,6 +269,11 @@ export function initBracket() { document.addEventListener('langchange', render); document.addEventListener('favchange', render); document.addEventListener('datachange', render); // tree already invalidated by the poll → rebuilds + document.addEventListener('timemodechange', render); // kickoff microlines follow the Local/Stadium toggle + // no explicit view choice → default follows the breakpoint (mobile = rounds pager) + window.matchMedia('(max-width: 767px)').addEventListener('change', () => { + if (!VIEW_IDS.includes(storageGet('prefs', {}).bracketView)) render(); + }); loadPredictionFromURL(); const root = document.getElementById('bracket-root'); @@ -311,35 +316,264 @@ function challengeCardHTML(challenge) { `; } +// ------------------------------------------- wallchart layout engine +// Center-out "wallchart": R32 1–8 flow left→center, 9–16 right→center, +// with the Final + champion centerpiece in the middle column and the +// third-place match beneath it. All positions are computed here (absolute +// px inside the canvas); connectors are generated as SVG bezier paths from +// the same numbers, so cards and lines can never drift apart. This engine +// replaced the old flex-column + CSS-stub system (its equal-height +// invariant could not express a mirrored right half). + +const GEO = { + gap: 42, // horizontal gap between adjacent columns + centerGap: 46, // SF column ↔ center column + rowGap: 20, // vertical gap between R32 cards + padX: 28, + padTop: 62, // leaves room for the round title row + padBottom: 30, + tiers: { + R32: { w: 184, h: 78 }, + R16: { w: 196, h: 82 }, + QF: { w: 208, h: 88 }, + SF: { w: 220, h: 94 }, + FINAL: { w: 264, h: 142 }, + THIRD: { w: 208, h: 88 }, + }, + championH: 118, + championGap: 20, // champion box ↔ final card + thirdGap: 26, // final card ↔ third-place title + thirdTitleH: 22, +}; + +function refName(roundId, index) { + return roundId === 'FINAL' ? 'FINAL' : `${roundId}-${index + 1}`; +} + +function computeWallchartLayout() { + const G = GEO; + const { R32: t32, R16: t16, QF: tQF, SF: tSF, FINAL: tF, THIRD: tT } = G.tiers; + const cards = new Map(); // ref → { x, y, w, h, tier, side } + + // column x positions, left → right (right half mirrors the left) + const xL32 = G.padX; + const xL16 = xL32 + t32.w + G.gap; + const xLQF = xL16 + t16.w + G.gap; + const xLSF = xLQF + tQF.w + G.gap; + const xC = xLSF + tSF.w + G.centerGap; + const xRSF = xC + tF.w + G.centerGap; + const xRQF = xRSF + tSF.w + G.gap; + const xR16 = xRQF + tQF.w + G.gap; + const xR32 = xR16 + t16.w + G.gap; + const w = xR32 + t32.w + G.padX; + const h = G.padTop + 8 * t32.h + 7 * G.rowGap + G.padBottom; + + for (let i = 0; i < 16; i += 1) { + const left = i < 8; + const row = left ? i : i - 8; + cards.set(refName('R32', i), { + x: left ? xL32 : xR32, + y: G.padTop + row * (t32.h + G.rowGap), + w: t32.w, h: t32.h, tier: 'R32', side: left ? 'L' : 'R', + }); + } + + // each later node sits at the vertical midpoint of its two feeders + const place = (roundId, prevId, count, tier, xLeft, xRight) => { + for (let i = 0; i < count; i += 1) { + const a = cards.get(refName(prevId, i * 2)); + const b = cards.get(refName(prevId, i * 2 + 1)); + const cy = (a.y + a.h / 2 + b.y + b.h / 2) / 2; + const left = i < count / 2; + cards.set(refName(roundId, i), { + x: left ? xLeft : xRight, + y: cy - tier.h / 2, + w: tier.w, h: tier.h, tier: roundId, side: left ? 'L' : 'R', + }); + } + }; + place('R16', 'R32', 8, t16, xL16, xR16); + place('QF', 'R16', 4, tQF, xLQF, xRQF); + place('SF', 'QF', 2, tSF, xLSF, xRSF); + + const sf1 = cards.get('SF-1'); + const sf2 = cards.get('SF-2'); + const midY = (sf1.y + sf1.h / 2 + sf2.y + sf2.h / 2) / 2; + const final = { x: xC, y: midY - tF.h / 2, w: tF.w, h: tF.h, tier: 'FINAL', side: 'C' }; + cards.set('FINAL', final); + const champion = { + x: xC, y: final.y - G.championGap - G.championH, w: tF.w, h: G.championH, + }; + const thirdTitle = { x: xC + (tF.w - tT.w) / 2, y: final.y + tF.h + G.thirdGap, w: tT.w }; + cards.set('THIRD-PLACE', { + x: thirdTitle.x, y: thirdTitle.y + G.thirdTitleH, w: tT.w, h: tT.h, tier: 'THIRD', side: 'C', + }); + + // connectors: two bezier flow lines into every non-R32 node + const links = []; + const addLink = (fromRef, toRef) => { + const a = cards.get(fromRef); + const b = cards.get(toRef); + const rightward = a.side === 'L'; // left half flows →, right half flows ← + const x1 = rightward ? a.x + a.w : a.x; + const x2 = rightward ? b.x : b.x + b.w; + const y1 = a.y + a.h / 2; + const y2 = b.y + b.h / 2; + const mx = (x1 + x2) / 2; + links.push({ + from: fromRef, to: toRef, tier: b.tier, + d: `M ${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}`, + }); + }; + for (const [roundId, prevId, count] of [['R16', 'R32', 8], ['QF', 'R16', 4], ['SF', 'QF', 2], ['FINAL', 'SF', 1]]) { + for (let i = 0; i < count; i += 1) { + addLink(refName(prevId, i * 2), refName(roundId, i)); + addLink(refName(prevId, i * 2 + 1), refName(roundId, i)); + } + } + // glowing stem from the final card up into the champion box + const stemX = xC + tF.w / 2; + const stem = `M ${stemX} ${final.y} L ${stemX} ${champion.y + champion.h}`; + + const ty = G.padTop - 40; + const labels = [ + { x: xL32, y: ty, w: t32.w, tier: 'R32', phase: 'Round of 32' }, + { x: xL16, y: ty, w: t16.w, tier: 'R16', phase: 'Round of 16' }, + { x: xLQF, y: ty, w: tQF.w, tier: 'QF', phase: 'Quarterfinals' }, + { x: xLSF, y: ty, w: tSF.w, tier: 'SF', phase: 'Semifinals' }, + { x: xC, y: ty, w: tF.w, tier: 'FINAL', phase: 'Final' }, + { x: xRSF, y: ty, w: tSF.w, tier: 'SF', phase: 'Semifinals' }, + { x: xRQF, y: ty, w: tQF.w, tier: 'QF', phase: 'Quarterfinals' }, + { x: xR16, y: ty, w: t16.w, tier: 'R16', phase: 'Round of 16' }, + { x: xR32, y: ty, w: t32.w, tier: 'R32', phase: 'Round of 32' }, + ]; + + const scenery = { type: 'pitch', cx: stemX, cy: final.y + tF.h / 2, r: Math.min(230, (h - G.padTop) / 2.6) }; + return { id: 'wallchart', w, h, cards, links, stem, labels, scenery, champion, thirdTitle }; +} + +// --------------------------------------------- radial layout engine +// Second chart view — the "orbit": circular flag tokens on concentric +// rings converging on the trophy. Outermost ring = the 32 entrants; each +// ring inward holds the winner slots of a round (a match's winner slot +// doubles as its next match's participant). Elbow lines trace each team's +// route; gold = real advancement, dashed blue = simulated pick. Clicking +// any token opens its match (winner slots = the match they decide), so the +// modal / sim-editor delegation is unchanged. Names and scores live in the +// app tooltip (has-tip/data-tip) and the modal. + +const TGEO = { + pad: 40, + outerR: 388, // 32 entrant tokens + outerD: 44, + winners: { // ring where each round's winner slot sits (its diameter grows inward) + R32: { r: 322, d: 48 }, + R16: { r: 252, d: 54 }, + QF: { r: 178, d: 60 }, + SF: { r: 96, d: 68 }, // = the two finalists flanking the trophy + }, + championD: 84, + glowR: 190, // radius of the gold glow pool behind the trophy + thirdDy: 64, // third-place group below the circle + thirdGap: 72, // distance between the two third-place tokens + thirdD: 48, +}; + +function computeRadialLayout() { + const G = TGEO; + const R = G.outerR + G.outerD / 2 + 8; // circle's outer edge + const cx = G.pad + R; + const cy = G.pad + R; + const w = (G.pad + R) * 2; + const thirdY = cy + R + G.thirdDy; + const h = thirdY + G.thirdD / 2 + G.pad; + return { id: 'radial', w, h, cx, cy, thirdY }; +} + +// ------------------------------------------------------------- render + +// compact kickoff line ("Jul 4, 17:00") honoring the Local/Stadium toggle — +// formatMatchTime()'s medium dateStyle (with year) is too wide for the cards +function kickoffShort(match) { + const stadium = getData().stadiumByName.get(match.stadium); + const mode = storageGet('prefs', {}).timeMode ?? 'local'; + const options = { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }; + if (mode === 'stadium' && stadium?.timezone) options.timeZone = stadium.timezone; + return new Intl.DateTimeFormat(getLocale(), options).format(matchDateUTC(match)); +} + +// ------------------------------------------------------ view switching +// Three views: 'rounds' (swipeable pager, mobile default), 'wallchart' +// (desktop default) and 'radial'. Explicit choice persists in +// wc2026_prefs.bracketView; without one the default follows the breakpoint. + +const VIEW_IDS = ['rounds', 'wallchart', 'radial']; + +function activeView() { + const stored = storageGet('prefs', {}).bracketView; + if (VIEW_IDS.includes(stored)) return stored; + return window.matchMedia('(max-width: 767px)').matches ? 'rounds' : 'wallchart'; +} + +function setBracketView(id) { + const prefs = storageGet('prefs', {}); + prefs.bracketView = id; + storageSet('prefs', prefs); +} + +function viewSwitchHTML(active) { + const labels = { + rounds: t('bracket.viewRounds'), + wallchart: t('bracket.viewWallchart'), + radial: t('bracket.viewRadial'), + }; + return ` +
+ ${VIEW_IDS.map((id) => ` + `).join('')} +
`; +} + function render() { const tree = getBracketTree(); const simulation = getSimulation(); const challenge = calculateChallengeScore(simulation, getData().results, tree); const hasPicks = Object.keys(simulation).length > 0; + const viewId = activeView(); + const chart = viewId !== 'rounds'; + const L = chart ? (viewId === 'radial' ? computeRadialLayout() : computeWallchartLayout()) : null; + + // the fit observer from the previous render watches removed DOM — reset + if (fitRO) { fitRO.disconnect(); fitRO = null; } + document.getElementById('bracket-root').innerHTML = ` ${challenge.total ? challengeCardHTML(challenge) : ''}
+ ${viewSwitchHTML(viewId)}
+ ${chart ? `
-
+
` : ''} ${simMode ? `

${t('sim.hint')}

` : ''} -
-
-
- ${tree.rounds.filter((r) => r.id !== 'FINAL').map(roundColumnHTML).join('')} - ${finalColumnHTML(tree)} -
-
-
`; + ${chart ? chartHTML(tree, L, viewId) : pagerHTML(tree)}`; + + for (const btn of document.querySelectorAll('#bracket-root .view-btn')) { + btn.addEventListener('click', () => { + if (btn.dataset.view === viewId) return; + setBracketView(btn.dataset.view); + render(); + }); + } document.getElementById('sim-toggle').addEventListener('click', () => { simMode = !simMode; render(); @@ -360,51 +594,263 @@ function render() { window.prompt(t('share.button'), link); // clipboard unavailable — let the user copy manually } }); - initInteractions(); + if (chart) initInteractions(L); + else initPager(tree); } -function roundColumnHTML(round) { - const pairs = []; - for (let i = 0; i < round.nodes.length; i += 2) { - pairs.push(` -
-
${matchNodeHTML(round.nodes[i])}
-
${matchNodeHTML(round.nodes[i + 1])}
-
`); - } +function chartHTML(tree, L, viewId) { + const inner = viewId === 'radial' ? radialInnerHTML(tree, L) : wallchartInnerHTML(tree, L); return ` -
-

${translatePhase(round.phase)}

-
${pairs.join('')}
-
`; -} - -function finalColumnHTML(tree) { - const finalNode = tree.rounds.find((r) => r.id === 'FINAL').nodes[0]; - const champion = tree.champion ? slotDisplay({ teamId: tree.champion }) : null; - return ` -
-

${translatePhase('Final')}

-
-
-
- - ${t('bracket.champion')} - ${champion ? champion.label : t('app.tbd')} -
-
-
${matchNodeHTML(finalNode)}
-
-
-

${translatePhase('Third Place')}

- ${matchNodeHTML(tree.third)} -
+
+
+
+ ${inner}
`; } -function matchNodeHTML(node) { +function wallchartInnerHTML(tree, L) { + let cardsHTML = ''; + for (const round of tree.rounds) { + for (const node of round.nodes) cardsHTML += matchNodeHTML(node, L.cards.get(node.ref)); + } + cardsHTML += matchNodeHTML(tree.third, L.cards.get('THIRD-PLACE')); + return ` + ${sceneryHTML(L)} + ${linksHTML(L)} + ${L.labels.map((lb) => ` + + ${translatePhase(lb.phase)}`).join('')} + ${cardsHTML} + ${centerHTML(tree, L)}`; +} + +// -------------------------------------------------- radial (orbit) view + +// short tooltip line for a match: score when played, kickoff otherwise +function matchTip(node) { + if (!node.match) return ''; + const home = slotDisplay(node.home); + const away = slotDisplay(node.away); + const { result } = node; + if (result && result.status !== 'scheduled' && node.home.teamId && node.away.teamId) { + const pens = result.penalties ? ` (${result.penalties.home}-${result.penalties.away})` : ''; + return `${home.label} ${result.homeScore}–${result.awayScore} ${away.label}${pens}`; + } + if (node.simulated && node.simScore) { + return `${home.label} ${node.simScore.home}–${node.simScore.away} ${away.label} · ${t('sim.chip')}`; + } + return `${home.label} ${t('hero.vs')} ${away.label} · ${kickoffShort(node.match)}`; +} + +function radialTokenHTML(pos, d, display, node, opts = {}) { + const classes = [ + 'bk-token', + opts.ring ? `tk-${opts.ring}` : '', + display.team ? '' : 'tk-tbd', + opts.out ? 'tk-out' : '', + opts.winner ? (node.simulated ? 'tk-sim' : 'tk-winner') : '', + opts.live ? 'tk-live' : '', + opts.fav ? 'tk-fav' : '', + opts.slot && simMode && isSimulatable(node) ? 'simulatable' : '', + ].filter(Boolean).join(' '); + const tip = opts.slot ? matchTip(node) : display.label; + const interactive = node.match + ? `data-match-id="${node.match.id}" tabindex="0" role="button" + aria-label="${matchTip(node) || display.label} — ${translatePhase(node.phase)}"` + : ''; + const flag = display.team + ? `` + : ''; + return ` +
+ ${flag} +
`; +} + +function radialInnerHTML(tree, L) { + const G = TGEO; + const { cx, cy } = L; + const favorites = getFavorites(); + const roundsById = new Map(tree.rounds.map((r) => [r.id, r])); + const polar = (slot, n, r) => { + const th = ((slot + 0.5) / n) * 2 * Math.PI; + return { x: cx + r * Math.sin(th), y: cy - r * Math.cos(th), th, r }; + }; + + const tokens = []; + const lines = []; + const dots = []; + // elbow: out of the participant radially, then straight to the winner slot + const addLine = (from, to, node, sideTeamId, fromRef) => { + const rMid = to.r != null ? to.r + (from.r - (to.r ?? 0)) * 0.5 : from.r * 0.5; + const bend = { x: cx + rMid * Math.sin(from.th), y: cy - rMid * Math.cos(from.th) }; + const adv = Boolean(node.winner && node.winner === sideTeamId); + const state = adv ? (node.simulated ? 'is-sim' : 'is-adv') : ''; + lines.push(` + `); + dots.push(``); + }; + + // entrants (outer ring) + one winner slot per match, ring by ring inward + const winnerPos = new Map(); + roundsById.get('R32').nodes.forEach((node, m) => { + const wPos = polar(m, 16, G.winners.R32.r); + winnerPos.set(node.ref, wPos); + [['home', 2 * m], ['away', 2 * m + 1]].forEach(([side, slot]) => { + const pos = polar(slot, 32, G.outerR); + const teamId = node[side].teamId; + tokens.push(radialTokenHTML(pos, G.outerD, slotDisplay(node[side]), node, { + ring: 'ent', + out: Boolean(node.winner && teamId && node.winner !== teamId), + fav: favorites.includes(teamId), + })); + addLine(pos, wPos, node, teamId, node.ref); + }); + }); + const winnerSlot = (node, ring, pos) => { + const display = node.winner ? slotDisplay({ teamId: node.winner }) : { team: null, label: t('app.tbd') }; + return radialTokenHTML(pos, G.winners[ring].d, display, node, { + ring: ring.toLowerCase(), + slot: true, // this token IS the match's winner slot — sim affordance lives here + winner: Boolean(node.winner), + live: node.result?.status === 'live', + fav: Boolean(node.winner) && favorites.includes(node.winner), + }); + }; + roundsById.get('R32').nodes.forEach((node) => tokens.push(winnerSlot(node, 'R32', winnerPos.get(node.ref)))); + for (const [roundId, prevId, n, ring] of [['R16', 'R32', 8, 'R16'], ['QF', 'R16', 4, 'QF'], ['SF', 'QF', 2, 'SF']]) { + roundsById.get(roundId).nodes.forEach((node, i) => { + const wPos = polar(i, n, G.winners[ring].r); + winnerPos.set(node.ref, wPos); + for (const [side, childRef] of [['home', refName(prevId, i * 2)], ['away', refName(prevId, i * 2 + 1)]]) { + addLine(winnerPos.get(childRef), wPos, node, node[side].teamId, childRef); + } + tokens.push(winnerSlot(node, ring, wPos)); + }); + } + + // trophy center = the FINAL's winner slot + const finalNode = tree.nodesByRef.get('FINAL'); + const centerPos = { x: cx, y: cy, th: 0, r: 0 }; + addLine(winnerPos.get('SF-1'), centerPos, finalNode, finalNode.home.teamId, 'SF-1'); + addLine(winnerPos.get('SF-2'), centerPos, finalNode, finalNode.away.teamId, 'SF-2'); + const champion = tree.champion ? slotDisplay({ teamId: tree.champion }) : null; + const simChampion = Boolean(tree.champion && finalNode.simulated); + const centerTip = matchTip(finalNode); + const centerHTMLStr = ` +
+ ${simChampion ? `${t('sim.chip')}` : ''} + + ${champion?.team ? `` : ''} + ${champion ? `${champion.label}` : ''} +
`; + + // third place: a small labeled pair below the circle (SF losers) + const third = tree.third; + const thirdTip = matchTip(third); + const thirdToken = (side) => { + const display = slotDisplay(third[side]); + const win = Boolean(third.winner && third[side].teamId === third.winner); + const flag = display.team + ? `` + : ''; + return ` + ${flag}`; + }; + const thirdHTML = ` +
+ ${translatePhase('Third Place')} + ${thirdToken('home')}${thirdToken('away')} +
`; + + const orbits = [G.outerR, G.winners.R32.r, G.winners.R16.r, G.winners.QF.r] + .map((r) => ``).join(''); + return ` + + ${tokens.join('')} + ${centerHTMLStr} + ${thirdHTML}`; +} + +// faint pitch geometry echoing the wallchart layout: halfway line through +// the center column, center circle around the Final — the stadium floor. +// (The radial view draws its own orbit scenery inside radialInnerHTML.) +function sceneryHTML(L) { + const { cx, cy, r } = L.scenery; + return ` + `; +} + +function linksHTML(L) { + const paths = L.links.map((link) => ` + `).join(''); + return ` + `; +} + +// champion centerpiece + third-place block (center column). +// A simulated champion renders in the sim-blue treatment with a SIM chip — +// a user's picks must never be mistakable for a real result (same rule the +// stats verdict follows). +function centerHTML(tree, L) { + const finalNode = tree.nodesByRef.get('FINAL'); + const champion = tree.champion ? slotDisplay({ teamId: tree.champion }) : null; + const simChampion = Boolean(tree.champion && finalNode?.simulated); + const flag = champion?.team + ? `` + : ''; + const c = L.champion; + const tt = L.thirdTitle; + return ` +
+ ${simChampion ? `${t('sim.chip')}` : ''} + + ${t('bracket.champion')} + ${champion ? `${flag}${champion.label}` : t('app.tbd')} +
+ + ${translatePhase('Third Place')}`; +} + +function matchNodeHTML(node, pos) { const home = slotDisplay(node.home); const away = slotDisplay(node.away); const live = node.result?.status === 'live'; @@ -412,6 +858,7 @@ function matchNodeHTML(node) { const hasFav = favorites.includes(node.home.teamId) || favorites.includes(node.away.teamId); const classes = [ 'bracket-match', + `bk-${pos.tier.toLowerCase()}`, live ? 'is-live' : '', node.simulated ? 'is-sim' : '', hasFav ? 'has-fav' : '', @@ -421,14 +868,148 @@ function matchNodeHTML(node) { ? `data-match-id="${node.match.id}" tabindex="0" role="button" aria-label="${home.label} ${t('hero.vs')} ${away.label} — ${translatePhase(node.phase)}"` : ''; + return ` +
+ ${node.simulated ? `${t('sim.chip')}` : ''} +
+ ${teamRowHTML(home, node, 'home')} + ${teamRowHTML(away, node, 'away')} +
+ ${metaHTML(node)} +
`; +} + +// status fragment shared by chart microlines and pager cards +function statusLine(node) { + const status = node.result?.status; + if (status === 'live') return `${t('hero.live')}`; + if (status === 'finished') return `${t('bracket.ft')}`; + return node.match ? `${kickoffShort(node.match)}` : ''; +} + +// microline at the foot of a card: kickoff (upcoming) / LIVE pulse / FT. +// The Final's hero card also carries its venue. +function metaHTML(node) { + if (!node.match) return ''; + const venue = node.ref === 'FINAL' + ? `${node.match.stadium} · ${node.match.city}` + : ''; + return `
${venue}${statusLine(node)}
`; +} + +// ------------------------------------------------------- rounds pager +// Round-by-round view: chip buttons switch the visible page (R32 → R16 → +// QF → SF → Finals) — button navigation only, no horizontal scrolling. +// Cards are the fuller variant (venue + city). Same data-ref/data-match-id +// contract, so the root delegation (modal + sim editor) works unchanged. + +let pagerIndex = null; // survives re-renders (langchange etc.), not reloads + +const PAGER_PAGES = [ + { id: 'R32', phase: 'Round of 32' }, + { id: 'R16', phase: 'Round of 16' }, + { id: 'QF', phase: 'Quarterfinals' }, + { id: 'SF', phase: 'Semifinals' }, + { id: 'FINALS', phase: 'Final' }, +]; + +// first round that still has an unfinished match — the page worth opening on +function firstOpenPage(tree) { + for (let i = 0; i < 4; i += 1) { + const round = tree.rounds.find((r) => r.id === PAGER_PAGES[i].id); + if (round.nodes.some((n) => n.result?.status !== 'finished')) return i; + } + return 4; +} + +function pcardHTML(node, hero = false) { + const home = slotDisplay(node.home); + const away = slotDisplay(node.away); + const live = node.result?.status === 'live'; + const favorites = getFavorites(); + const hasFav = favorites.includes(node.home.teamId) || favorites.includes(node.away.teamId); + const classes = [ + 'bk-pcard', + hero ? 'bk-pcard-hero' : '', + live ? 'is-live' : '', + node.simulated ? 'is-sim' : '', + hasFav ? 'has-fav' : '', + simMode && isSimulatable(node) ? 'simulatable' : '', + ].filter(Boolean).join(' '); + const interactive = node.match + ? `data-match-id="${node.match.id}" tabindex="0" role="button" + aria-label="${home.label} ${t('hero.vs')} ${away.label} — ${translatePhase(node.phase)}"` + : ''; + const top = node.match + ? `
+ ${node.match.stadium} · ${node.match.city} + ${statusLine(node)} +
` + : ''; return `
${node.simulated ? `${t('sim.chip')}` : ''} + ${top} ${teamRowHTML(home, node, 'home')} ${teamRowHTML(away, node, 'away')}
`; } +function pagerFinalsHTML(tree) { + const finalNode = tree.nodesByRef.get('FINAL'); + const champion = tree.champion ? slotDisplay({ teamId: tree.champion }) : null; + const simChampion = Boolean(tree.champion && finalNode?.simulated); + const flag = champion?.team + ? `` + : ''; + return ` +
+ ${simChampion ? `${t('sim.chip')}` : ''} + + ${t('bracket.champion')} + ${champion ? `${flag}${champion.label}` : t('app.tbd')} +
+ ${pcardHTML(finalNode, true)} +

${translatePhase('Third Place')}

+ ${pcardHTML(tree.third)}`; +} + +function pagerHTML(tree) { + const roundsById = new Map(tree.rounds.map((r) => [r.id, r])); + const chips = PAGER_PAGES.map((page, i) => ` + `).join(''); + const pages = PAGER_PAGES.map((page) => { + const body = page.id === 'FINALS' + ? `
${pagerFinalsHTML(tree)}
` + : `
${roundsById.get(page.id).nodes.map((n) => pcardHTML(n)).join('')}
`; + return `
${body}
`; + }).join(''); + return ` +
+ +
${pages}
+
`; +} + +function initPager(tree) { + const pages = [...document.querySelectorAll('#bk-pages .bk-page')]; + const chips = [...document.querySelectorAll('#bk-pager-nav .bk-page-btn')]; + if (pagerIndex === null) pagerIndex = firstOpenPage(tree); + + const show = (i) => { + pagerIndex = Math.max(0, Math.min(PAGER_PAGES.length - 1, i)); + pages.forEach((page, k) => { page.hidden = k !== pagerIndex; }); + chips.forEach((chip, k) => { + chip.classList.toggle('active', k === pagerIndex); + chip.setAttribute('aria-pressed', String(k === pagerIndex)); + }); + }; + chips.forEach((chip, i) => chip.addEventListener('click', () => show(i))); + show(pagerIndex); +} + // ----------------------------------------------------- simulation (step 9) let simMode = false; @@ -550,35 +1131,53 @@ function openSimEditor(ref) { // ------------------------------------------------- interactions (step 8) const ROUND_ORDER = ['R32', 'R16', 'QF', 'SF', 'FINAL']; -// zoom level survives re-renders (language switch) but not reloads -const view = { scale: 1, natW: 0, natH: 0 }; -const MIN_SCALE = 0.4; +// Zoom state survives re-renders (language switch) but not reloads. +// The fit scale (whole chart visible) is the resting point and the zoom +// label's "100%"; it's recomputed by a ResizeObserver because the panel is +// usually hidden when render() first runs (clientWidth 0 — gotcha class). +const view = { scale: 1, fit: 0, natW: 0, natH: 0, userZoomed: false }; const MAX_SCALE = 2; +let fitRO = null; -function initInteractions() { +function initInteractions(layout) { const wrap = document.getElementById('bracket-wrap'); const zoomBox = document.getElementById('bracket-zoom'); const canvas = document.getElementById('bracket-canvas'); - view.natW = 0; // re-measure lazily — panel may be hidden right now (offsetWidth 0) - if (view.scale !== 1) applyScale(wrap, zoomBox, canvas); + if (view.layoutId !== layout.id) { // wallchart ↔ radial: fresh fit, not inherited zoom + view.layoutId = layout.id; + view.userZoomed = false; + view.fit = 0; + } + view.natW = layout.w; // geometry is computed, not measured — always known + view.natH = layout.h; + if (view.fit) { + if (!view.userZoomed) view.scale = view.fit; + applyScale(wrap, zoomBox, canvas); + } updateZoomLabel(); - const ensureMeasured = () => { - if (view.natW) return true; - if (!canvas.offsetWidth) return false; - view.natW = canvas.offsetWidth; - view.natH = canvas.offsetHeight; - return true; - }; + if (fitRO) fitRO.disconnect(); + fitRO = new ResizeObserver(() => { + const cw = wrap.clientWidth; + const ch = wrap.clientHeight; + if (cw <= 0 || ch <= 0) return; // panel hidden — wait for the tab to open + view.fit = Math.min(cw / view.natW, ch / view.natH, 1); + if (!view.userZoomed || view.scale < view.fit) view.scale = Math.max(view.fit, view.userZoomed ? view.scale : 0); + if (!view.userZoomed) view.scale = view.fit; + applyScale(wrap, zoomBox, canvas); + updateZoomLabel(); + }); + fitRO.observe(wrap); const setScale = (next, cx, cy) => { - if (!ensureMeasured()) return; - const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, next)); + if (!view.fit) return; + const scale = Math.min(MAX_SCALE, Math.max(view.fit, next)); if (scale === view.scale) return; const rect = wrap.getBoundingClientRect(); const px = (cx - rect.left + wrap.scrollLeft) / view.scale; const py = (cy - rect.top + wrap.scrollTop) / view.scale; view.scale = scale; + view.userZoomed = true; applyScale(wrap, zoomBox, canvas); wrap.scrollLeft = px * scale - (cx - rect.left); wrap.scrollTop = py * scale - (cy - rect.top); @@ -593,8 +1192,9 @@ function initInteractions() { document.getElementById('zoom-in').addEventListener('click', () => zoomAtCenter(1.25)); document.getElementById('zoom-out').addEventListener('click', () => zoomAtCenter(1 / 1.25)); document.getElementById('zoom-reset').addEventListener('click', () => { - if (!ensureMeasured()) return; - view.scale = 1; + if (!view.fit) return; + view.scale = view.fit; + view.userZoomed = false; applyScale(wrap, zoomBox, canvas); updateZoomLabel(); }); @@ -672,34 +1272,29 @@ function initInteractions() { } }, true); - // hover/focus path highlight + // hover/focus path highlight — any ref-carrying element (card or token) canvas.addEventListener('mouseover', (event) => { - const node = event.target.closest('.bracket-match'); + const node = event.target.closest('[data-ref]'); if (node) showPath(node.dataset.ref); }); canvas.addEventListener('mouseout', () => clearPath()); canvas.addEventListener('focusin', (event) => { - const node = event.target.closest('.bracket-match'); + const node = event.target.closest('[data-ref]'); if (node) showPath(node.dataset.ref); }); canvas.addEventListener('focusout', () => clearPath()); } function applyScale(wrap, zoomBox, canvas) { - if (view.natW) { - zoomBox.style.width = `${view.natW * view.scale}px`; - zoomBox.style.height = `${view.natH * view.scale}px`; - } - canvas.style.transform = view.scale === 1 ? '' : `scale(${view.scale})`; - if (view.scale === 1) { - zoomBox.style.width = ''; - zoomBox.style.height = ''; - } + zoomBox.style.width = `${view.natW * view.scale}px`; + zoomBox.style.height = `${view.natH * view.scale}px`; + canvas.style.transform = `scale(${view.scale})`; } +// 100% = the whole-chart fit, not natural card size function updateZoomLabel() { const label = document.getElementById('zoom-reset'); - if (label) label.textContent = `${Math.round(view.scale * 100)}%`; + if (label) label.textContent = `${Math.round((view.scale / (view.fit || 1)) * 100)}%`; } // ----------------------------------------------------- path highlight @@ -743,12 +1338,15 @@ function showPath(ref) { const canvas = document.getElementById('bracket-canvas'); clearPath(); canvas.classList.add('has-path'); - for (const r of pathRefs(ref)) { - const el = canvas.querySelector(`[data-ref="${r}"]`); - if (!el) continue; - el.classList.add('path-on'); - el.closest('.bracket-slot')?.classList.add('path-on'); - el.closest('.bracket-pair')?.classList.add('path-on'); + const refs = pathRefs(ref); + for (const r of refs) { + // cards (wallchart), tokens/center/third group (radial) — anything owning the ref + for (const el of canvas.querySelectorAll(`[data-ref="${r}"]`)) el.classList.add('path-on'); + } + // a connector lights up when both of its endpoints are on the path + // (the champion stem carries FINAL→FINAL, so it follows the final) + for (const path of canvas.querySelectorAll('.bk-links path')) { + if (refs.has(path.dataset.from) && refs.has(path.dataset.to)) path.classList.add('path-on'); } } diff --git a/assets/js/i18n.js b/assets/js/i18n.js index a20349a..cf061d4 100644 --- a/assets/js/i18n.js +++ b/assets/js/i18n.js @@ -95,6 +95,11 @@ const dicts = { 'bracket.groupRunnerUp': 'Group {g} Runner-up', 'bracket.bestThird': 'Best 3rd #{n}', 'bracket.champion': 'Champion', + 'bracket.ft': 'FT', + 'bracket.viewLabel': 'Bracket view', + 'bracket.viewRounds': 'Rounds', + 'bracket.viewWallchart': 'Wallchart', + 'bracket.viewRadial': 'Radial', 'bracket.zoomIn': 'Zoom in', 'bracket.zoomOut': 'Zoom out', 'bracket.zoomReset': 'Reset zoom', @@ -275,6 +280,11 @@ const dicts = { 'bracket.groupRunnerUp': '2º do Grupo {g}', 'bracket.bestThird': 'Melhor 3º #{n}', 'bracket.champion': 'Campeão', + 'bracket.ft': 'FIM', + 'bracket.viewLabel': 'Visualização do mata-mata', + 'bracket.viewRounds': 'Fases', + 'bracket.viewWallchart': 'Chaveamento', + 'bracket.viewRadial': 'Radial', 'bracket.zoomIn': 'Aproximar', 'bracket.zoomOut': 'Afastar', 'bracket.zoomReset': 'Restaurar zoom',