| <!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; |
| } |
| |
| |
| @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; |
| } |
| |
| |
| #menu-bg-canvas { |
| position: absolute; inset: 0; |
| width: 100%; height: 100%; |
| pointer-events: none; |
| } |
| |
| |
| #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; |
| } |
| |
| |
| .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; |
| } |
| |
| |
| #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 { |
| position: fixed; |
| inset: 0; |
| pointer-events: none; |
| } |
| |
| |
| #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; |
| } |
| #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 { |
| 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; } |
| #follow-x { margin-left: 2px; color: rgba(255,255,255,0.2); } |
| |
| |
| #standings { |
| position: absolute; |
| right: 16px; |
| top: 50%; |
| transform: translateY(-50%); |
| display: flex; |
| flex-direction: column; |
| gap: 2px; |
| max-height: 82vh; |
| overflow-y: auto; |
| overflow-x: visible; |
| pointer-events: all; |
| scrollbar-width: none; |
| } |
| #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; } |
| |
| |
| #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; |
| } |
| @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 { |
| font-size: 9px; letter-spacing: 3px; text-transform: uppercase; |
| color: rgba(255,255,255,0.22); margin-top: 3px; |
| } |
| |
| |
| #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; } |
| |
| |
| #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; } |
| |
| |
| #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; } |
| |
| |
| #strat-btn { |
| position: fixed; |
| bottom: 26px; left: 16px; |
| 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; |
| } |
| #strat-btn:hover { color: rgba(255,255,255,0.7); background: rgba(255,255,255,0.07); } |
| #strat-btn.on { color: white; background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.2); } |
| |
| |
| .strat-strip { |
| position: relative; |
| display: flex; height: 4px; border-radius: 2px; overflow: visible; margin-top: 2px; width: 100%; |
| } |
| .strat-seg { height: 100%; } |
| |
| .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; |
| } |
| |
| |
| #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-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 { |
| 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; |
| } |
| #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; } |
| |
| |
| #tyre-legend { |
| position: absolute; |
| bottom: 78px; right: 16px; |
| display: flex; flex-direction: column; gap: 3px; |
| 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 { |
| 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); |
| } |
| </style> |
| </head> |
| <body> |
|
|
| |
| <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-logo"> |
| <span id="menu-logo-f1">F1</span> |
| <span id="menu-logo-sub">Race Simulator</span> |
| </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> |
| </div> |
| </div> |
|
|
| <canvas id="canvas"></canvas> |
|
|
| |
| <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> |
|
|
| |
| <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="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="strat-btn" role="button">Strategy</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> |
|
|
| |
| <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> |
| </div> |
| </div> |
|
|
| |
| <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">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> 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 & 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 & 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 id="help-close" role="button">Got it</div> |
| </div> |
| </div> |
|
|
|
|
|
|
| <script> |
| 'use strict'; |
| |
| |
| (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); |
| |
| |
| for (let i = 0; i < DOTS; i++) { |
| dots.push({ |
| x: Math.random() * 2, |
| 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, |
| |
| red: Math.random() < 0.18, |
| }); |
| } |
| |
| function drawMenu() { |
| if (!document.getElementById('menu') || |
| document.getElementById('menu').style.display === 'none') return; |
| mctx.clearRect(0, 0, mW, mH); |
| |
| const now = performance.now() * 0.001; |
| |
| |
| 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(); |
| |
| |
| 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(); |
| } |
| |
| |
| 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(); |
| })(); |
| |
| |
| 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); |
| |
| |
| let raceData = null; |
| let simTime = 0; |
| let playing = false; |
| let simSpeed = 5; |
| let lastTs = null; |
| let trackP2D = null; |
| let pitP2D = []; |
| let showStrategy = false; |
| let summaryShown = false; |
| |
| |
| 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 }; |
| } |
| |
| |
| 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; |
| } |
| |
| |
| |
| 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; |
| } |
| |
| |
| |
| 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')}`; |
| } |
| |
| |
| 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; |
| } |
| |
| |
| 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; |
| } |
| } |
| |
| |
| |
| 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); } |
| |
| |
| 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(); |
| |
| |
| |
| |
| pitP2D = []; |
| const pitSrc = raceData.pit_lane; |
| 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 { |
| |
| 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); |
| } |
| } |
| } |
| |
| |
| 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(); |
| |
| |
| 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 { |
| |
| 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); |
| |
| mmCtx.strokeStyle = 'rgba(255,255,255,0.18)'; mmCtx.lineWidth = 2; |
| mmCtx.stroke(mmPath); |
| |
| mmCtx.lineCap = 'round'; |
| for (const mp of mmPitPaths) { |
| mmCtx.strokeStyle = 'rgba(255,165,0,0.55)'; mmCtx.lineWidth = 2; |
| mmCtx.stroke(mp); |
| } |
| |
| 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(); |
| } |
| |
| 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(); |
| } |
| } |
| } |
| |
| |
| 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; } |
| |
| |
| function raceProgress(drv,t) { |
| const pos = interp(drv.positions,t); |
| return pos ? pos.d : 0; |
| } |
| |
| |
| 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; |
| } |
| |
| |
| 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); |
| } |
| |
| |
| 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); |
| |
| |
| 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(); |
| } |
| |
| |
| 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; |
| |
| 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; |
| } |
| } |
| |
| 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; |
| |
| 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; |
| |
| 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; |
| } |
| |
| 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(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'; |
| } |
| |
| |
| 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(); |
| } |
| } |
| |
| |
| 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)); |
| |
| |
| updateCamera(dt); |
| |
| ctx.fillStyle='#04040a'; ctx.fillRect(0,0,W,H); |
| drawTrack(); |
| drawCars(entries,ts); |
| |
| if (cam.follow) { |
| document.getElementById('minimap').classList.add('on'); |
| drawMiniMap(entries); |
| } else { |
| document.getElementById('minimap').classList.remove('on'); |
| } |
| |
| checkOvertakes(entries,ts); |
| updateHUD(entries); |
| } |
| |
| |
| 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)+'%'; |
| |
| |
| 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}`; |
| } |
| |
| |
| 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=''; } |
| |
| |
| 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'); |
| } |
| |
| |
| 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; |
| } |
| } |
| |
| |
| function buildStrategyBar(drv) { |
| if (!drv.tyres || drv.tyres.length === 0) return ''; |
| const tEnd = raceData.t_end, tStart = raceData.t_start; |
| const total = tEnd - tStart; |
| |
| 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>`; |
| } |
| |
| 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>`; |
| } |
| |
| |
| 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; |
| 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; |
| |
| 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(); |
| } |
| }); |
| |
| canvas.addEventListener('wheel',e=>{ |
| if(!raceData) return; |
| e.preventDefault(); |
| const zd=e.deltaY<0?1.15:1/1.15; |
| if(cam.follow){ |
| cam.followZoom=Math.max(1.5,Math.min(20,cam.followZoom*zd)); |
| } else { |
| const wx=(e.clientX-W*0.5)/cam.tz+cam.tx; |
| const wy=-(e.clientY-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-(e.clientX-W*0.5)/cam.tz; |
| cam.ty=wy+(e.clientY-H*0.5)/cam.tz; |
| } |
| },{passive:false}); |
| |
| |
| function showSummary() { |
| summaryShown = true; |
| const entries = []; |
| for (const [num,drv] of Object.entries(raceData.drivers)) { |
| entries.push({num,drv}); |
| } |
| |
| |
| 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; |
| const ta = na ? la[na-1] : 0; |
| const tb = nb ? lb[nb-1] : 0; |
| return ta - tb; |
| }); |
| 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'); |
| }); |
| |
| |
| 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(); |
| }); |
| |
| |
| document.getElementById('play-btn').addEventListener('click',()=>{ |
| if(!raceData) return; |
| playing=!playing; |
| document.getElementById('play-btn').textContent=playing?'⏸':'▶'; |
| if(playing) lastTs=null; |
| }); |
| |
| document.querySelectorAll('.spd').forEach(el=>{ |
| el.addEventListener('click',()=>{ |
| simSpeed=+el.dataset.v; |
| document.querySelectorAll('.spd').forEach(e=>e.classList.remove('on')); |
| el.classList.add('on'); |
| }); |
| }); |
| |
| document.getElementById('strat-btn').addEventListener('click', () => { |
| showStrategy = !showStrategy; |
| document.getElementById('strat-btn').classList.toggle('on', showStrategy); |
| _lastStHtml = ''; |
| }); |
| |
| window.addEventListener('keydown',e=>{ |
| if(!raceData) 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();} |
| else if(e.code==='ArrowLeft') {simTime=Math.max(raceData.t_start,simTime-30*simSpeed);resetOvertakes();} |
| 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==='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(); |
| } |
| scrubber.addEventListener('mousedown',e=>{scrubbing=true;doScrub(e);}); |
| window.addEventListener('mousemove',e=>{if(scrubbing)doScrub(e);}); |
| window.addEventListener('mouseup',()=>{scrubbing=false;}); |
| |
| |
| 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(); |
| _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}`; |
| document.getElementById('menu').style.display='none'; |
| document.getElementById('summary').classList.remove('on'); |
| playing=true; lastTs=null; |
| document.getElementById('play-btn').textContent='⏸'; |
| } |
| |
| |
| 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 = {}; |
| |
| { |
| const cur = new Date().getFullYear(); |
| for (let y = cur; y >= 2018; y--) { |
| 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 fetch(dataUrl).then(r=>r.json()); |
| 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 fetch(dataUrl).then(r=>r.json()); |
| 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'); |
| }; |
| } |
| }); |
| |
| loadSchedule(selYear.value); |
| |
| W=canvas.width=window.innerWidth; |
| H=canvas.height=window.innerHeight; |
| requestAnimationFrame(frame); |
| </script> |
| </body> |
| </html> |
|
|