_wcFull =window.wcLiveSim.fullStream()_wcSimulation =window.wcLiveSim.simStream()_wcStrength = {try { returnawaitwindow.fetchParquet(window.DATA_BASE_URL+"football/wc2026_team_strength.parquet") }catch (e) { console.error("[wc2026] team_strength load failed:", e);returnnull }}_wcHubFixtures = {try { returnawaitwindow.fetchParquet(window.DATA_BASE_URL+"football/wc2026_predictions.parquet") }catch (e) { console.error("[wc2026] predictions load failed:", e);returnnull }}// Live fixture feed (kickoff times, scores, status). NULL = feed unavailable// (outage — the cards strip shows a muted notice), [] = feed loaded but no// WC rows yet. The cards below degrade gracefully to date-only in both cases._wcHubLiveFx = {returnawaitwindow.wcMaps.fetchWcFixtures()}
Show code
// ── Navigation cards ───────────────────────────────────────// Card grid mirrors football/index.qmd's section pattern. Each card// links to one of the WC sub-pages (simulator, bracket picker, title race,// groups, matches, strength) and previews what the reader will find there.{const ns ="http://www.w3.org/2000/svg"const accent ="#5a9a7a"const statsEsc =window.statsEscfunctionsvgIcon(children) {const s =document.createElementNS(ns,"svg") s.setAttribute("viewBox","0 0 24 24") s.setAttribute("width","24"); s.setAttribute("height","24") s.setAttribute("fill","none"); s.setAttribute("stroke", accent) s.setAttribute("stroke-width","1.5"); s.setAttribute("stroke-linecap","round"); s.setAttribute("stroke-linejoin","round")for (const c of children) {const el =document.createElementNS(ns, c[0])for (const [k, v] ofObject.entries(c[1])) el.setAttribute(k, v) s.appendChild(el) }return s }const icons = {"title-race": () =>svgIcon([["path", { d:"M6 21h12" }], ["path", { d:"M12 17v4" }], ["path", { d:"M7 3h10v6a5 5 0 1 1-10 0V3z" }]]),"groups": () =>svgIcon([["rect", { x:3,y:3,width:8,height:8,rx:1 }], ["rect", { x:13,y:3,width:8,height:8,rx:1 }], ["rect", { x:3,y:13,width:8,height:8,rx:1 }], ["rect", { x:13,y:13,width:8,height:8,rx:1 }]]),"matches": () =>svgIcon([["rect", { x:2,y:5,width:20,height:14,rx:2 }], ["line", { x1:12,y1:5,x2:12,y2:19 }], ["line", { x1:7,y1:10,x2:7,y2:14 }], ["line", { x1:17,y1:10,x2:17,y2:14 }]]),"strength": () =>svgIcon([["line", { x1:4,y1:20,x2:20,y2:20 }], ["rect", { x:5,y:13,width:3,height:7,rx:0.5 }], ["rect", { x:10.5,y:9,width:3,height:11,rx:0.5 }], ["rect", { x:16,y:4,width:3,height:16,rx:0.5 }]]),"simulator": () =>svgIcon([["rect", { x:3,y:3,width:18,height:18,rx:3 }], ["circle", { cx:8,cy:8,r:1.3,fill: accent,stroke:"none" }], ["circle", { cx:16,cy:8,r:1.3,fill: accent,stroke:"none" }], ["circle", { cx:12,cy:12,r:1.3,fill: accent,stroke:"none" }], ["circle", { cx:8,cy:16,r:1.3,fill: accent,stroke:"none" }], ["circle", { cx:16,cy:16,r:1.3,fill: accent,stroke:"none" }]]),"bracket": () =>svgIcon([["rect", { x:3,y:3,width:6,height:4,rx:1 }], ["rect", { x:3,y:17,width:6,height:4,rx:1 }], ["rect", { x:15,y:10,width:6,height:4,rx:1 }], ["path", { d:"M9 5h3v7h3" }], ["path", { d:"M9 19h3v-7" }]]),"players": () =>svgIcon([["circle", { cx:12,cy:7,r:3.5 }], ["path", { d:"M5 21v-1.5a6 6 0 0 1 6-6h2a6 6 0 0 1 6 6V21" }]]),"wallchart": () =>svgIcon([["rect", { x:3,y:3,width:18,height:18,rx:1.5 }], ["line", { x1:3,y1:9,x2:21,y2:9 }], ["line", { x1:9,y1:9,x2:9,y2:21 }], ["line", { x1:15,y1:9,x2:15,y2:21 }]]),"venues": () =>svgIcon([["path", { d:"M12 21s-7-6.2-7-11a7 7 0 0 1 14 0c0 4.8-7 11-7 11z" }], ["circle", { cx:12,cy:10,r:2.5 }]]),"ratings": () =>svgIcon([["polyline", { points:"3 17 9 11 13 15 21 7" }], ["polyline", { points:"15 7 21 7 21 13" }]]) }const pages = [ { title:"Simulator",desc:"Lock in results and watch every team's odds update live",href:"world-cup-simulator",icon:"simulator" }, { title:"Wall Chart",desc:"Every group and the whole bracket on one printable poster",href:"world-cup-wallchart",icon:"wallchart" }, { title:"Pick Your Bracket",desc:"Fill in your own bracket and take on the model",href:"world-cup-predictor",icon:"bracket" }, { title:"Title Race",desc:"Champion odds from the 10K-tournament sim",href:"world-cup-title-race",icon:"title-race" }, { title:"Groups",desc:"Finish probabilities for all 12 groups",href:"world-cup-groups",icon:"groups" }, { title:"Match Predictions",desc:"All 72 fixtures with probabilities and scorelines",href:"world-cup-matches",icon:"matches" }, { title:"Player Ratings",desc:"Every player at the tournament, rated going in",href:"world-cup-player-ratings",icon:"ratings" }, { title:"Player Stats",desc:"Tournament box scores, filling in as games are played",href:"world-cup-player-stats",icon:"players" }, { title:"Venues",desc:"Sixteen stadiums, three countries, on the map",href:"world-cup-venues",icon:"venues" }, { title:"Team Strength",desc:"Tiento — every squad in goals above average",href:"world-cup-strength",icon:"strength" } ]const grid =document.createElement("div") grid.className="wc-nav-grid"for (const p of pages) {const card =document.createElement("a") card.className="wc-nav-card" card.href= p.href+".html"const iconEl = icons[p.icon]()const header =document.createElement("div") header.className="wc-nav-card-header" header.appendChild(iconEl)const title =document.createElement("span") title.className="wc-nav-card-title" title.textContent= p.title header.appendChild(title) card.appendChild(header)const desc =document.createElement("p") desc.className="wc-nav-card-desc" desc.textContent= p.desc card.appendChild(desc) grid.appendChild(card) }return grid}
Show code
// ── On now & next two days — match cards ───────────────────// Sits above the nav cards: the time-sensitive strip is the newsiest thing// on the page, then the reader chooses a sub-page. Cards reuse theme.scss's// shared .match-card language; kickoff times render in the READER'S timezone// from the fixture feed's utcDate (graceful date-only fallback until the// worker fixture cron carries WC rows).{const statsEsc =window.statsEscconst maps =window.wcMapsconst liveFx = _wcHubLiveFx || []const fixtures = _wcHubFixtures || []if (fixtures.length===0&& liveFx.length===0) returnhtml``// fixture-feed lookup keyed on UTC day + canonical team pair. Date is part// of the key because from the QF onward a knockout tie can be an exact// same-orientation rematch of a group fixture — pair-only keys let the KO// row clobber the group row. Slice the ISO string (UTC day), never round// through a local Date.const fxKey = m =>`${String(m.date||"").slice(0,10)}|${m._h}|${m._a}`const fxByPair =newMap(liveFx.map(m => [fxKey(m), m]))const parseDay = d => {const m =String(d ||"").slice(0,10).match(/^(\d{4})-(\d{2})-(\d{2})$/)return m ?newDate(Date.UTC(+m[1],+m[2] -1,+m[3],12)) :null// noon UTC: stable local date }const now =newDate()const today =newDate(); today.setHours(0,0,0,0)const windowEnd =newDate(today.getTime() +3*86400000) // today + 2 full daysconst recentCut =newDate(now.getTime() -4*3600000) // keep a 23:00-yesterday kickoff visible// UNION of the two sources: the parquet only carries the 72 GROUP-stage// fixtures, so from June 28 the knockouts exist ONLY in the feed. Parquet// rows join their feed row by day + canonical pair (as before); unmatched// feed rows become prediction-less knockout cards.const matched =newSet()const entries = fixtures.map(f => {const dayKey =String(f.match_date||"").replace("Z","").slice(0,10)const key =`${dayKey}|${maps.normalizeWcTeam(f.home_team)}|${maps.normalizeWcTeam(f.away_team)}`const live = fxByPair.get(key)if (live) matched.add(key)const when = live?.date?newDate(live.date) :parseDay(f.match_date)return { f, live, when } })for (const m of liveFx) {if (matched.has(fxKey(m))) continue entries.push({ f:null,live: m,when: m.date?newDate(m.date) :null }) }// In window: any live game, plus anything from "4h ago" (so a match that// kicked off late yesterday and is still in play doesn't vanish at local// midnight) up to the end of the 3rd day.const upcoming = entries.filter(x => x.when&& ((x.live&& maps.LIVE_STATUSES.has(x.live.status)) || (x.when>= recentCut && x.when< windowEnd))).sort((a, b) => a.when- b.when)if (!upcoming.length) returnhtml``const fmtTime = dt => dt.toLocaleTimeString(undefined, { hour:"2-digit",minute:"2-digit" })const wrap =document.createElement("div") wrap.className="wc-hub-cards"const label =document.createElement("div") label.className="wc-hero-fxlabel" label.textContent="On now and the next two days — kickoff times in your timezone" wrap.appendChild(label)// Feed outage (null — distinct from the no-WC-rows-yet []): say so, since// the parquet-only cards silently drop live scores and kickoff times.if (_wcHubLiveFx ===null) {const feedNote =document.createElement("p") feedNote.className="text-muted" feedNote.style.cssText="font-size:0.78rem;margin:0 0 0.4rem" feedNote.textContent="Live scores and kickoff times are temporarily unavailable — showing model predictions." wrap.appendChild(feedNote) }let lastHead =nullconst sections = []for (const { f, live, when } of upcoming) {const head = maps.fmtDateHead(when)if (head !== lastHead) { sections.push(`<div class="match-date-header">${statsEsc(head)}</div>`); lastHead = head }const isLive = live && maps.LIVE_STATUSES.has(live.status)const finished = live && live.status==="FINISHED"&& live.homeScore!=nullconst hasTime = live && live.date&&String(live.date).length>11const infoBits = []if (isLive) infoBits.push(`<span class="live-badge">${live.status==="PAUSED"?"HT":"LIVE"}</span>`)if (hasTime &&!finished &&!isLive) infoBits.push(statsEsc(fmtTime(when)))// Feed rows don't carry a venue for WC games — fall back to the curated// group-stage map (keyed on the parquet's UTC day + canonical pair).const venue = live?.venue|| (f ? maps.venueFor(f.match_date, f.home_team, f.away_team) :null)if (venue) {const country = maps.venueCountryOf(venue) infoBits.push(statsEsc(country ?`${venue} · ${country}`: venue)) }const chip = f?`<a class="wc-group-chip" href="world-cup-group.html#group=${statsEsc(f.group)}">Group ${statsEsc(f.group)}</a>`:`<a class="wc-group-chip" href="world-cup-wallchart.html">Knockout</a>`const infoHtml =`<div class="match-info">${infoBits.join(" · ")}<span class="wc-chip-slot">${chip}</span></div>`const showScore = (finished || isLive) && live.homeScore!=nullconst centre = showScore?`<div class="match-score">${live.homeScore} – ${live.awayScore}</div>`:"vs"// Match-page deep link (stretched via .wc-match-link, see theme.scss) —// home/away must be the PARQUET-canonical names: the match page resolves// its predictions row by league|date|normalized names, then pulls live// xG/shots/chains from the worker by Opta match_id. _h/_a arrive// pre-normalized from fetchWcFixtures, parquet rows are canonical already.const linkDay = f?String(f.match_date||"").replace("Z","").slice(0,10):String(live?.date||"").slice(0,10)const linkHome = f ? f.home_team: live._hconst linkAway = f ? f.away_team: live._aconst matchLink =`<a class="wc-match-link" href="match.html#league=WC&date=${linkDay}&home=${encodeURIComponent(linkHome)}&away=${encodeURIComponent(linkAway)}">Match centre →</a>`if (f) {const favH = f.predicted==="H", favA = f.predicted==="A"// Footer prediction summary — same convention as the AFL/football// match cards ("Prediction: Team 1.8 : 0.9 Team")const predSummary = (f.pred_home_goals!=null&& f.pred_away_goals!=null)?`<div class="pred-summary">Prediction: ${statsEsc(f.home_team)}${f.pred_home_goals.toFixed(1)} : ${f.pred_away_goals.toFixed(1)}${statsEsc(f.away_team)}</div>`:"" sections.push(` <div class="match-card football wc-hub-card wc-linked">${infoHtml} <div class="match-teams"> <div class="team ${favH ?"favoured":""}">${maps.teamLinkHtml(f.home_team)}<span class="rating">${f.pred_home_goals?.toFixed(1) ??""}</span></div> <div class="match-vs">${centre}</div> <div class="team ${favA ?"favoured":""}">${maps.teamLinkHtml(f.away_team)}<span class="rating">${f.pred_away_goals?.toFixed(1) ??""}</span></div> </div> <div class="match-prediction"><div class="prob-bars-group"> <div class="prob-bar-row">${maps.probBarHtml(f.prob_home, f.prob_draw, f.prob_away)}</div> </div></div> <div class="match-card-footer">${predSummary}${matchLink}</div> </div>`) } else {// Feed-only knockout card — no prediction columns: flags + linked// names + kickoff/LIVE/score states only. sections.push(` <div class="match-card football wc-hub-card wc-linked">${infoHtml} <div class="match-teams"> <div class="team">${maps.teamLinkHtml(live._h)}</div> <div class="match-vs">${centre}</div> <div class="team">${maps.teamLinkHtml(live._a)}</div> </div> <div class="match-card-footer">${matchLink}</div> </div>`) } } sections.push(`<a class="wc-hero-more" href="world-cup-matches.html">All 72 fixtures →</a>`)const inner =document.createElement("div") inner.className="match-cards-container" inner.innerHTML= sections.join("") wrap.appendChild(inner)return wrap}
Show code
// ── Top 8 favourites teaser (pulls from simulation) ────────{if (_wcSimulation ==null|| _wcSimulation.length===0) returnhtml``const maps =window.wcMapsconst statsEsc =window.statsEscconst sorted = [..._wcSimulation].sort((a, b) => b.p_champ- a.p_champ)const top = sorted.slice(0,8)const restPct = sorted.slice(8).reduce((s, t) => s + t.p_champ,0)// Fixed 0-25% axis: bar lengths are comparable AS probabilities, so a 17%// favourite reads as "very much not a lock" rather than a full bar.const AXIS =25const wrap =document.createElement("div") wrap.style.marginTop="1.5rem"const h2 =document.createElement("h2"); h2.textContent="Who actually wins this thing?"const head =document.createElement("div") head.style.cssText="display:flex;align-items:center;gap:0.6rem;flex-wrap:wrap" head.append(h2,window.wcLiveSim.liveBadge(_wcFull ? _wcFull.meta:null)) wrap.appendChild(head)const note =document.createElement("p") note.className="text-muted" note.style.cssText="font-size:0.85rem;margin:0 0 0.6rem" note.innerHTML=`Title chances from a 10,000-tournament sim re-run live in your browser — even the favourite wins barely one run in six. Bars drawn to a 25% scale. <a href="world-cup-title-race.html">Full leaderboard →</a> · <a href="world-cup-simulator.html">Run your own what-ifs →</a>` wrap.appendChild(note)const fairOdds = (p) => p >0?`$${(100/ p).toFixed(100/ p >=20?0:1)}`:""const list =document.createElement("div") list.className="wc-titlerace" top.forEach((t, i) => {const w =Math.min(100, (t.p_champ/ AXIS) *100)const row =document.createElement("div") row.className="wc-titlerace-row" row.innerHTML=` <span class="wc-titlerace-rank">${i +1}</span> <span class="wc-titlerace-team">${maps.teamLinkHtml(t.team)}</span> <a class="wc-titlerace-group" href="world-cup-group.html#group=${statsEsc(t.group)}">Grp ${statsEsc(t.group)}</a> <div class="wc-titlerace-bar"><div class="wc-titlerace-fill" style="width:${w}%"></div></div> <span class="wc-titlerace-pct">${t.p_champ.toFixed(1)}%</span> <span class="wc-titlerace-odds" title="Fair decimal odds: no margin, straight from the sim">${fairOdds(t.p_champ)}</span> ` list.appendChild(row) })// The honest context line: the long tail collectively matters.const rest =document.createElement("div") rest.className="wc-titlerace-row wc-titlerace-rest" rest.innerHTML=` <span class="wc-titlerace-rank"></span> <span class="wc-titlerace-team">The other ${sorted.length-8} teams</span> <span class="wc-titlerace-group"></span> <div class="wc-titlerace-bar"><div class="wc-titlerace-fill wc-titlerace-fill-rest" style="width:${Math.min(100, (restPct / AXIS) *100)}%"></div></div> <span class="wc-titlerace-pct">${restPct.toFixed(1)}%</span> <span class="wc-titlerace-odds"></span> ` list.appendChild(rest) wrap.appendChild(list)return wrap}
Show code
{const inner =document.createElement("div") inner.className="side-rail-inner"const { railBlock, btnTile, tableSource } =window.editorialif (_wcSimulation && _wcSimulation.length>0) {const sorted = [..._wcSimulation].sort((a, b) => b.p_champ- a.p_champ)const fav = sorted[0]const btn =railBlock("By the numbers") btn.appendChild(btnTile(`${fav.p_champ.toFixed(0)}%`, [ { text:"Title favourite · " }, { text: fav.team,bold:true } ]))if (_wcStrength && _wcStrength.length>0) {// How many of the seven rating systems agree on the top squad —// self-explanatory, unlike a raw panna decimal.const systems = ["panna","offense","defense","epr","psr","elo","bt"]const counts =newMap()for (const k of systems) {const top = [..._wcStrength].sort((a, b) => (b[k] ??-Infinity) - (a[k] ??-Infinity))[0]if (top) counts.set(top.team, (counts.get(top.team) ||0) +1) }const [topTeam, nAgree] = [...counts.entries()].sort((a, b) => b[1] - a[1])[0] btn.appendChild(btnTile(`${nAgree} of 7`, [ { text:"rating systems say " }, { text: topTeam,bold:true }, { text:" has the strongest squad" } ])) } btn.appendChild(btnTile("48", [ { text:"Teams qualified" } ])) btn.appendChild(btnTile("72", [ { text:"Group-stage fixtures" } ])) inner.appendChild(btn) }const links =railBlock("Read next")const l1 =document.createElement("div"); l1.innerHTML=`<a href="leagues.html"><strong>Leagues & Sims</strong></a><br><span class="text-muted" style="font-size:0.78rem">Club season projections</span>` links.appendChild(l1)const l2 =document.createElement("div"); l2.style.marginTop="0.7rem" l2.innerHTML=`<a href="player-ratings.html"><strong>Player Ratings</strong></a><br><span class="text-muted" style="font-size:0.78rem">Panna ratings across 15 leagues</span>` links.appendChild(l2) inner.appendChild(links) inner.appendChild(tableSource({source:"pannadata",sourceUrl:"https://github.com/peteowen1/pannadata",sourceNote:"Opta scrape",license:"CC BY 4.0",hint:"Refreshed weekly · 10K-simulation Monte Carlo" }))return inner}