Arry7868's picture
Claude Opus 5.5
Rename the repo to box-box-f1 and update its links
6ef9258
Raw History Blame Contribute Delete
98 kB
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>F1 Simulator</title> <style> *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } body { background: #04040a; color: #fff; font-family: -apple-system, 'SF Pro Display', BlinkMacSystemFont, 'Segoe UI', sans-serif; overflow: hidden; width: 100vw; height: 100vh; user-select: none; -webkit-user-select: none; } #canvas { position: fixed; inset: 0; display: block; cursor: default; } /* ── Menu ────────────────────────────────────────────────────────────── */ @keyframes spin { to { transform: rotate(360deg); } } @keyframes cardIn { from { opacity:0; transform: translateY(28px) scale(0.97); } to { opacity:1; transform: translateY(0) scale(1); } } @keyframes titleIn { from { opacity:0; transform: translateY(-12px); } to { opacity:1; transform: translateY(0); } } @keyframes accentSlide { from { transform: scaleX(0); } to { transform: scaleX(1); } } @keyframes fadeUp { from { opacity:0; transform: translateY(10px); } to { opacity:1; transform: translateY(0); } } @keyframes bgPulse { 0%,100% { opacity:.35; } 50% { opacity:.55; } } @keyframes streakMove { from { transform: translateX(-100%) rotate(-18deg); } to { transform: translateX(220%) rotate(-18deg); } } @keyframes glowPulse { 0%,100% { box-shadow: 0 0 40px rgba(225,6,0,.18), 0 0 80px rgba(225,6,0,.06); } 50% { box-shadow: 0 0 60px rgba(225,6,0,.28), 0 0 120px rgba(225,6,0,.10); } } #menu { position: fixed; inset: 0; background: #04040a; display: flex; align-items: center; justify-content: center; z-index: 300; overflow: hidden; } /* Particle / speed-lines canvas behind the card */ #menu-bg-canvas { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; } /* Radial red glow centred on card */ #menu-glow { position: absolute; width: 700px; height: 700px; border-radius: 50%; background: radial-gradient(circle, rgba(225,6,0,.13) 0%, transparent 70%); pointer-events: none; animation: bgPulse 4s ease-in-out infinite; } /* Moving light streaks */ .menu-streak { position: absolute; top: 0; left: 0; width: 180px; height: 100%; background: linear-gradient(90deg, transparent, rgba(255,255,255,.025), transparent); transform: rotate(-18deg); pointer-events: none; animation: streakMove linear infinite; } .menu-streak:nth-child(1) { animation-duration: 7s; animation-delay: 0s; } .menu-streak:nth-child(2) { animation-duration: 11s; animation-delay: 3s; width: 90px; } .menu-streak:nth-child(3) { animation-duration: 9s; animation-delay: 6s; width: 260px; opacity:.6; } #menu-card { position: relative; width: 380px; border-radius: 18px; background: rgba(255,255,255,0.038); border: 1px solid rgba(255,255,255,0.09); padding: 40px 34px 34px; display: flex; flex-direction: column; gap: 18px; backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); animation: cardIn .55s cubic-bezier(.22,.9,.36,1) both, glowPulse 5s ease-in-out infinite; } /* Top red accent line */ #menu-card::before { content: ''; position: absolute; top: 0; left: 10%; right: 10%; height: 2px; border-radius: 0 0 2px 2px; background: linear-gradient(90deg, transparent, #e10600, rgba(255,120,80,.8), #e10600, transparent); animation: accentSlide .7s .25s cubic-bezier(.22,.9,.36,1) both; transform-origin: center; } #menu-logo { text-align: center; margin-bottom: 4px; animation: titleIn .5s .15s both; } #menu-logo-f1 { font-size: 13px; font-weight: 800; letter-spacing: 8px; text-transform: uppercase; color: #e10600; display: block; margin-bottom: 5px; text-shadow: 0 0 18px rgba(225,6,0,.55); } #menu-logo-sub { font-size: 9px; letter-spacing: 5px; text-transform: uppercase; color: rgba(255,255,255,0.2); display: block; } .sel-group { display: flex; flex-direction: column; gap: 5px; animation: fadeUp .45s both; } .sel-group:nth-of-type(1) { animation-delay: .25s; } .sel-group:nth-of-type(2) { animation-delay: .32s; } .sel-group:nth-of-type(3) { animation-delay: .39s; } .sel-label { font-size: 9px; letter-spacing: 3px; text-transform: uppercase; color: rgba(255,255,255,0.28); } .sel { appearance: none; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.09); border-radius: 8px; color: rgba(255,255,255,0.75); font-size: 13px; padding: 11px 14px; cursor: pointer; outline: none; font-family: inherit; transition: border-color .2s, background .2s, box-shadow .2s; } .sel:focus, .sel:hover:not(:disabled) { border-color: rgba(225,6,0,.45); background: rgba(225,6,0,.06); box-shadow: 0 0 0 3px rgba(225,6,0,.08); } .sel:disabled { opacity: 0.3; cursor: default; } .sel option { background: #14141e; } #cache-badge { font-size: 9px; letter-spacing: 2px; text-transform: uppercase; text-align: right; min-height: 14px; animation: fadeUp .4s .44s both; } #cache-badge.cached { color: rgba(80,220,140,0.85); } #cache-badge.uncached { color: rgba(255,185,60,0.8); } #load-btn { position: relative; overflow: hidden; padding: 14px; text-align: center; border-radius: 9px; background: rgba(225,6,0,.12); border: 1px solid rgba(225,6,0,.28); color: rgba(255,255,255,0.8); font-size: 10px; letter-spacing: 5px; text-transform: uppercase; cursor: pointer; transition: background .2s, color .2s, border-color .2s, box-shadow .2s; user-select: none; animation: fadeUp .4s .5s both; } #load-btn::after { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,.08) 50%, transparent 100%); transform: translateX(-100%); transition: transform .4s; } #load-btn:hover:not([aria-disabled="true"])::after { transform: translateX(100%); } #load-btn:hover:not([aria-disabled="true"]) { background: rgba(225,6,0,.22); border-color: rgba(225,6,0,.55); color: #fff; box-shadow: 0 0 24px rgba(225,6,0,.2); } #load-btn[aria-disabled="true"] { opacity: 0.3; cursor: default; } #prog-wrap { display: none; flex-direction: column; gap: 8px; max-height: 120px; overflow-y: auto; } #prog-log { font-size: 9px; font-family: 'SF Mono','Fira Code',ui-monospace,monospace; color: rgba(255,255,255,0.3); white-space: pre-wrap; line-height: 1.8; } .ring { width: 22px; height: 22px; align-self: center; border-radius: 50%; border: 2px solid rgba(225,6,0,.15); border-top-color: #e10600; animation: spin 0.75s linear infinite; } /* ── HUD ─────────────────────────────────────────────────────────────── */ #hud { position: fixed; inset: 0; pointer-events: none; } /* top-center: race name + timer */ #header { position: absolute; top: 26px; left: 50%; transform: translateX(-50%); text-align: center; } #race-title { font-size: 9px; letter-spacing: 5px; text-transform: uppercase; color: rgba(255,255,255,0.22); margin-bottom: 5px; pointer-events: all; cursor: pointer; transition: color .15s; } #race-title:hover { color: rgba(255,255,255,0.6); } #race-title::after { content: ' \25BE'; letter-spacing: 0; } #menu-back { position: absolute; top: 14px; right: 16px; font-size: 9px; letter-spacing: 2px; text-transform: uppercase; color: rgba(255,255,255,0.35); cursor: pointer; display: none; } #menu-back:hover { color: #fff; } #instant { display: none; flex-direction: column; gap: 8px; } #instant-list { display: flex; flex-wrap: wrap; gap: 6px; max-height: 96px; overflow-y: auto; } .chip { padding: 4px 10px; border-radius: 12px; cursor: pointer; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); font-size: 10px; color: rgba(255,255,255,0.7); white-space: nowrap; transition: background .15s, color .15s; } .chip:hover { background: rgba(225,6,0,0.35); color: #fff; } .chip.current { border-color: rgba(225,6,0,0.7); color: #fff; } #timer { font-size: 30px; font-weight: 200; letter-spacing: -0.5px; color: rgba(255,255,255,0.82); font-variant-numeric: tabular-nums; font-feature-settings: "tnum"; } /* follow badge – top left */ #follow-badge { position: absolute; top: 18px; left: 18px; display: none; align-items: center; gap: 7px; padding: 5px 14px 5px 10px; background: rgba(0,0,0,0.55); border: 1px solid rgba(255,255,255,0.09); border-radius: 20px; backdrop-filter: blur(12px); font-size: 9px; letter-spacing: 2px; text-transform: uppercase; color: rgba(255,255,255,0.42); pointer-events: all; cursor: pointer; transition: color .15s; } #follow-badge.on { display: flex; } #follow-badge:hover { color: rgba(255,255,255,0.65); } #follow-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; } /* ── Pit-stop what-if panel ──────────────────────────────────────────── */ #whatif { position: absolute; top: 56px; left: 18px; width: 360px; max-width: calc(100vw - 36px); max-height: calc(100vh - 200px); overflow-y: auto; display: none; flex-direction: column; gap: 10px; padding: 16px; background: rgba(0,0,0,0.72); border: 1px solid rgba(255,255,255,0.09); border-radius: 10px; backdrop-filter: blur(14px); pointer-events: all; font-size: 11px; color: rgba(255,255,255,0.7); } #whatif.on { display: flex; } .wi-title { font-size: 9px; letter-spacing: 3px; text-transform: uppercase; color: rgba(255,255,255,0.4); } .wi-title b { color: #fff; font-weight: 600; } .wi-controls { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } .wi-controls select { background: rgba(255,255,255,0.06); color: #fff; border: 1px solid rgba(255,255,255,0.12); border-radius: 4px; font-size: 11px; padding: 4px 6px; } .wi-controls option { background: #111; } .wi-btn { padding: 5px 12px; border-radius: 4px; cursor: pointer; text-align: center; background: rgba(225,6,0,0.75); color: #fff; font-size: 9px; letter-spacing: 2px; text-transform: uppercase; } .wi-btn.ghosted { background: rgba(255,255,255,0.08); } .wi-btn:hover { filter: brightness(1.2); } .wi-headline { font-size: 12px; color: #fff; line-height: 1.5; } .wi-row { display: grid; grid-template-columns: 46px 48px 52px 1fr 48px; gap: 6px; align-items: center; padding: 3px 0; } .wi-row.head { font-size: 8px; letter-spacing: 1px; text-transform: uppercase; color: rgba(255,255,255,0.3); } .wi-name { display: flex; align-items: center; gap: 5px; letter-spacing: 1px; } .wi-num { text-align: right; font-variant-numeric: tabular-nums; } .wi-chance { display: flex; gap: 6px; align-items: center; } .wi-bar { flex: 1; height: 6px; border-radius: 3px; background: rgba(255,255,255,0.08); position: relative; } .wi-bar > span { position: absolute; inset: 0 auto 0 0; border-radius: 3px; background: #43B02A; } .wi-note { font-size: 9px; color: rgba(255,255,255,0.3); line-height: 1.5; } .wi-err { color: #ff6b6b; } #follow-x { margin-left: 2px; color: rgba(255,255,255,0.2); } /* right: standings */ #standings { position: absolute; right: 16px; top: 64px; bottom: 52px; /* clear of the header and the tyre legend: fits 22 cars */ display: flex; flex-direction: column; gap: 2px; overflow-y: auto; overflow-x: visible; pointer-events: all; scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.12) transparent; } #standings::-webkit-scrollbar { display: none; } .drv-row { display: flex; align-items: center; gap: 6px; padding: 5px 10px 5px 7px; background: rgba(0,0,0,0.45); border: 1px solid rgba(255,255,255,0.045); border-radius: 5px; backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); min-width: 152px; pointer-events: all; cursor: pointer; transition: background .15s, border-color .15s; } .drv-row:hover { background: rgba(255,255,255,0.04); } .drv-row.active { border-color: rgba(255,255,255,0.2); background: rgba(255,255,255,0.05); } .drv-row.battle { border-color: rgba(255,180,0,0.35); background: rgba(255,160,0,0.06); animation: battle-pulse 1.4s ease-in-out infinite; } @keyframes battle-pulse { 0%,100% { border-color: rgba(255,180,0,0.35); } 50% { border-color: rgba(255,180,0,0.65); } } .drv-pos { font-size: 9px; color: rgba(255,255,255,0.18); width: 10px; text-align: right; } .drv-bar { width: 3px; height: 14px; border-radius: 1.5px; flex-shrink: 0; } .drv-name { font-size: 11px; letter-spacing: 1.5px; color: rgba(255,255,255,0.72); flex: 1; } .drv-lap { font-size: 9px; color: rgba(255,255,255,0.2); letter-spacing: .5px; } .cmp-dot { width: 16px; height: 16px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 8px; font-weight: 700; flex-shrink: 0; transition: background .3s; } .pit-tag { font-size: 8px; font-weight: 700; color: #ff5555; letter-spacing: .4px; opacity: 0; transition: opacity .2s; min-width: 18px; } .pit-tag.on { opacity: 1; } /* overtake log – above controls */ #ot-log { position: absolute; bottom: 78px; left: 50%; transform: translateX(-50%); display: flex; flex-direction: column; align-items: center; gap: 4px; pointer-events: none; } .ot-entry { padding: 4px 16px; background: rgba(0,0,0,0.6); border: 1px solid rgba(255,255,255,0.07); border-radius: 20px; font-size: 9px; letter-spacing: 1.5px; color: rgba(255,255,255,0.55); backdrop-filter: blur(8px); white-space: nowrap; animation: ot-in 5s ease-out forwards; } .radio-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 8px; vertical-align: 1px; } @keyframes ot-in { 0% { opacity:0; transform:translateY(4px); } 10% { opacity:1; transform:translateY(0); } 75% { opacity:1; } 100% { opacity:0; transform:translateY(-5px); } } /* ── Lap counter ─────────────────────────────────────────────────────── */ #lap-counter { font-size: 9px; letter-spacing: 3px; text-transform: uppercase; color: rgba(255,255,255,0.22); margin-top: 3px; } /* ── SC / VSC / Red Flag banner ──────────────────────────────────────── */ #sc-banner { position: absolute; top: 80px; left: 50%; transform: translateX(-50%); padding: 5px 22px; border-radius: 4px; font-size: 9px; letter-spacing: 4px; text-transform: uppercase; font-weight: 700; display: none; pointer-events: none; white-space: nowrap; } #sc-banner.sc { display: block; background: rgba(255,200,0,0.18); border: 1px solid rgba(255,200,0,0.55); color: #FFC800; } #sc-banner.vsc { display: block; background: rgba(255,200,0,0.12); border: 1px solid rgba(255,200,0,0.35); color: #FFC800; } #sc-banner.red { display: block; background: rgba(220,50,50,0.18); border: 1px solid rgba(220,50,50,0.55); color: #FF4444; } /* ── Fastest lap badge ──────────────────────────────────────────────── */ #fl-badge { position: absolute; top: 18px; right: 194px; display: none; align-items: center; gap: 6px; padding: 5px 13px; background: rgba(120,0,180,0.25); border: 1px solid rgba(180,0,255,0.35); border-radius: 20px; backdrop-filter: blur(10px); font-size: 9px; letter-spacing: 2px; text-transform: uppercase; color: rgba(200,130,255,0.85); pointer-events: none; } #fl-badge.on { display: flex; } #fl-dot-badge { width: 6px; height: 6px; border-radius: 50%; background: #BB00FF; flex-shrink: 0; } /* ── Mini-map (shown in follow mode) ─────────────────────────────────── */ #minimap { position: absolute; bottom: 88px; left: 16px; width: 160px; height: 120px; background: rgba(0,0,0,0.5); border: 1px solid rgba(255,255,255,0.07); border-radius: 8px; display: none; pointer-events: none; overflow: hidden; } #minimap.on { display: block; } /* ── Strategy toggle button ─────────────────────────────────────────── */ #tool-btns { position: fixed; bottom: 26px; left: 16px; display: flex; gap: 6px; } #races-btn, #strat-btn, #radio-btn, #whatif-btn { padding: 5px 14px; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 4px; font-size: 9px; letter-spacing: 2px; text-transform: uppercase; color: rgba(255,255,255,0.3); cursor: pointer; pointer-events: all; transition: color .15s, background .15s; } #races-btn:hover, #strat-btn:hover, #radio-btn:hover, #whatif-btn:hover { color: rgba(255,255,255,0.7); background: rgba(255,255,255,0.07); } #whatif-btn.off { opacity: .35; } #strat-btn.on, #radio-btn.on, #whatif-btn.on { color: white; background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.2); } /* ── Strategy strip (inside standings column when toggled) ──────────── */ .strat-strip { position: relative; display: flex; height: 4px; border-radius: 2px; overflow: visible; margin-top: 2px; width: 100%; } .strat-seg { height: 100%; } /* Pit-stop marker: a bright vertical tick above/below the strip */ .strat-pit { position: absolute; top: -3px; bottom: -3px; width: 2px; border-radius: 1px; background: #ffffff; box-shadow: 0 0 4px rgba(255,255,255,0.9), 0 0 8px rgba(255,255,255,0.4); transform: translateX(-50%); pointer-events: none; } /* ── End-of-race summary overlay ────────────────────────────────────── */ #summary { position: fixed; inset: 0; background: rgba(4,4,10,0.92); backdrop-filter: blur(18px); display: none; align-items: center; justify-content: center; z-index: 200; pointer-events: all; } #summary.on { display: flex; } #summary-card { width: 520px; max-width: 90vw; max-height: 82vh; overflow-y: auto; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.07); border-radius: 14px; padding: 32px 28px; display: flex; flex-direction: column; gap: 10px; } #summary-title { font-size: 10px; letter-spacing: 5px; text-transform: uppercase; color: rgba(255,255,255,0.22); text-align: center; margin-bottom: 4px; } .sum-row { display: flex; align-items: center; gap: 10px; padding: 7px 12px; background: rgba(255,255,255,0.025); border-radius: 6px; } .sum-pos { font-size: 11px; color: rgba(255,255,255,0.18); width: 22px; text-align: right; } .sum-bar { width: 3px; height: 16px; border-radius: 2px; flex-shrink: 0; } .sum-name { font-size: 12px; letter-spacing: 1px; flex: 1; color: rgba(255,255,255,0.75); } .sum-extra{ font-size: 9px; letter-spacing: .5px; color: rgba(255,255,255,0.22); } .sum-fl { font-size: 9px; font-weight: 700; color: #BB00FF; margin-left: 4px; } .sum-ret { font-size: 9px; color: rgba(255,80,80,0.7); margin-left: 4px; } #summary-close { align-self: center; margin-top: 8px; padding: 10px 36px; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); border-radius: 6px; font-size: 9px; letter-spacing: 4px; text-transform: uppercase; color: rgba(255,255,255,0.5); cursor: pointer; transition: background .15s, color .15s; } #summary-close:hover { background: rgba(255,255,255,0.12); color: white; } /* ── Help button ─────────────────────────────────────────────────────── */ #help-btn { position: absolute; top: 22px; right: 22px; width: 28px; height: 28px; border-radius: 50%; border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.05); color: rgba(255,255,255,0.45); font-size: 13px; line-height: 28px; text-align: center; cursor: pointer; pointer-events: all; transition: background .15s, color .15s, border-color .15s; user-select: none; } #help-btn:hover { background: rgba(255,255,255,0.12); color: #fff; border-color: rgba(255,255,255,0.25); } /* ── Help overlay ───────────────────────────────────────────────────── */ #help { position: fixed; inset: 0; background: rgba(4,4,10,0.88); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); display: none; align-items: center; justify-content: center; z-index: 250; pointer-events: all; } #help.on { display: flex; } #help-card { width: 580px; max-width: 92vw; max-height: 86vh; overflow-y: auto; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 16px; padding: 36px 36px 28px; display: flex; flex-direction: column; gap: 28px; } #help-title { font-size: 9px; letter-spacing: 6px; text-transform: uppercase; color: rgba(255,255,255,0.25); text-align: center; } .help-section { display: flex; flex-direction: column; gap: 10px; } .help-section-title { font-size: 8px; letter-spacing: 4px; text-transform: uppercase; color: #e10600; margin-bottom: 2px; } .help-row { display: flex; align-items: flex-start; gap: 14px; font-size: 12px; color: rgba(255,255,255,0.65); line-height: 1.5; } .help-key { flex-shrink: 0; min-width: 90px; font-size: 10px; font-family: 'SF Mono','Fira Code',ui-monospace,monospace; color: rgba(255,255,255,0.85); background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.1); border-radius: 5px; padding: 3px 8px; text-align: center; letter-spacing: .5px; } .help-icon { flex-shrink: 0; width: 14px; height: 14px; border-radius: 50%; margin-top: 2px; } /* Links to the source, in the menu, help panel and final classification */ .gh-link { display: block; align-self: center; text-align: center; font-size: 10px; letter-spacing: 1px; color: rgba(255,255,255,0.45); text-decoration: none; transition: color .15s; } .gh-link:hover { color: #fff; } .help-foot { font-size: 11px; line-height: 1.6; color: rgba(255,255,255,0.45); text-align: center; } .help-foot a { color: rgba(255,255,255,0.8); } #help-close { align-self: center; margin-top: 4px; padding: 10px 32px; border-radius: 7px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.09); color: rgba(255,255,255,0.55); font-size: 9px; letter-spacing: 4px; text-transform: uppercase; cursor: pointer; transition: background .15s, color .15s; } #help-close:hover { background: rgba(255,255,255,0.11); color: white; } /* bottom: controls */ #tyre-legend { position: absolute; bottom: 30px; right: 16px; display: flex; flex-direction: row; gap: 10px; pointer-events: none; } .tleg { display: flex; align-items: center; gap: 5px; font-size: 8px; color: rgba(255,255,255,0.22); letter-spacing:.5px; } .tleg-dot { width: 9px; height: 9px; border-radius: 50%; } #controls { position: absolute; bottom: 26px; left: 50%; transform: translateX(-50%); display: flex; flex-direction: column; align-items: center; gap: 9px; pointer-events: all; } #timeline-bar { display: flex; align-items: center; gap: 11px; width: 500px; max-width: 78vw; } #play-btn { width: 27px; height: 27px; border-radius: 50%; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.11); color: rgba(255,255,255,0.65); font-size: 9px; cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: background 0.15s, color 0.15s; } #play-btn:hover { background: rgba(255,255,255,0.13); color: white; } #scrubber { touch-action: none; flex: 1; height: 2px; background: rgba(255,255,255,0.07); border-radius: 1px; cursor: pointer; position: relative; } #scrubber-fill { position: absolute; left: 0; top: 0; bottom: 0; background: rgba(255,255,255,0.48); border-radius: 1px; pointer-events: none; } #scrubber-knob { position: absolute; top: 50%; width: 10px; height: 10px; background: white; border-radius: 50%; transform: translate(-50%, -50%); pointer-events: none; box-shadow: 0 0 7px rgba(255,255,255,0.5); } #speeds { display: flex; gap: 3px; } .spd { padding: 3px 10px; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.06); border-radius: 3px; color: rgba(255,255,255,0.3); font-size: 9px; letter-spacing: 0.5px; cursor: pointer; transition: background 0.15s, color 0.15s; pointer-events: all; } .spd:hover { color: rgba(255,255,255,0.65); } .spd.on { background: rgba(255,255,255,0.1); color: white; border-color: rgba(255,255,255,0.18); } /* Bigger grab area for the 2 px timeline, and no browser panning/zooming on the track canvas (pinch zooms the camera instead) */ #scrubber::before { content: ''; position: absolute; inset: -12px 0; } #canvas { touch-action: none; } .wi-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; } .wi-close { cursor: pointer; color: rgba(255,255,255,0.4); font-size: 13px; padding: 0 4px; } .wi-close:hover { color: #fff; } /* Standings expand/collapse toggle (narrow screens only) */ #st-toggle { display: none; position: absolute; right: 8px; top: 52px; padding: 4px 10px; border-radius: 12px; background: rgba(0,0,0,0.55); border: 1px solid rgba(255,255,255,0.12); font-size: 8px; letter-spacing: 2px; text-transform: uppercase; color: rgba(255,255,255,0.55); cursor: pointer; pointer-events: all; } /* ── Phones (portrait or landscape) and narrow windows ───────────────── */ @media (max-width: 760px), (max-height: 500px) { #header { top: 12px; max-width: 56vw; } #race-title { font-size: 7px; letter-spacing: 2px; line-height: 1.6; } #timer { font-size: 20px; } #fl-badge, #minimap, #tyre-legend { display: none !important; } #follow-badge { top: 12px; left: 12px; } #help-btn { top: 12px; right: 12px; } #sc-banner { top: 74px; } /* Standings collapse to position + name; the toggle expands every column */ #st-toggle { display: block; } #standings { right: 8px; top: 80px; bottom: 132px; } #standings:not(.expanded) .drv-row { min-width: 0; padding: 3px 7px 3px 5px; gap: 5px; } #standings:not(.expanded) .drv-lap, #standings:not(.expanded) .cmp-dot, #standings:not(.expanded) .strat-strip { display: none; } #standings:not(.expanded) .drv-name { font-size: 10px; letter-spacing: 1px; } #standings:not(.expanded) .pit-tag:not(.on) { display: none; } #standings.expanded { left: 8px; padding: 6px; border-radius: 10px; z-index: 40; background: rgba(4,4,10,0.92); backdrop-filter: blur(10px); } /* Bottom: toolbar row, then timeline + speeds above it */ #tool-btns { bottom: 10px; left: 12px; right: 12px; overflow-x: auto; } #races-btn, #strat-btn, #radio-btn, #whatif-btn { flex: 1 0 auto; text-align: center; padding: 7px 10px; } #controls { bottom: 50px; left: 12px; right: 12px; transform: none; } #timeline-bar { width: 100%; max-width: none; } .spd { padding: 4px 12px; } #ot-log { bottom: 128px; } #ot-log .ot-entry:nth-child(n+3) { display: none; } /* What-if becomes a bottom sheet */ #whatif { top: auto; left: 0; right: 0; bottom: 0; width: auto; max-width: none; max-height: 64vh; border-radius: 14px 14px 0 0; z-index: 60; } #menu-card { width: calc(100vw - 32px); padding: 30px 20px 24px; gap: 14px; } #help-card { padding: 22px 18px; gap: 20px; } } </style> </head> <body> <!-- Menu --> <div id="menu"> <canvas id="menu-bg-canvas"></canvas> <div id="menu-glow"></div> <div class="menu-streak"></div> <div class="menu-streak"></div> <div class="menu-streak"></div> <div id="menu-card"> <div id="menu-back" role="button">Back to race βœ•</div> <div id="menu-logo"> <span id="menu-logo-f1">F1</span> <span id="menu-logo-sub">Race Simulator</span> </div> <div id="instant"> <div class="sel-label">Ready to watch Β· instant</div> <div id="instant-list"></div> </div> <div class="sel-group"> <div class="sel-label">Year</div> <select id="sel-year" class="sel"></select> </div> <div class="sel-group"> <div class="sel-label">Grand Prix</div> <select id="sel-gp" class="sel" disabled> <option value="">β€” select year first β€”</option> </select> </div> <div class="sel-group"> <div class="sel-label">Session</div> <select id="sel-session" class="sel"> <option value="R">Race</option> <option value="Q">Qualifying</option> <option value="S">Sprint</option> <option value="FP1">Practice 1</option> <option value="FP2">Practice 2</option> <option value="FP3">Practice 3</option> </select> </div> <div id="cache-badge"></div> <div id="load-btn" role="button" aria-disabled="true">Load Race</div> <div id="prog-wrap"> <div class="ring" id="prog-ring" style="display:none"></div> <div id="prog-log"></div> </div> <a class="gh-link" href="https://github.com/ARRY7686/box-box-f1" target="_blank" rel="noopener">Open source Β· β˜… Star it on GitHub</a> </div> </div> <canvas id="canvas"></canvas> <!-- HUD --> <div id="hud"> <div id="header"> <div id="race-title">β€”</div> <div id="timer">0:00:00</div> <div id="lap-counter"></div> </div> <div id="sc-banner"></div> <div id="fl-badge"><span id="fl-dot-badge"></span><span id="fl-name">β€”</span><span style="margin-left:4px;opacity:.5">FL</span></div> <!-- Follow badge --> <div id="follow-badge"> <span id="follow-dot"></span> <span id="follow-name">β€”</span> <span id="follow-x">βœ•</span> </div> <div id="standings"></div> <div id="ot-log"></div> <div id="st-toggle" role="button">Standings β–Έ</div> <div id="minimap"><canvas id="minimap-canvas" width="160" height="120"></canvas></div> <div id="tyre-legend"> <div class="tleg"><span class="tleg-dot" style="background:#E8002D"></span>SOFT</div> <div class="tleg"><span class="tleg-dot" style="background:#FFC906"></span>MED</div> <div class="tleg"><span class="tleg-dot" style="background:#E8E8E8"></span>HARD</div> <div class="tleg"><span class="tleg-dot" style="background:#43B02A"></span>INTER</div> <div class="tleg"><span class="tleg-dot" style="background:#0067FF"></span>WET</div> </div> <div id="tool-btns"> <div id="races-btn" role="button">Races</div> <div id="strat-btn" role="button">Strategy</div> <div id="radio-btn" role="button" class="on">Radio</div> <div id="whatif-btn" role="button" class="off" title="Follow a driver in a race first">What if</div> </div> <div id="whatif"></div> <div id="help-btn" role="button">?</div> <div id="controls"> <div id="timeline-bar"> <div id="play-btn">β–Ά</div> <div id="scrubber"> <div id="scrubber-fill"></div> <div id="scrubber-knob" style="left:0"></div> </div> </div> <div id="speeds"> <div class="spd" data-v="1">1Γ—</div> <div class="spd on" data-v="5">5Γ—</div> <div class="spd" data-v="15">15Γ—</div> <div class="spd" data-v="30">30Γ—</div> <div class="spd" data-v="60">60Γ—</div> </div> </div> </div> <!-- End-of-race summary --> <div id="summary"> <div id="summary-card"> <div id="summary-title">Final Classification</div> <div id="summary-rows"></div> <div id="summary-close" role="button">Close</div> <a class="gh-link" href="https://github.com/ARRY7686/box-box-f1" target="_blank" rel="noopener">Enjoyed the replay? β˜… Star it on GitHub</a> </div> </div> <!-- Help overlay --> <div id="help"> <div id="help-card"> <div id="help-title">F1 Race Simulator β€” Features</div> <div class="help-section"> <div class="help-section-title">Playback</div> <div class="help-row"><span class="help-key">Space</span>Play / Pause the simulation</div> <div class="help-row"><span class="help-key">← / β†’</span>Rewind / skip 30 seconds</div> <div class="help-row"><span class="help-key">1 – 5</span>Set playback speed (1Γ—, 5Γ—, 15Γ—, 30Γ—, 60Γ—)</div> <div class="help-row"><span class="help-key">Scrubber</span>Click or drag the bar at the bottom to jump to any race moment</div> </div> <div class="help-section"> <div class="help-section-title">Following a Driver</div> <div class="help-row"><span class="help-key">Click car</span>Click any car dot on the track to follow it β€” camera locks on</div> <div class="help-row"><span class="help-key">Click name</span>Click a driver row in the standings to follow them</div> <div class="help-row"><span class="help-key">Esc</span>Exit follow mode and return to the full-track view</div> <div class="help-row"><span class="help-key">βœ• badge</span>Click the driver badge (top-left) to stop following</div> </div> <div class="help-section"> <div class="help-section-title">Live Standings</div> <div class="help-row"><span class="help-key">Order</span>Drivers sorted by race position in real-time</div> <div class="help-row"><span class="help-key">Gap</span>Time gap to the car ahead shown on each row</div> <div class="help-row"><span class="help-key">Tyres</span>Current compound shown as a coloured dot per driver</div> <div class="help-row"><span class="help-key">Pitting</span>Row flashes when a driver is in the pit lane</div> <div class="help-row"><span class="help-key">Retired</span>Dimmed rows (opacity 45%) indicate retired drivers</div> <div class="help-row"><span class="help-key">Battle</span><span style="display:inline-block;width:10px;height:10px;background:rgba(255,180,0,0.6);border-radius:2px;margin:0 4px"></span>Amber pulse on rows within 1.5 s of each other</div> </div> <div class="help-section"> <div class="help-section-title">Strategy Strip</div> <div class="help-row"><span class="help-key">S key</span>Toggle the strategy strip on / off for all drivers</div> <div class="help-row"><span class="help-key">H key</span>Open / close this help panel</div> <div class="help-row"><span class="help-key">M key</span>Change race: click the race title or the Races button; downloaded races load instantly</div> <div class="help-row"><span class="help-key">W key</span>Pit-stop what-if for the followed driver: pit at the end of a chosen lap on a chosen compound (or stay out) and see the projected gaps 10 laps later, with a ghost car</div> <div class="help-row"><span class="help-key">R key</span>Turn team radio on / off (plays at 1Γ— and 5Γ—; only the followed driver's radio while following)</div> <div class="help-row"><span class="help-key">Colours</span><span style="display:inline-flex;gap:4px;margin-left:4px"> <span style="width:22px;height:8px;background:#E8002D;border-radius:2px"></span> <span style="width:22px;height:8px;background:#FFC906;border-radius:2px"></span> <span style="width:22px;height:8px;background:#E8E8E8;border-radius:2px"></span> <span style="width:22px;height:8px;background:#43B02A;border-radius:2px"></span> <span style="width:22px;height:8px;background:#0067FF;border-radius:2px"></span> </span>&nbsp;Soft / Medium / Hard / Inter / Wet</div> <div class="help-row"><span class="help-key">White tick</span>Pit stop marker on the strip</div> </div> <div class="help-section"> <div class="help-section-title">Track &amp; Pit Lane</div> <div class="help-row"><span class="help-key">Track</span>White outline = racing circuit</div> <div class="help-row"><span class="help-key">Pit lane</span><span style="display:inline-block;width:22px;height:4px;background:rgba(255,165,0,.7);border-radius:2px;margin:0 4px"></span>Amber line shows the pit lane route</div> <div class="help-row"><span class="help-key">Mini-map</span>Bottom-right inset shows all cars β€” tap a followed driver’s dot to see their position clearly</div> </div> <div class="help-section"> <div class="help-section-title">Overlays &amp; Flags</div> <div class="help-row"><span class="help-key">SC / VSC</span>Safety Car banner appears at the top when active</div> <div class="help-row"><span class="help-key">FL badge</span>Fastest Lap holder shown with a purple badge (top-right)</div> <div class="help-row"><span class="help-key">Overtake log</span>Bottom-left flashes when a position change happens</div> <div class="help-row"><span class="help-key">End of race</span>Final Classification table shown automatically</div> </div> <div class="help-foot"> Open source on <a href="https://github.com/ARRY7686/box-box-f1" target="_blank" rel="noopener">GitHub</a>, with data from <a href="https://openf1.org" target="_blank" rel="noopener">OpenF1</a>. If you enjoyed it, a β˜… on GitHub helps other people find it. </div> <div id="help-close" role="button">Got it</div> </div> </div> <script> 'use strict'; // ── Menu background particle animation ────────────────────────────────────── (function() { const mc = document.getElementById('menu-bg-canvas'); if (!mc) return; const mctx = mc.getContext('2d'); let mW, mH; const DOTS = 55; const dots = []; function resize() { mW = mc.width = window.innerWidth; mH = mc.height = window.innerHeight; } resize(); window.addEventListener('resize', resize); // Generate particles β€” small glowing dots that drift slowly for (let i = 0; i < DOTS; i++) { dots.push({ x: Math.random() * 2, // 0-200% so they're spread offscreen too y: Math.random(), vx: (Math.random() - 0.5) * 0.00012, vy: (Math.random() - 0.5) * 0.00008, r: 0.5 + Math.random() * 1.5, a: 0.12 + Math.random() * 0.22, // some are red-ish, most white red: Math.random() < 0.18, }); } let running = true; window.startMenuBg = () => { if (!running) { running = true; requestAnimationFrame(drawMenu); } }; function drawMenu() { if (!document.getElementById('menu') || document.getElementById('menu').style.display === 'none') { running = false; return; } mctx.clearRect(0, 0, mW, mH); const now = performance.now() * 0.001; // Grid lines β€” faint, perspective-hinting mctx.save(); mctx.strokeStyle = 'rgba(255,255,255,0.025)'; mctx.lineWidth = 1; const cols = 9, rows = 7; for (let c = 0; c <= cols; c++) { const x = (c / cols) * mW; mctx.beginPath(); mctx.moveTo(x, 0); mctx.lineTo(x, mH); mctx.stroke(); } for (let r = 0; r <= rows; r++) { const y = (r / rows) * mH; mctx.beginPath(); mctx.moveTo(0, y); mctx.lineTo(mW, y); mctx.stroke(); } mctx.restore(); // Particles for (const d of dots) { d.x += d.vx; d.y += d.vy; if (d.x < -0.05) d.x = 1.05; if (d.x > 1.05) d.x = -0.05; if (d.y < -0.05) d.y = 1.05; if (d.y > 1.05) d.y = -0.05; const px = d.x * mW, py = d.y * mH; const pulse = 0.7 + 0.3 * Math.sin(now * 1.2 + d.x * 10 + d.y * 7); const alpha = d.a * pulse; const color = d.red ? `rgba(225,6,0,${alpha})` : `rgba(255,255,255,${alpha})`; mctx.beginPath(); mctx.arc(px, py, d.r * pulse, 0, Math.PI * 2); mctx.fillStyle = color; mctx.fill(); } // Thin red horizontal line drifting across top const lineY = mH * 0.22 + Math.sin(now * 0.4) * 18; const lineGrad = mctx.createLinearGradient(0, lineY, mW, lineY); lineGrad.addColorStop(0, 'transparent'); lineGrad.addColorStop(0.3, 'rgba(225,6,0,0.18)'); lineGrad.addColorStop(0.7, 'rgba(225,6,0,0.18)'); lineGrad.addColorStop(1, 'transparent'); mctx.beginPath(); mctx.moveTo(0, lineY); mctx.lineTo(mW, lineY); mctx.strokeStyle = lineGrad; mctx.lineWidth = 1; mctx.stroke(); requestAnimationFrame(drawMenu); } drawMenu(); })(); // ── Canvas ────────────────────────────────────────────────────────────────── const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); let W = 0, H = 0; function resize() { W = canvas.width = window.innerWidth; H = canvas.height = window.innerHeight; if (raceData) { buildTrackPath(); initCamera(); } } window.addEventListener('resize', resize); // ── State ─────────────────────────────────────────────────────────────────── let raceData = null; let simTime = 0; let playing = false; let simSpeed = 5; let lastTs = null; let trackP2D = null; let pitP2D = []; // array of Path2D segments (one per pit stop across all drivers) let showStrategy = false; let summaryShown = false; // ── Tyre compounds ────────────────────────────────────────────────────────── const CMP_CLR = { SOFT:'#E8002D', MEDIUM:'#FFC906', HARD:'#E8E8E8', INTERMEDIATE:'#43B02A', WET:'#0067FF', UNKNOWN:'#555' }; const CMP_ABR = { SOFT:'S', MEDIUM:'M', HARD:'H', INTERMEDIATE:'I', WET:'W', UNKNOWN:'?' }; function getTyre(drv, t) { if (!drv.tyres || !drv.tyres.length) return { compound:'UNKNOWN', life:0 }; let cur = drv.tyres[0]; for (const ty of drv.tyres) { if (t >= ty[0]) cur = ty; else break; } const stintStart = cur[0]; let lapsSinceStint = 0; for (const ty of drv.tyres) { if (ty[0] > stintStart && ty[0] <= t) lapsSinceStint++; } return { compound: cur[1] || 'UNKNOWN', life: (cur[2] || 0) + lapsSinceStint }; } // ── Pit detection ──────────────────────────────────────────────────────────── function isPitting(drv, t) { if (!drv.pits) return false; for (const [pi, po] of drv.pits) { if (t >= pi && t <= po) return true; } return false; } // ── DNF / Retired detection ────────────────────────────────────────────────── // A driver is considered retired if their last position sample is > 90s before current simTime function isRetired(drv, t) { if (!drv.positions || !drv.positions.length) return false; const last = drv.positions[drv.positions.length - 1][0]; return t > last + 90; } // ── Fastest lap tracking ───────────────────────────────────────────────────── // Returns { driverNum, lapTime } for fastest lap up to t function computeFastestLap(t) { let best = null, bestNum = null; for (const [num, drv] of Object.entries(raceData.drivers)) { const laps = drv.laps; for (let i = 1; i < laps.length; i++) { if (laps[i] > t) break; const lt = laps[i] - laps[i-1]; if (lt > 40 && lt < 300 && (!best || lt < best)) { best = lt; bestNum = num; } } } return bestNum ? { num: bestNum, lap: best } : null; } let _flCache = null, _flCacheT = -1; function getFastestLap(t) { if (Math.abs(t - _flCacheT) < 5) return _flCache; _flCache = computeFastestLap(t); _flCacheT = t; return _flCache; } function fmtLap(s) { const m = Math.floor(s / 60), ss = (s % 60).toFixed(3); return `${m}:${ss.padStart(6,'0')}`; } // ── Track status (SC / VSC / Red flag) ────────────────────────────────────── function getTrackStatus(t) { if (!raceData.track_status || !raceData.track_status.length) return null; let cur = null; for (const [ts, status, msg] of raceData.track_status) { if (ts <= t) cur = { status, msg }; else break; } return cur; } // ── Camera ─────────────────────────────────────────────────────────────────── const cam = { x:0, y:0, zoom:1, tx:0, ty:0, tz:1, baseZoom:1, baseX:0, baseY:0, follow: null, followZoom: 5, }; function initCamera() { let minX=Infinity, maxX=-Infinity, minY=Infinity, maxY=-Infinity; for (const [x,y] of raceData.track) { if (x<minX) minX=x; if (x>maxX) maxX=x; if (y<minY) minY=y; if (y>maxY) maxY=y; } const pad = Math.min(W,H)*0.11; const sz = Math.min((W-pad*2)/(maxX-minX),(H-pad*2)/(maxY-minY)); cam.baseZoom = sz; cam.baseX = (minX+maxX)/2; cam.baseY = (minY+maxY)/2; if (!cam.follow) { cam.tx=cam.baseX; cam.ty=cam.baseY; cam.tz=cam.baseZoom; cam.x=cam.tx; cam.y=cam.ty; cam.zoom=cam.tz; } } // ── Battle detection: any two adjacent drivers within 1s gap ──────────────── // Returns a Set of driver nums that are in a battle function getBattlingDrivers(entries) { const battling = new Set(); for (let i = 0; i < entries.length - 1; i++) { const a = entries[i], b = entries[i+1]; if (isRetired(a.drv,simTime) || isRetired(b.drv,simTime)) continue; if (isPitting(a.drv,simTime) || isPitting(b.drv,simTime)) continue; const pa = interp(a.drv.positions, simTime); const pb = interp(b.drv.positions, simTime); if (!pa || !pb) continue; const avgSpd = ((pa.v + pb.v) / 2) || 60; const distDiff = raceProgress(a.drv,simTime) - raceProgress(b.drv,simTime); if (distDiff < 0) continue; const gapSec = distDiff / (avgSpd / 3.6); if (gapSec < 1.5) { battling.add(a.num); battling.add(b.num); } } return battling; } function updateCamera(dt) { if (cam.follow && raceData) { const drv = raceData.drivers[cam.follow]; if (drv) { const pos = interp(drv.positions, simTime); if (pos) { cam.tx=pos.x; cam.ty=pos.y; cam.tz=cam.baseZoom*cam.followZoom; } } } const kp = cam.follow ? (1-Math.pow(0.0001,dt*10)) : (1-Math.pow(0.001,dt*5)); const kz = 1-Math.pow(0.001,dt*4); cam.x += (cam.tx-cam.x)*kp; cam.y += (cam.ty-cam.y)*kp; cam.zoom += (cam.tz-cam.zoom)*kz; } function worldToScreen(wx,wy) { return [(wx-cam.x)*cam.zoom+W*0.5, -(wy-cam.y)*cam.zoom+H*0.5]; } function setWorldTx() { ctx.setTransform(cam.zoom,0,0,-cam.zoom,W*0.5-cam.x*cam.zoom,H*0.5+cam.y*cam.zoom); } function setScreenTx() { ctx.setTransform(1,0,0,1,0,0); } // ── Track Path2D ───────────────────────────────────────────────────────────── function buildTrackPath() { const pts = raceData.track; trackP2D = new Path2D(); trackP2D.moveTo(pts[0][0], pts[0][1]); for (let i = 1; i < pts.length; i++) { trackP2D.lineTo(pts[i][0], pts[i][1]); } trackP2D.closePath(); // Build pit lane Path2D β€” use pre-built static path from raceData.pit_lane // (built by build_tracks.py from OpenF1; works for all circuits/years). // Falls back to extracting from telemetry if static data is missing. pitP2D = []; const pitSrc = raceData.pit_lane; // [[x,y],...] or null if (pitSrc && pitSrc.length >= 3) { const path = new Path2D(); path.moveTo(pitSrc[0][0], pitSrc[0][1]); for (let i = 1; i < pitSrc.length; i++) path.lineTo(pitSrc[i][0], pitSrc[i][1]); pitP2D.push(path); } else { // Fallback: extract from telemetry (uses longest pit-stop transit found) let bestSeg = null; for (const drv of Object.values(raceData.drivers)) { if (!drv.pits || drv.pits.length === 0) continue; for (const [piIn, piOut] of drv.pits) { const seg = drv.positions.filter(p => p[0] >= piIn - 0.5 && p[0] <= piOut + 0.5); if (seg.length >= 3 && (!bestSeg || seg.length > bestSeg.length)) bestSeg = seg; } } if (bestSeg) { const path = new Path2D(); path.moveTo(bestSeg[0][1], bestSeg[0][2]); for (let i = 1; i < bestSeg.length; i++) path.lineTo(bestSeg[i][1], bestSeg[i][2]); pitP2D.push(path); } } } // ── Mini-map ───────────────────────────────────────────────────────────────── const mmCanvas = document.getElementById('minimap-canvas'); const mmCtx = mmCanvas.getContext('2d'); let mmPath = null, mmBounds = null, mmPitPaths = []; function buildMiniMap() { if (!raceData) return; let minX=Infinity, maxX=-Infinity, minY=Infinity, maxY=-Infinity; for (const [x,y] of raceData.track) { if (x<minX) minX=x; if (x>maxX) maxX=x; if (y<minY) minY=y; if (y>maxY) maxY=y; } const pad = 10; const sx = (mmCanvas.width - pad*2) / (maxX - minX); const sy = (mmCanvas.height - pad*2) / (maxY - minY); const sc = Math.min(sx, sy); const ox = pad + (mmCanvas.width - pad*2 - (maxX-minX)*sc) / 2 - minX*sc; const oy = pad + (mmCanvas.height - pad*2 - (maxY-minY)*sc) / 2 + maxY*sc; mmBounds = { sc, ox, oy }; mmPath = new Path2D(); const pts = raceData.track; mmPath.moveTo(pts[0][0]*sc + ox, -pts[0][1]*sc + oy); for (let i = 1; i < pts.length; i++) { mmPath.lineTo(pts[i][0]*sc+ox, -pts[i][1]*sc+oy); } mmPath.closePath(); // ONE pit lane path on minimap β€” use static raceData.pit_lane mmPitPaths = []; const pitSrc2 = raceData.pit_lane; if (pitSrc2 && pitSrc2.length >= 3) { const mp = new Path2D(); mp.moveTo(pitSrc2[0][0]*sc+ox, -pitSrc2[0][1]*sc+oy); for (let i = 1; i < pitSrc2.length; i++) mp.lineTo(pitSrc2[i][0]*sc+ox, -pitSrc2[i][1]*sc+oy); mmPitPaths.push(mp); } else { // Fallback: extract from telemetry let bestSeg2 = null; for (const drv of Object.values(raceData.drivers)) { if (!drv.pits || drv.pits.length === 0) continue; for (const [piIn, piOut] of drv.pits) { const seg = drv.positions.filter(p => p[0] >= piIn - 0.5 && p[0] <= piOut + 0.5); if (seg.length >= 3 && (!bestSeg2 || seg.length > bestSeg2.length)) bestSeg2 = seg; } } if (bestSeg2) { const mp = new Path2D(); mp.moveTo(bestSeg2[0][1]*sc+ox, -bestSeg2[0][2]*sc+oy); for (let i = 1; i < bestSeg2.length; i++) mp.lineTo(bestSeg2[i][1]*sc+ox, -bestSeg2[i][2]*sc+oy); mmPitPaths.push(mp); } } } function drawMiniMap(entries) { if (!mmPath || !mmBounds) return; const {sc, ox, oy} = mmBounds; mmCtx.clearRect(0, 0, mmCanvas.width, mmCanvas.height); // Track mmCtx.strokeStyle = 'rgba(255,255,255,0.18)'; mmCtx.lineWidth = 2; mmCtx.stroke(mmPath); // Pit lane on minimap mmCtx.lineCap = 'round'; for (const mp of mmPitPaths) { mmCtx.strokeStyle = 'rgba(255,165,0,0.55)'; mmCtx.lineWidth = 2; mmCtx.stroke(mp); } // Cars for (const e of entries) { const pos = interp(e.drv.positions, simTime); if (!pos) continue; const mx = pos.x * sc + ox, my = -pos.y * sc + oy; mmCtx.beginPath(); mmCtx.arc(mx, my, 2.5, 0, Math.PI*2); mmCtx.fillStyle = isRetired(e.drv, simTime) ? '#555' : e.drv.color; mmCtx.fill(); } // Highlight followed car if (cam.follow && raceData.drivers[cam.follow]) { const pos = interp(raceData.drivers[cam.follow].positions, simTime); if (pos) { const mx = pos.x * sc + ox, my = -pos.y * sc + oy; mmCtx.beginPath(); mmCtx.arc(mx, my, 4, 0, Math.PI*2); mmCtx.strokeStyle = raceData.drivers[cam.follow].color; mmCtx.lineWidth = 1.5; mmCtx.stroke(); } } } // ── Catmull-Rom Interpolation ──────────────────────────────────────────────── function cr(p0,p1,p2,p3,t) { const t2=t*t,t3=t2*t; return .5*(2*p1+(-p0+p2)*t+(2*p0-5*p1+4*p2-p3)*t2+(-p0+3*p1-3*p2+p3)*t3); } function interp(positions, t) { const n=positions.length; if (!n) return null; if (t<=positions[0][0]) return {x:positions[0][1],y:positions[0][2],v:positions[0][3],d:positions[0][4]||0}; if (t>=positions[n-1][0]) return {x:positions[n-1][1],y:positions[n-1][2],v:positions[n-1][3],d:positions[n-1][4]||0}; let lo=0,hi=n-1; while (hi-lo>1){const m=(lo+hi)>>1; positions[m][0]<=t?(lo=m):(hi=m);} const a=(t-positions[lo][0])/(positions[hi][0]-positions[lo][0]); const i0=Math.max(0,lo-1),i3=Math.min(n-1,hi+1); return { x: cr(positions[i0][1],positions[lo][1],positions[hi][1],positions[i3][1],a), y: cr(positions[i0][2],positions[lo][2],positions[hi][2],positions[i3][2],a), v: positions[lo][3]+(positions[hi][3]-positions[lo][3])*a, d: (positions[lo][4]||0)+((positions[hi][4]||0)-(positions[lo][4]||0))*a, }; } function getLapIdx(laps,t) { if (!laps || !laps.length) return 0; let i = 0; while (i < laps.length - 1 && t >= laps[i + 1]) i++; return i; } function getLap(laps,t){ return getLapIdx(laps,t)+1; } // raceProgress = total metres driven in the race (FastF1 Distance is cumulative). function raceProgress(drv,t) { const pos = interp(drv.positions,t); return pos ? pos.d : 0; } // ── Gap to car ahead (seconds estimate) ───────────────────────────────────── function gapToAhead(entries, idx) { if (idx === 0) return null; const cur = entries[idx]; const prev = entries[idx-1]; if (isRetired(cur.drv, simTime) || isRetired(prev.drv, simTime)) return null; const distDiff = raceProgress(prev.drv, simTime) - raceProgress(cur.drv, simTime); if (distDiff <= 0) return null; const avgSpd = ((interp(prev.drv.positions, simTime)?.v || 60) + (interp(cur.drv.positions, simTime)?.v || 60)) / 2; const mps = Math.max(1, avgSpd / 3.6); return distDiff / mps; } // ── Overtake Tracking ──────────────────────────────────────────────────────── let prevOrder = []; let prevOrderSimTime = -Infinity; const otFlashes = new Map(); const otPairCooldown = new Map(); const OT_MS = 3000; const OT_SIM_INTERVAL = 12; const OT_PAIR_COOLDOWN = 90; function resetOvertakes() { prevOrder = []; prevOrderSimTime = -Infinity; otPairCooldown.clear(); } function checkOvertakes(entries, nowTs) { if (simTime - prevOrderSimTime < OT_SIM_INTERVAL) return; const curr = entries.map(e => e.num); if (prevOrder.length > 0) { for (const e of entries) { if (isPitting(e.drv, simTime) || isRetired(e.drv, simTime)) continue; const ci = curr.indexOf(e.num); const pi = prevOrder.indexOf(e.num); if (pi === -1 || pi <= ci) continue; const displacedNum = prevOrder[ci]; if (!displacedNum) continue; const displaced = raceData.drivers[displacedNum]; if (!displaced || isPitting(displaced, simTime) || isRetired(displaced, simTime)) continue; const key = `${e.num}-${displacedNum}`; const last = otPairCooldown.get(key) ?? -Infinity; if (simTime - last < OT_PAIR_COOLDOWN) continue; otPairCooldown.set(key, simTime); otFlashes.set(e.num, {endTs: nowTs + OT_MS, color: e.drv.color}); pushOtLog(e.drv.name, displaced.name, ci + 1); } } prevOrder = curr; prevOrderSimTime = simTime; } function pushOtLog(gainer,loser,pos) { const log=document.getElementById('ot-log'); const el=document.createElement('div'); el.className='ot-entry'; el.textContent=`${gainer} overtook ${loser} Β· P${pos}`; log.prepend(el); setTimeout(()=>el.remove(),5300); while(log.children.length>3) log.removeChild(log.lastChild); } // ── Team radio ─────────────────────────────────────────────────────────────── // Clips play at 1x and 5x only: at higher speeds one clip spans minutes of race. const RADIO_MAX_SPEED = 5; const radioAudio = new Audio(); let radioOn = true; let radioIdx = 0; // next clip in raceData.radio to trigger let radioNow = null; // driver number whose clip is playing radioAudio.addEventListener('ended', () => { radioNow = null; }); radioAudio.addEventListener('error', () => { radioNow = null; }); function stopRadio() { radioAudio.pause(); radioNow = null; } function resetRadio() { stopRadio(); const r = raceData?.radio || []; let lo = 0, hi = r.length; while (lo < hi) { const m = (lo + hi) >> 1; if (r[m][0] < simTime) lo = m + 1; else hi = m; } radioIdx = lo; } function checkRadio() { const r = raceData.radio; if (!r) return; while (radioIdx < r.length && r[radioIdx][0] <= simTime) { const [, num, url] = r[radioIdx++]; const drv = raceData.drivers[num]; if (!drv || !radioOn || simSpeed > RADIO_MAX_SPEED) continue; pushRadioLog(drv); if (cam.follow && cam.follow !== num) continue; radioAudio.src = url; radioNow = num; radioAudio.play().catch(() => { if (radioNow === num) radioNow = null; }); } } function pushRadioLog(drv) { const log = document.getElementById('ot-log'); const el = document.createElement('div'); el.className = 'ot-entry'; const dot = document.createElement('span'); dot.className = 'radio-dot'; dot.style.background = drv.color; el.append(dot, `${drv.name} Β· TEAM RADIO`); log.prepend(el); setTimeout(() => el.remove(), 5300); while (log.children.length > 3) log.removeChild(log.lastChild); } // ── Pit-stop what-if ───────────────────────────────────────────────────────── // The server simulates the next laps twice (real strategy vs the chosen one); // ghost = the followed driver's race time at the end of laps L..until in the what-if. let ghost = null; // {num, lap, times} const wiPanel = document.getElementById('whatif'); const wiBtn = document.getElementById('whatif-btn'); const CMP_NAME = {SOFT:'Soft', MEDIUM:'Medium', HARD:'Hard', INTERMEDIATE:'Inter', WET:'Wet'}; function whatifAvailable() { return !!(raceData && raceData.lap_table && cam.follow && ['R','S'].includes(raceData.session)); } function updateWhatifBtn() { wiBtn.classList.toggle('off', !whatifAvailable()); wiBtn.title = !raceData?.lap_table ? 'Re-download this race to enable the what-if' : !['R','S'].includes(raceData?.session) ? 'Available for races and sprints' : !cam.follow ? 'Follow a driver first (click a car)' : 'Pit-stop what-if (W)'; } function closeWhatif() { wiPanel.classList.remove('on'); wiBtn.classList.remove('on'); } function note(text, cls = 'wi-note') { const el = document.createElement('div'); el.className = cls; el.textContent = text; return el; } function openWhatif() { if (!whatifAvailable()) { updateWhatifBtn(); return; } if (playing) document.getElementById('play-btn').click(); const num = cam.follow, drv = raceData.drivers[num]; const L = getLap(drv.laps, simTime) - 1; // laps completed const N = raceData.total_laps; const title = document.createElement('div'); title.className = 'wi-title'; title.append('What if Β· '); const b = document.createElement('b'); b.textContent = drv.name; title.append(b, ` Β· from the end of lap ${L}`); const head = document.createElement('div'); head.className = 'wi-head'; const close = document.createElement('span'); close.className = 'wi-close'; close.textContent = '\u2715'; close.title = 'Close'; close.addEventListener('click', closeWhatif); head.append(title, close); wiPanel.replaceChildren(head); if (L < 3 || L >= N - 2) { wiPanel.append(note(`Pick a moment between lap 3 and lap ${N - 3}.`)); } else { const ctl = document.createElement('div'); ctl.className = 'wi-controls'; const pit = document.createElement('select'); pit.add(new Option('Stay out', '')); for (let p = L + 1; p <= Math.min(L + 8, N - 2); p++) pit.add(new Option(`Pit end of lap ${p}`, p)); pit.selectedIndex = 1; const cmp = document.createElement('select'); for (const c of ['SOFT','MEDIUM','HARD','INTERMEDIATE','WET']) cmp.add(new Option(CMP_NAME[c], c)); cmp.value = getTyre(drv, simTime).compound === 'HARD' ? 'MEDIUM' : 'HARD'; const go = document.createElement('div'); go.className = 'wi-btn'; go.textContent = 'Simulate'; pit.addEventListener('change', () => { cmp.disabled = !pit.value; }); go.addEventListener('click', () => runWhatif(num, L, pit.value, cmp.value)); ctl.append(pit, cmp, go); const out = document.createElement('div'); out.id = 'wi-result'; out.style.display = 'flex'; out.style.flexDirection = 'column'; out.style.gap = '6px'; wiPanel.append(ctl, out); } wiPanel.classList.add('on'); wiBtn.classList.add('on'); } const fmtG = g => g == null ? '–' : (g > 0 ? '+' : '') + g.toFixed(1); async function runWhatif(num, L, pitLap, compound) { const out = document.getElementById('wi-result'); out.replaceChildren(note('Simulating…')); const q = new URLSearchParams({year: raceData.year, gp: raceData.grand_prix, session: raceData.session, driver: num, lap: L, pit_lap: pitLap, compound}); let r; try { const res = await fetch('/api/whatif?' + q); r = await res.json(); if (!res.ok) throw new Error(r.error || res.statusText); } catch (e) { out.replaceChildren(note('Could not simulate: ' + e.message, 'wi-note wi-err')); return; } const drv = raceData.drivers[num]; const plan = r.pit_lap ? `pits at the end of lap ${r.pit_lap} for ${CMP_NAME[r.compound].toLowerCase()}s` : 'stays out'; const head = document.createElement('div'); head.className = 'wi-headline'; head.textContent = `If ${drv.name} ${plan}: projected P${r.position_whatif} at the end of lap ${r.until} ` + `(P${r.position_actual_plan} with the real strategy).`; out.replaceChildren(head); const hdr = document.createElement('div'); hdr.className = 'wi-row head'; for (const t of ['Rival', 'Gap now', 'What-if', 'Chance ahead', 'Really']) hdr.append(note(t, '')); out.append(hdr); // The 8 rivals closest in the what-if, in projected order const nearest = [...r.rivals].sort((a, b) => Math.abs(a.gap_whatif) - Math.abs(b.gap_whatif)) .slice(0, 8).sort((a, b) => b.gap_whatif - a.gap_whatif); for (const rv of nearest) { const rd = raceData.drivers[rv.driver]; if (!rd) continue; const row = document.createElement('div'); row.className = 'wi-row'; const name = document.createElement('div'); name.className = 'wi-name'; const dot = document.createElement('span'); dot.className = 'radio-dot'; dot.style.background = rd.color; dot.style.marginRight = '0'; name.append(dot, rd.name); const chance = document.createElement('div'); chance.className = 'wi-chance'; const bar = document.createElement('div'); bar.className = 'wi-bar'; const fill = document.createElement('span'); fill.style.width = (rv.p_ahead_whatif * 100) + '%'; bar.append(fill); chance.title = `${Math.round(rv.p_ahead_whatif * 100)}% with the what-if, ` + `${Math.round(rv.p_ahead_actual_plan * 100)}% with the real strategy`; chance.append(bar, note(`${Math.round(rv.p_ahead_whatif * 100)}%`, 'wi-num')); row.append(name, note(fmtG(rv.gap_now), 'wi-num'), note(fmtG(rv.gap_whatif), 'wi-num'), chance, note(fmtG(rv.gap_really), 'wi-num')); out.append(row); } out.append(note(`Gaps in seconds (+ = ${drv.name} behind) at the end of lap ${r.until}. ` + `"Really" is what happened with the real strategy. Model trained on ${r.model.races} races; ` + `chances come from its errors on races it never saw.`)); const watch = document.createElement('div'); watch.className = 'wi-btn ghosted'; watch.textContent = 'Watch it with a ghost car'; watch.addEventListener('click', () => { ghost = buildGhost(num, L, r.ghost, r.pit_lap); simTime = r.ghost[0] - 2; resetOvertakes(); resetRadio(); closeWhatif(); if (!playing) document.getElementById('play-btn').click(); }); out.append(watch); } wiBtn.addEventListener('click', () => wiPanel.classList.contains('on') ? closeWhatif() : openWhatif()); // Ghost car: each what-if lap replays a real lap of the right kind, re-timed to // the what-if lap time. Normal laps copy the driver's nearest normal lap (racing // line, real braking points); the what-if in-lap and out-lap copy a real pit // stop (the driver's own nearest one, else another driver's), so the ghost // drives through the pit lane where it stops and stays on track where it doesn't. function buildGhost(num, L, times, pitLap) { const t = raceData.lap_table, c = Object.fromEntries(t.columns.map((k, i) => [k, i])); const rows = t.rows.filter(r => r[c.lap_time] != null).map(r => ({ drv: String(r[c.driver]), lap: r[c.lap], start: r[c.start], end: r[c.start] + r[c.lap_time], pitIn: r[c.pit_in_lap], pitOut: r[c.pit_out_lap], neutral: r[c.neutralised]})); const at = new Map(rows.map(r => [`${r.drv}:${r.lap}`, r])); const isNormal = r => r && !r.pitIn && !r.pitOut; const normalLap = n => { for (let k = 0; k < 20; k++) for (const m of [n - k, n + k]) { const r = at.get(`${num}:${m}`); if (isNormal(r)) return r; } return null; }; let stop = null; // a real in-lap followed by its out-lap if (pitLap) { const stops = rows.filter(r => r.pitIn && !r.neutral && at.get(`${r.drv}:${r.lap + 1}`)?.pitOut); const own = stops.filter(r => r.drv === num); stop = (own.length ? own : stops).sort((a, b) => Math.abs(a.lap - pitLap) - Math.abs(b.lap - pitLap))[0]; } const laps = []; for (let i = 1; i < times.length; i++) { const n = L + i; const tpl = stop && n === pitLap ? stop : stop && n === pitLap + 1 ? at.get(`${stop.drv}:${stop.lap + 1}`) : normalLap(n); laps.push({t0: times[i - 1], t1: times[i], tpl}); } return {num, laps}; } function ghostPos() { if (!ghost || !raceData) return null; const seg = ghost.laps.find(s => simTime >= s.t0 && simTime <= s.t1); if (!seg || !seg.tpl) return null; const f = (simTime - seg.t0) / Math.max(1e-6, seg.t1 - seg.t0); const tt = seg.tpl.start + f * (seg.tpl.end - seg.tpl.start); const src = raceData.drivers[seg.tpl.drv]; const p = src && interp(src.positions, tt); if (!p) return null; return {x: p.x, y: p.y, drv: raceData.drivers[ghost.num], pit: isPitting(src, tt)}; } function drawGhost(nowTs) { const gp = ghostPos(); if (!gp) return; const [sx, sy] = worldToScreen(gp.x, gp.y); ctx.save(); ctx.setLineDash([3, 3]); ctx.lineWidth = 2; ctx.strokeStyle = gp.drv.color; ctx.globalAlpha = 0.6 + 0.3 * Math.sin(nowTs / 200); ctx.beginPath(); ctx.arc(sx, sy, 7, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); ctx.globalAlpha = 0.9; ctx.font = '700 8px -apple-system,sans-serif'; ctx.fillStyle = gp.drv.color; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(gp.drv.name + ' WHAT-IF' + (gp.pit ? ' Β· PIT' : ''), sx + 11, sy); ctx.restore(); } // ── Draw Track ─────────────────────────────────────────────────────────────── function drawTrack() { if (!trackP2D) return; const sw=1/cam.zoom; setWorldTx(); ctx.lineCap=ctx.lineJoin='round'; ctx.strokeStyle='rgba(70,70,120,0.07)'; ctx.lineWidth=50*sw; ctx.shadowColor='rgba(110,110,200,0.18)'; ctx.shadowBlur=55; ctx.stroke(trackP2D); ctx.strokeStyle='rgba(16,16,32,1)'; ctx.lineWidth=15*sw; ctx.shadowColor='rgba(0,0,0,0.9)'; ctx.shadowBlur=6; ctx.stroke(trackP2D); ctx.strokeStyle='rgba(255,255,255,0.13)'; ctx.lineWidth=0.7*sw; ctx.shadowColor='rgba(255,255,255,0.18)'; ctx.shadowBlur=3; ctx.stroke(trackP2D); // Pit lane β€” amber line on top of the track if (pitP2D.length > 0) { ctx.lineCap = ctx.lineJoin = 'round'; for (const p of pitP2D) { ctx.strokeStyle = 'rgba(10,10,20,1)'; ctx.lineWidth = 11*sw; ctx.shadowBlur = 0; ctx.stroke(p); ctx.strokeStyle = 'rgba(255,165,0,0.7)'; ctx.lineWidth = 3.5*sw; ctx.shadowColor = 'rgba(255,165,0,0.6)'; ctx.shadowBlur = 8; ctx.stroke(p); } } ctx.shadowBlur=0; setScreenTx(); } // ── Draw Cars ──────────────────────────────────────────────────────────────── function drawCars(entries, nowTs) { const fl = getFastestLap(simTime); const STEPS=8, DT=0.28; setWorldTx(); ctx.lineCap='round'; for (const e of entries) { const pos=interp(e.drv.positions,simTime); if(!pos) continue; const {x,y}=pos; const retired = isRetired(e.drv, simTime); const pitting=isPitting(e.drv,simTime); const dim=( pitting||retired)?0.25:1; const color = retired ? '#444' : e.drv.color; // Trail if (!retired) { let prev=null; for(let k=STEPS;k>=0;k--){ const tp=interp(e.drv.positions,simTime-k*DT); if(!tp){prev=null;continue;} if(prev){ const a=((STEPS-k)/STEPS)*0.38*dim; const h=Math.round(a*255).toString(16).padStart(2,'0'); ctx.beginPath(); ctx.moveTo(prev.x,prev.y); ctx.lineTo(tp.x,tp.y); ctx.strokeStyle=color+h; ctx.lineWidth=2.4/cam.zoom; ctx.stroke(); } prev=tp; } } // Halo ctx.beginPath(); ctx.arc(x,y,10/cam.zoom,0,Math.PI*2); ctx.fillStyle=color+(retired?'08':pitting?'0c':'1a'); if (!retired) { ctx.shadowColor=color; ctx.shadowBlur=18; } ctx.fill(); ctx.shadowBlur=0; // Dot ctx.beginPath(); ctx.arc(x,y,4.2/cam.zoom,0,Math.PI*2); ctx.fillStyle=retired?'#333':pitting?color+'66':color; if (!retired) { ctx.shadowColor=color; ctx.shadowBlur=pitting?4:13; } ctx.fill(); ctx.shadowBlur=0; // Fastest lap ring (purple) if (fl && fl.num === e.num && !retired) { ctx.beginPath(); ctx.arc(x,y,8/cam.zoom,0,Math.PI*2); ctx.strokeStyle='#BB00FF'; ctx.lineWidth=1.5/cam.zoom; ctx.shadowColor='#BB00FF'; ctx.shadowBlur=10; ctx.stroke(); ctx.shadowBlur=0; } // Overtake flash ring const flash=otFlashes.get(e.num); if(flash&&flash.endTs>nowTs){ const t2=1-(flash.endTs-nowTs)/OT_MS; const ra=Math.max(0,Math.sin(t2*Math.PI*3)*(1-t2)); if(ra>0){ const rh=Math.round(ra*255).toString(16).padStart(2,'0'); ctx.beginPath(); ctx.arc(x,y,(17+t2*10)/cam.zoom,0,Math.PI*2); ctx.strokeStyle='#FFFFFF'+rh; ctx.lineWidth=2/cam.zoom; ctx.shadowColor='#FFFFFF'; ctx.shadowBlur=14; ctx.stroke(); ctx.shadowBlur=0; } } } setScreenTx(); ctx.textAlign='center'; for (const e of entries) { const pos=interp(e.drv.positions,simTime); if(!pos) continue; const [sx,sy]=worldToScreen(pos.x,pos.y); const retired=isRetired(e.drv,simTime); const pitting=isPitting(e.drv,simTime); if (retired) { ctx.font='700 8px -apple-system,sans-serif'; ctx.fillStyle='rgba(150,50,50,0.85)'; ctx.textBaseline='bottom'; ctx.fillText('RET',sx,sy-11); } else if(pitting){ ctx.font='700 8px -apple-system,sans-serif'; ctx.fillStyle='rgba(255,80,80,0.9)'; ctx.textBaseline='bottom'; ctx.fillText('PIT',sx,sy-11); } else { ctx.font='700 8px -apple-system,sans-serif'; ctx.fillStyle='rgba(255,255,255,0.72)'; ctx.textBaseline='bottom'; ctx.fillText(e.drv.name,sx,sy-11); } if (radioNow === e.num) { const pulse = 0.5 + 0.5 * Math.sin(nowTs / 110); ctx.beginPath(); ctx.arc(sx, sy, 14 + pulse * 5, 0, Math.PI * 2); ctx.strokeStyle = e.drv.color; ctx.globalAlpha = 0.35 + 0.45 * pulse; ctx.lineWidth = 1.5; ctx.stroke(); ctx.globalAlpha = 1; ctx.font = '700 7px -apple-system,sans-serif'; ctx.fillStyle = e.drv.color; ctx.textBaseline = 'top'; ctx.fillText('RADIO', sx, sy + (cam.follow === e.num ? 26 : 12)); } if(cam.follow===e.num){ ctx.font='600 9px -apple-system,sans-serif'; ctx.fillStyle='rgba(255,255,255,0.38)'; ctx.textBaseline='top'; ctx.fillText(Math.round(pos.v)+' km/h',sx,sy+13); ctx.lineWidth=1; ctx.strokeStyle='rgba(255,255,255,0.2)'; const R=24; for(let a=0;a<4;a++){ ctx.beginPath(); ctx.arc(sx,sy,R,(a*0.5+0.07)*Math.PI,(a*0.5+0.43)*Math.PI); ctx.stroke(); } } } ctx.textBaseline='alphabetic'; } // ── Render Loop ────────────────────────────────────────────────────────────── function frame(ts) { requestAnimationFrame(frame); if (!raceData || !trackP2D) return; const dt = lastTs!==null ? Math.min((ts-lastTs)/1000,0.1) : 0; lastTs = ts; if (playing) { simTime += dt*simSpeed; if (simTime>=raceData.t_end) { simTime=raceData.t_end; playing=false; document.getElementById('play-btn').textContent='β–Ά'; if (!summaryShown) showSummary(); } } // Build entries sorted by race progress (P1 first) const entries=[]; for (const [num,drv] of Object.entries(raceData.drivers)) { const pos=interp(drv.positions,simTime); if(!pos) continue; entries.push({num,drv,pos}); } entries.sort((a,b)=>raceProgress(b.drv,simTime)-raceProgress(a.drv,simTime)); // battle highlights computed per-frame in updateHUD updateCamera(dt); ctx.fillStyle='#04040a'; ctx.fillRect(0,0,W,H); drawTrack(); drawCars(entries,ts); drawGhost(ts); if (cam.follow) { document.getElementById('minimap').classList.add('on'); drawMiniMap(entries); } else { document.getElementById('minimap').classList.remove('on'); } checkOvertakes(entries,ts); checkRadio(); updateWhatifBtn(); updateHUD(entries); } // ── HUD ────────────────────────────────────────────────────────────────────── function fmtTime(s) { s=Math.max(0,s); const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),ss=Math.floor(s%60); return `${h}:${String(m).padStart(2,'0')}:${String(ss).padStart(2,'0')}`; } function fmtGap(sec) { if (sec === null) return ''; if (sec < 60) return '+' + sec.toFixed(1) + 's'; return '+' + fmtTime(sec); } document.getElementById('standings').addEventListener('click',e=>{ const row=e.target.closest('[data-num]'); if(!row) return; const num = row.dataset.num; cam.follow===num ? exitFollow() : enterFollow(num); }); let _lastStHtml=''; function updateHUD(entries) { const prog=(simTime-raceData.t_start)/(raceData.t_end-raceData.t_start); document.getElementById('timer').textContent=fmtTime(simTime-raceData.t_start); document.getElementById('scrubber-fill').style.width=(prog*100)+'%'; document.getElementById('scrubber-knob').style.left=(prog*100)+'%'; // Lap counter const lapEl = document.getElementById('lap-counter'); if (raceData.total_laps > 0) { const leaderLap = getLap(entries[0]?.drv?.laps || [], simTime); lapEl.textContent = `Lap ${Math.min(leaderLap, raceData.total_laps)} / ${raceData.total_laps}`; } // SC / VSC / Red flag banner const sc = getTrackStatus(simTime); const scEl = document.getElementById('sc-banner'); if (sc) { const s = sc.status; if (s === '4') { scEl.className='sc'; scEl.textContent='Safety Car'; } else if (s === '6') { scEl.className='sc'; scEl.textContent='Virtual Safety Car'; } else if (s === '5') { scEl.className='red'; scEl.textContent='Red Flag'; } else if (s === '2') { scEl.className='vsc'; scEl.textContent='Yellow Flag'; } else { scEl.className=''; } } else { scEl.className=''; } // Fastest lap badge const fl = getFastestLap(simTime); const flBadge = document.getElementById('fl-badge'); if (fl && raceData.drivers[fl.num]) { flBadge.classList.add('on'); document.getElementById('fl-name').textContent = raceData.drivers[fl.num].name + ' Β· ' + fmtLap(fl.lap); } else { flBadge.classList.remove('on'); } // Standings rows const battling = getBattlingDrivers(entries); const html=entries.map((e,i)=>{ const ty=getTyre(e.drv,simTime); const cc=CMP_CLR[ty.compound]||CMP_CLR.UNKNOWN; const ca=CMP_ABR[ty.compound]||'?'; const pit=isPitting(e.drv,simTime); const ret=isRetired(e.drv,simTime); const act=cam.follow===e.num?' active':battling.has(e.num)?' battle':''; const txtColor=ty.compound==='HARD'?'#111':'rgba(0,0,0,0.75)'; const flTag = (fl && fl.num===e.num) ? '<span style="font-size:8px;color:#BB00FF;margin-left:2px">FL</span>' : ''; const retTag = ret ? '<span style="font-size:8px;color:rgba(255,80,80,0.6);margin-left:2px">RET</span>' : ''; const gap = gapToAhead(entries, i); const gapStr = i===0 ? '<span class="drv-lap" style="color:rgba(255,255,255,0.35)">Leader</span>' : `<span class="drv-lap">${gap!==null ? fmtGap(gap) : ''}</span>`; const lapStr = `L${getLap(e.drv.laps,simTime)}`; let stratHtml = ''; if (showStrategy) { stratHtml = buildStrategyBar(e.drv); } return `<div class="drv-row${act}${ret?' ret':''}" data-num="${e.num}"${ret?' style="opacity:0.45"':''}> <span class="drv-pos">${i+1}</span> <span class="drv-bar" style="background:${ret?'#444':e.drv.color}"></span> <span class="drv-name">${e.drv.name}${flTag}${retTag}</span> <span class="cmp-dot" title="${ty.compound} – ${ty.life} laps" style="background:${ret?'#333':cc};color:${txtColor}">${ret?'β€”':ca}</span> <span class="drv-lap">${lapStr}</span> ${gapStr} <span class="pit-tag${pit&&!ret?' on':''}">PIT</span> ${stratHtml} </div>`; }).join(''); if(html!==_lastStHtml){ document.getElementById('standings').innerHTML=html; _lastStHtml=html; } } // ── Strategy bar HTML ───────────────────────────────────────────────────────── function buildStrategyBar(drv) { if (!drv.tyres || drv.tyres.length === 0) return ''; const tEnd = raceData.t_end, tStart = raceData.t_start; const total = tEnd - tStart; // Build stints: each stint goes from tyres[i][0] to tyres[i+1][0] (or t_end) let segs = ''; for (let i = 0; i < drv.tyres.length; i++) { const start = Math.max(0, drv.tyres[i][0] - tStart); const end = i+1 < drv.tyres.length ? drv.tyres[i+1][0] - tStart : total; const pct = Math.max(1, ((end - start) / total) * 100); const clr = CMP_CLR[drv.tyres[i][1]] || CMP_CLR.UNKNOWN; segs += `<div class="strat-seg" style="width:${pct.toFixed(1)}%;background:${clr};opacity:0.75"></div>`; } // Pit-stop markers: one white tick per pit stop (at PitInTime) let pits = ''; if (drv.pits) { for (const [piIn] of drv.pits) { const pos = Math.max(0, Math.min(100, ((piIn - tStart) / total) * 100)); pits += `<div class="strat-pit" style="left:${pos.toFixed(2)}%"></div>`; } } return `<div class="strat-strip" style="min-width:60px;max-width:80px">${segs}${pits}</div>`; } // ── Follow mode ─────────────────────────────────────────────────────────────── function enterFollow(num){ cam.follow=num; const drv=raceData.drivers[num]; document.getElementById('follow-dot').style.background=drv.color; document.getElementById('follow-name').textContent=drv.name; document.getElementById('follow-badge').classList.add('on'); } function exitFollow(){ cam.follow=null; closeWhatif(); cam.tx=cam.baseX; cam.ty=cam.baseY; cam.tz=cam.baseZoom; document.getElementById('follow-badge').classList.remove('on'); document.getElementById('minimap').classList.remove('on'); } document.getElementById('follow-badge').addEventListener('click',e=>{ e.stopPropagation(); exitFollow(); }); canvas.addEventListener('click', e => { if (!raceData) return; // Hit-test: find nearest car within 28px of click const HIT = 28; let best = null, bestD = HIT * HIT; for (const [num, drv] of Object.entries(raceData.drivers)) { const pos = interp(drv.positions, simTime); if (!pos) continue; const [sx, sy] = worldToScreen(pos.x, pos.y); const d = (sx - e.clientX) ** 2 + (sy - e.clientY) ** 2; if (d < bestD) { bestD = d; best = num; } } if (best) { cam.follow === best ? exitFollow() : enterFollow(best); } else if (cam.follow) { exitFollow(); } }); function zoomAt(sx, sy, zd) { if(cam.follow){ cam.followZoom=Math.max(1.5,Math.min(20,cam.followZoom*zd)); } else { const wx=(sx-W*0.5)/cam.tz+cam.tx; const wy=-(sy-H*0.5)/cam.tz+cam.ty; cam.tz=Math.max(cam.baseZoom*0.35,Math.min(cam.baseZoom*28,cam.tz*zd)); cam.tx=wx-(sx-W*0.5)/cam.tz; cam.ty=wy+(sy-H*0.5)/cam.tz; } } canvas.addEventListener('wheel',e=>{ if(!raceData) return; e.preventDefault(); zoomAt(e.clientX, e.clientY, e.deltaY<0?1.15:1/1.15); },{passive:false}); // Two-finger pinch zooms around the midpoint between the fingers const touches = new Map(); let pinchDist = 0; const pinchSpan = () => { const [a, b] = [...touches.values()]; return Math.hypot(a.x - b.x, a.y - b.y); }; canvas.addEventListener('pointerdown', e => { touches.set(e.pointerId, {x: e.clientX, y: e.clientY}); if (touches.size === 2) pinchDist = pinchSpan(); }); canvas.addEventListener('pointermove', e => { if (!touches.has(e.pointerId)) return; touches.set(e.pointerId, {x: e.clientX, y: e.clientY}); if (touches.size === 2 && raceData && pinchDist > 0) { const d = pinchSpan(), [a, b] = [...touches.values()]; zoomAt((a.x + b.x) / 2, (a.y + b.y) / 2, d / pinchDist); pinchDist = d; } }); for (const ev of ['pointerup', 'pointercancel', 'pointerleave']) canvas.addEventListener(ev, e => { touches.delete(e.pointerId); if (touches.size < 2) pinchDist = 0; }); // Narrow screens: tap to expand the collapsed standings columns document.getElementById('st-toggle').addEventListener('click', () => { const on = document.getElementById('standings').classList.toggle('expanded'); document.getElementById('st-toggle').textContent = on ? 'Standings \u25C2' : 'Standings \u25B8'; }); // ── End-of-race Summary ─────────────────────────────────────────────────────── function showSummary() { summaryShown = true; const entries = []; for (const [num,drv] of Object.entries(raceData.drivers)) { entries.push({num,drv}); } // Sort by: most laps completed first; ties broken by who started their last // lap earliest (= crossed the finish line first = higher finishing position). entries.sort((a,b) => { const la = a.drv.laps, lb = b.drv.laps; const na = la ? la.length : 0, nb = lb ? lb.length : 0; if (nb !== na) return nb - na; // more laps = better const ta = na ? la[na-1] : 0; const tb = nb ? lb[nb-1] : 0; return ta - tb; // earlier last-lap start = better }); const fl = getFastestLap(raceData.t_end); const rows = entries.map((e,i) => { const ret = isRetired(e.drv, raceData.t_end); const flTag = (fl && fl.num===e.num) ? `<span class="sum-fl">FL ${fmtLap(fl.lap)}</span>` : ''; const retTag = ret ? '<span class="sum-ret">RET</span>' : ''; const pits = e.drv.pits ? e.drv.pits.length : 0; return `<div class="sum-row"> <span class="sum-pos">${i+1}</span> <span class="sum-bar" style="background:${ret?'#444':e.drv.color}"></span> <span class="sum-name">${e.drv.name}</span> <span class="sum-extra">${pits} stop${pits!==1?'s':''}</span> ${flTag}${retTag} </div>`; }).join(''); document.getElementById('summary-rows').innerHTML = rows; document.getElementById('summary').classList.add('on'); } document.getElementById('summary-close').addEventListener('click', () => { document.getElementById('summary').classList.remove('on'); }); // ── Help overlay ───────────────────────────────────────────────────────────── function openHelp() { document.getElementById('help').classList.add('on'); } function closeHelp() { document.getElementById('help').classList.remove('on'); } document.getElementById('help-btn').addEventListener('click', openHelp); document.getElementById('help-close').addEventListener('click', closeHelp); document.getElementById('help').addEventListener('click', e => { if (e.target === document.getElementById('help')) closeHelp(); }); // ── Controls ───────────────────────────────────────────────────────────────── document.getElementById('play-btn').addEventListener('click',()=>{ if(!raceData) return; playing=!playing; document.getElementById('play-btn').textContent=playing?'⏸':'β–Ά'; if(playing) lastTs=null; if(!playing) radioAudio.pause(); else if(radioNow) radioAudio.play().catch(()=>{}); }); document.querySelectorAll('.spd').forEach(el=>{ el.addEventListener('click',()=>{ simSpeed=+el.dataset.v; if(simSpeed>RADIO_MAX_SPEED) stopRadio(); document.querySelectorAll('.spd').forEach(e=>e.classList.remove('on')); el.classList.add('on'); }); }); document.getElementById('radio-btn').addEventListener('click', () => { radioOn = !radioOn; document.getElementById('radio-btn').classList.toggle('on', radioOn); if (!radioOn) stopRadio(); }); document.getElementById('strat-btn').addEventListener('click', () => { showStrategy = !showStrategy; document.getElementById('strat-btn').classList.toggle('on', showStrategy); _lastStHtml = ''; // force re-render }); window.addEventListener('keydown',e=>{ if(!raceData) return; if(menuOpen()) { if(e.code==='Escape') closeMenu(); return; } if(e.code==='KeyM') { openMenu(); return; } if(e.code==='Space'){e.preventDefault();document.getElementById('play-btn').click();} else if(e.code==='ArrowRight'){simTime=Math.min(raceData.t_end,simTime+30*simSpeed);resetOvertakes(); resetRadio();} else if(e.code==='ArrowLeft') {simTime=Math.max(raceData.t_start,simTime-30*simSpeed);resetOvertakes(); resetRadio();} else if(e.code==='Escape') { if(document.getElementById('help').classList.contains('on')) closeHelp(); else if(document.getElementById('summary').classList.contains('on')) document.getElementById('summary').classList.remove('on'); else exitFollow(); } else if(e.key>='1'&&e.key<='5') document.querySelectorAll('.spd')[+e.key-1]?.click(); else if(e.code==='KeyS') document.getElementById('strat-btn').click(); else if(e.code==='KeyR') document.getElementById('radio-btn').click(); else if(e.code==='KeyW') document.getElementById('whatif-btn').click(); else if(e.code==='KeyH') document.getElementById('help-btn').click(); }); const scrubber=document.getElementById('scrubber'); let scrubbing=false; function doScrub(e){ if(!raceData) return; const r=scrubber.getBoundingClientRect(); const p=Math.max(0,Math.min(1,(e.clientX-r.left)/r.width)); simTime=raceData.t_start+p*(raceData.t_end-raceData.t_start); resetOvertakes(); resetRadio(); } scrubber.addEventListener('pointerdown',e=>{ scrubbing=true; try { scrubber.setPointerCapture(e.pointerId); } catch (err) { /* window listeners still track the drag */ } doScrub(e); }); window.addEventListener('pointermove',e=>{if(scrubbing)doScrub(e);}); window.addEventListener('pointerup',()=>{scrubbing=false;}); window.addEventListener('pointercancel',()=>{scrubbing=false;}); // ── Data Init ───────────────────────────────────────────────────────────────── function initData(d) { raceData=d; simTime=d.t_start; summaryShown=false; W=canvas.width=window.innerWidth; H=canvas.height=window.innerHeight; buildTrackPath(); buildMiniMap(); initCamera(); resetOvertakes(); resetRadio(); ghost=null; closeWhatif(); _flCache=null; _flCacheT=-1; const SL={R:'Race',Q:'Qualifying',S:'Sprint',FP1:'Practice 1',FP2:'Practice 2',FP3:'Practice 3'}; document.getElementById('race-title').textContent= `${d.year} Β· ${d.grand_prix.replace(/ Grand Prix$/i,'').trim()} Grand Prix Β· ${SL[d.session]||d.session}`; closeMenu(); history.replaceState(null, '', '#' + [d.year, d.grand_prix, d.session].map(encodeURIComponent).join('/')); document.getElementById('summary').classList.remove('on'); playing=true; lastTs=null; document.getElementById('play-btn').textContent='⏸'; } // ── Menu ───────────────────────────────────────────────────────────────────── const selYear = document.getElementById('sel-year'); const selGP = document.getElementById('sel-gp'); const selSession = document.getElementById('sel-session'); const cacheBadge = document.getElementById('cache-badge'); const loadBtn = document.getElementById('load-btn'); const progWrap = document.getElementById('prog-wrap'); const progLog = document.getElementById('prog-log'); const progRing = document.getElementById('prog-ring'); let gpCachedSessions = {}; async function fetchJson(url) { const r = await fetch(url); if (!r.ok) throw new Error(`${r.status} ${r.statusText}`); return r.json(); } { const cur = new Date().getFullYear(); for (let y = cur; y >= 2023; y--) { // OpenF1 data starts in 2023 const o = document.createElement('option'); o.value = o.textContent = y; if (y === 2024) o.selected = true; selYear.appendChild(o); } } async function loadSchedule(year) { selGP.disabled = true; selGP.innerHTML = '<option value="">Loading schedule…</option>'; cacheBadge.className=''; cacheBadge.textContent=''; loadBtn.setAttribute('aria-disabled','true'); gpCachedSessions = {}; try { const r = await fetch(`/api/schedule?year=${year}`); const events = await r.json(); if (events.error) throw new Error(events.error); selGP.innerHTML = ''; events.forEach(ev => { gpCachedSessions[ev.name] = ev.cached_sessions || []; const isCached = gpCachedSessions[ev.name].length > 0; const o = document.createElement('option'); o.value = ev.name; o.textContent = `${isCached ? '● ' : ' '}${String(ev.round).padStart(2,' ')}. ${ev.name}`; selGP.appendChild(o); }); selGP.disabled = false; } catch (e) { selGP.innerHTML = `<option value="">Failed to load β€” check server</option>`; } updateCacheBadge(); } function updateCacheBadge() { const gp = selGP.value, session = selSession.value; if (!gp) { cacheBadge.className=''; cacheBadge.textContent=''; loadBtn.setAttribute('aria-disabled','true'); return; } const cached = (gpCachedSessions[gp] || []).includes(session); cacheBadge.className = cached ? 'cached' : 'uncached'; cacheBadge.textContent = cached ? '● CACHED' : 'β—‹ NOT CACHED β€” WILL DOWNLOAD (~2 MIN)'; loadBtn.setAttribute('aria-disabled','false'); } selYear.addEventListener('change', () => loadSchedule(selYear.value)); selGP.addEventListener('change', updateCacheBadge); selSession.addEventListener('change', updateCacheBadge); loadBtn.addEventListener('click', async () => { if (loadBtn.getAttribute('aria-disabled')==='true') return; const year=selYear.value, gp=selGP.value, session=selSession.value; if (!gp) return; const dataUrl = `/api/data?year=${year}&gp=${encodeURIComponent(gp)}&session=${session}`; const fetchUrl = `/api/fetch?year=${year}&gp=${encodeURIComponent(gp)}&session=${session}`; const cached = (gpCachedSessions[gp] || []).includes(session); if (cached) { loadBtn.setAttribute('aria-disabled','true'); loadBtn.textContent = 'Loading…'; try { const d = await fetchJson(dataUrl); initData(d); } catch(e) { loadBtn.setAttribute('aria-disabled','false'); loadBtn.textContent = 'Load Race'; progLog.textContent = 'Error: ' + e.message; progWrap.style.display = 'flex'; } } else { loadBtn.setAttribute('aria-disabled','true'); loadBtn.textContent = 'Downloading…'; cacheBadge.textContent = ''; progWrap.style.display = 'flex'; progRing.style.display = 'block'; progLog.textContent = ''; const es = new EventSource(fetchUrl); es.onmessage = async ({data}) => { const {msg} = JSON.parse(data); if (msg === '__DONE__') { es.close(); progRing.style.display = 'none'; if (!gpCachedSessions[gp]) gpCachedSessions[gp] = []; if (!gpCachedSessions[gp].includes(session)) gpCachedSessions[gp].push(session); progLog.textContent += '\nLoading simulation data…'; try { const d = await fetchJson(dataUrl); initData(d); } catch(e) { progLog.textContent += '\nFailed to load data: ' + e.message; loadBtn.textContent = 'Retry'; loadBtn.setAttribute('aria-disabled','false'); } } else if (msg.startsWith('__ERROR__:')) { es.close(); progRing.style.display = 'none'; progLog.textContent += '\nError: ' + msg.slice(10); loadBtn.textContent = 'Retry'; loadBtn.setAttribute('aria-disabled','false'); } else { progLog.textContent += msg + '\n'; progWrap.scrollTop = progWrap.scrollHeight; } }; es.onerror = () => { es.close(); progRing.style.display = 'none'; progLog.textContent += '\nConnection error.'; loadBtn.textContent = 'Retry'; loadBtn.setAttribute('aria-disabled','false'); }; } }); // ── Race switcher ──────────────────────────────────────────────────────────── const menuEl = document.getElementById('menu'); function menuOpen() { return menuEl.style.display !== 'none'; } function closeMenu() { menuEl.style.display = 'none'; } async function openMenu() { if (playing) document.getElementById('play-btn').click(); stopRadio(); closeWhatif(); menuEl.style.display = 'flex'; window.startMenuBg(); document.getElementById('menu-back').style.display = raceData ? 'block' : 'none'; progWrap.style.display = 'none'; progLog.textContent = ''; loadBtn.textContent = 'Load Race'; if (raceData) { selYear.value = raceData.year; await loadSchedule(raceData.year); selGP.value = raceData.grand_prix; selSession.value = raceData.session; updateCacheBadge(); } refreshInstant(); } async function refreshInstant() { let races = []; try { races = await fetchJson('/api/cached'); } catch (e) { /* no list: hide the section */ } const box = document.getElementById('instant'), list = document.getElementById('instant-list'); const SN = {R:'Race', Q:'Quali', S:'Sprint', FP1:'FP1', FP2:'FP2', FP3:'FP3'}; list.replaceChildren(...races.map(r => { const c = document.createElement('div'); c.className = 'chip'; if (raceData && r.year === raceData.year && r.gp === raceData.grand_prix && r.session === raceData.session) c.classList.add('current'); c.textContent = `${r.year} ${r.gp.replace(/ Grand Prix$/, '')} Β· ${SN[r.session] || r.session}`; c.addEventListener('click', () => loadByName(r.year, r.gp, r.session)); return c; })); box.style.display = races.length ? 'flex' : 'none'; } // Select a session in the menu and load it (instant if downloaded, else downloads) async function loadByName(year, gp, session) { selYear.value = year; await loadSchedule(year); if (![...selGP.options].some(o => o.value === gp)) return; selGP.value = gp; selSession.value = session; updateCacheBadge(); loadBtn.click(); } document.getElementById('race-title').addEventListener('click', openMenu); document.getElementById('races-btn').addEventListener('click', openMenu); document.getElementById('menu-back').addEventListener('click', closeMenu); // Shareable links: #2024/Bahrain%20Grand%20Prix/R const linked = location.hash.slice(1).split('/').map(decodeURIComponent); if (linked.length === 3 && +linked[0] >= 2023) { loadByName(+linked[0], linked[1], linked[2]); } else { loadSchedule(selYear.value); } refreshInstant(); W=canvas.width=window.innerWidth; H=canvas.height=window.innerHeight; requestAnimationFrame(frame); </script> </body> </html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>F1 Simulator</title>
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #04040a;
color: #fff;
font-family: -apple-system, 'SF Pro Display', BlinkMacSystemFont, 'Segoe UI', sans-serif;
overflow: hidden;
width: 100vw;
height: 100vh;
user-select: none;
-webkit-user-select: none;
}
#canvas {
position: fixed;
inset: 0;
display: block;
cursor: default;
}
/* ── Menu ────────────────────────────────────────────────────────────── */
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes cardIn { from { opacity:0; transform: translateY(28px) scale(0.97); } to { opacity:1; transform: translateY(0) scale(1); } }
@keyframes titleIn { from { opacity:0; transform: translateY(-12px); } to { opacity:1; transform: translateY(0); } }
@keyframes accentSlide { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@keyframes fadeUp { from { opacity:0; transform: translateY(10px); } to { opacity:1; transform: translateY(0); } }
@keyframes bgPulse { 0%,100% { opacity:.35; } 50% { opacity:.55; } }
@keyframes streakMove { from { transform: translateX(-100%) rotate(-18deg); } to { transform: translateX(220%) rotate(-18deg); } }
@keyframes glowPulse { 0%,100% { box-shadow: 0 0 40px rgba(225,6,0,.18), 0 0 80px rgba(225,6,0,.06); } 50% { box-shadow: 0 0 60px rgba(225,6,0,.28), 0 0 120px rgba(225,6,0,.10); } }
#menu {
position: fixed; inset: 0;
background: #04040a;
display: flex; align-items: center; justify-content: center;
z-index: 300;
overflow: hidden;
}
/* Particle / speed-lines canvas behind the card */
#menu-bg-canvas {
position: absolute; inset: 0;
width: 100%; height: 100%;
pointer-events: none;
}
/* Radial red glow centred on card */
#menu-glow {
position: absolute;
width: 700px; height: 700px;
border-radius: 50%;
background: radial-gradient(circle, rgba(225,6,0,.13) 0%, transparent 70%);
pointer-events: none;
animation: bgPulse 4s ease-in-out infinite;
}
/* Moving light streaks */
.menu-streak {
position: absolute;
top: 0; left: 0;
width: 180px; height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,.025), transparent);
transform: rotate(-18deg);
pointer-events: none;
animation: streakMove linear infinite;
}
.menu-streak:nth-child(1) { animation-duration: 7s; animation-delay: 0s; }
.menu-streak:nth-child(2) { animation-duration: 11s; animation-delay: 3s; width: 90px; }
.menu-streak:nth-child(3) { animation-duration: 9s; animation-delay: 6s; width: 260px; opacity:.6; }
#menu-card {
position: relative;
width: 380px;
border-radius: 18px;
background: rgba(255,255,255,0.038);
border: 1px solid rgba(255,255,255,0.09);
padding: 40px 34px 34px;
display: flex; flex-direction: column; gap: 18px;
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
animation: cardIn .55s cubic-bezier(.22,.9,.36,1) both, glowPulse 5s ease-in-out infinite;
}
/* Top red accent line */
#menu-card::before {
content: '';
position: absolute;
top: 0; left: 10%; right: 10%;
height: 2px;
border-radius: 0 0 2px 2px;
background: linear-gradient(90deg, transparent, #e10600, rgba(255,120,80,.8), #e10600, transparent);
animation: accentSlide .7s .25s cubic-bezier(.22,.9,.36,1) both;
transform-origin: center;
}