Spaces:
Sleeping
Sleeping
| import { useEffect, useMemo, useRef, useState } from 'react'; | |
| import './App.css'; | |
| const MATCH_DURATION = 70; | |
| const SUPPORTED_REGION = 'US only'; | |
| const API_ROUTING_LABEL = 'NA1 platform + AMERICAS match routing'; | |
| // βββ Input sanitization βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Accepted format: one or more uppercase letters/digits, an underscore, then | |
| // one or more digits only. Examples: NA1_1234567890 EUW1_9876543210 | |
| const MATCH_ID_REGEX = /^[A-Z0-9]+_\d+$/; | |
| const MATCH_ID_MAX_LEN = 30; | |
| const sanitizeMatchId = (raw) => | |
| raw | |
| .toUpperCase() | |
| .replace(/[^A-Z0-9_]/g, '') | |
| .replace(/_{2,}/g, '_') | |
| .slice(0, MATCH_ID_MAX_LEN); | |
| // βββ Constants ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const MODEL_META = [ | |
| { key: 'xgboost', label: 'XGBoost', short: 'Teamfight Pattern', colorClass: 'xgboost' }, | |
| { key: 'lstm', label: 'LSTM', short: 'Momentum Curve', colorClass: 'lstm' }, | |
| { key: 'logreg', label: 'Logistic Regression', short: 'Stability Baseline', colorClass: 'logistic' }, | |
| ]; | |
| const EXAMPLE_MATCH_IDS = ['NA1_5498339609', 'NA1_5498663444', 'NA1_5504289306']; | |
| const EVENT_FILTERS = ['all', 'kills', 'objectives', 'structures']; | |
| const EVENT_TYPE_COLORS = { | |
| all: '#c6a769', | |
| kills: '#ff5f7a', | |
| objectives: '#19d7ff', | |
| structures: '#f0932b', | |
| }; | |
| // βββ Utilities ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // eslint-disable-next-line no-unused-vars | |
| const clamp = (value, min, max) => Math.min(Math.max(value, min), max); | |
| // βββ Components βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function AnimatedBackground() { | |
| return ( | |
| <div className="background-container" aria-hidden="true"> | |
| <div className="bg-grid" /> | |
| <div className="bg-noise" /> | |
| <div className="bg-scanline" /> | |
| <div className="bg-wave" /> | |
| <div className="bg-orb orb-left" /> | |
| <div className="bg-orb orb-right" /> | |
| <div className="bg-vignette" /> | |
| </div> | |
| ); | |
| } | |
| function LandingView({ matchId, onMatchIdChange, onSimulate, onExampleClick, error, isLoading, fetchError }) { | |
| return ( | |
| <section className="view-shell landing-shell"> | |
| <article className="landing-card rise"> | |
| <p className="eyebrow centered">Post-Match Analyzer</p> | |
| <h1 className="landing-title centered">Rift Breakdown</h1> | |
| <p className="landing-subtitle"> | |
| Analyze a finished match and inspect how the win probability evolved through each key moment. | |
| </p> | |
| <div className="landing-metrics" role="presentation"> | |
| <span>Region available: {SUPPORTED_REGION}</span> | |
| <span>{API_ROUTING_LABEL}</span> | |
| <span>Player-friendly insights</span> | |
| <span>Timeline breakdown</span> | |
| </div> | |
| {/* ββ ML Models badge ββ */} | |
| <div className="ml-models-row" role="presentation" aria-label="ML models used"> | |
| <span className="ml-models-label">Powered by</span> | |
| {MODEL_META.map((model) => ( | |
| <span key={model.key} className={`ml-model-badge ${model.colorClass}`}> | |
| {model.label} | |
| </span> | |
| ))} | |
| </div> | |
| <form className="match-form" onSubmit={onSimulate}> | |
| <label htmlFor="match-id" className="input-label"> | |
| Match ID | |
| </label> | |
| <div className={`input-row ${error ? 'has-error' : ''}`}> | |
| <input | |
| id="match-id" | |
| type="text" | |
| className="match-input" | |
| placeholder="NA1_1234567890" | |
| value={matchId} | |
| onChange={(event) => onMatchIdChange(sanitizeMatchId(event.target.value))} | |
| autoComplete="off" | |
| spellCheck="false" | |
| inputMode="text" | |
| aria-invalid={error ? 'true' : 'false'} | |
| aria-describedby="match-help" | |
| disabled={isLoading} | |
| /> | |
| <button type="submit" className="primary-btn" disabled={isLoading}> | |
| {isLoading ? 'Analyzing...' : 'Analyze Match'} | |
| </button> | |
| </div> | |
| <p | |
| id="match-help" | |
| className={`helper-text ${error || fetchError ? 'error' : ''}`} | |
| > | |
| {error | |
| ? 'Match ID must follow the format REGION_DIGITS β e.g. NA1_1234567890.' | |
| : fetchError | |
| ? fetchError | |
| : 'Use a completed US match ID (NA routing only). Format: NA1_1234567890.'} | |
| </p> | |
| </form> | |
| {/* ββ Example match IDs ββ */} | |
| <div className="example-ids-row"> | |
| <span className="example-ids-label">Try an example:</span> | |
| {EXAMPLE_MATCH_IDS.map((id) => ( | |
| <button | |
| key={id} | |
| type="button" | |
| className={`example-id-chip ${matchId === id ? 'active' : ''}`} | |
| onClick={() => onExampleClick(id)} | |
| disabled={isLoading} | |
| > | |
| {id} | |
| </button> | |
| ))} | |
| </div> | |
| </article> | |
| </section> | |
| ); | |
| } | |
| function ModelCard({ model, value }) { | |
| const redValue = 100 - value; | |
| return ( | |
| <article className={`model-card ${model.colorClass}`}> | |
| <p className="model-kicker">{model.short}</p> | |
| <h3>{model.label}</h3> | |
| <div className="model-dual-values"> | |
| <p className="model-value blue">Blue: {value.toFixed(1)}%</p> | |
| <p className="model-value red">Red: {redValue.toFixed(1)}%</p> | |
| </div> | |
| <div className="model-track" aria-hidden="true"> | |
| <div className="model-fill" style={{ width: `${value}%` }} /> | |
| </div> | |
| </article> | |
| ); | |
| } | |
| function ProbabilityChart({ history, minute, selectedFilter, events }) { | |
| const chartWidth = 760; | |
| const chartHeight = 220; | |
| const maxIndex = history.length - 1; | |
| const toX = (index) => (index / maxIndex) * chartWidth; | |
| const toY = (value) => chartHeight - (value / 100) * chartHeight; | |
| const buildPath = (modelKey) => | |
| history | |
| .map((entry, index) => `${index === 0 ? 'M' : 'L'} ${toX(index)} ${toY(entry[modelKey])}`) | |
| .join(' '); | |
| const indicatorX = toX(minute); | |
| const highlightedEvents = events.filter( | |
| (event) => | |
| (selectedFilter === 'all' || event.type === selectedFilter) && | |
| event.minute <= minute | |
| ); | |
| const maxMark = history.length > 0 ? history[history.length - 1].minute : MATCH_DURATION; | |
| const axisMarks = [0, Math.round(maxMark / 3), Math.round((maxMark * 2) / 3), maxMark]; | |
| return ( | |
| <section className="chart-wrap"> | |
| <svg | |
| className="probability-chart" | |
| viewBox={`0 0 ${chartWidth} ${chartHeight}`} | |
| preserveAspectRatio="none" | |
| role="img" | |
| aria-label="Win probability chart by minute" | |
| > | |
| <path d={buildPath('xgboost')} className="chart-line xgboost" /> | |
| <path d={buildPath('lstm')} className="chart-line lstm" /> | |
| <path d={buildPath('logreg')} className="chart-line logistic" /> | |
| {highlightedEvents.map((event, index) => ( | |
| <line | |
| key={`chart-event-${index}`} | |
| x1={toX(event.minute)} y1="0" | |
| x2={toX(event.minute)} y2={chartHeight} | |
| className={`chart-event-line ${event.type}`} | |
| /> | |
| ))} | |
| <line | |
| x1={indicatorX} y1="0" | |
| x2={indicatorX} y2={chartHeight} | |
| className="chart-indicator" | |
| /> | |
| </svg> | |
| <div className="chart-axis"> | |
| {axisMarks.map((mark) => ( | |
| <span key={mark}>{mark}m</span> | |
| ))} | |
| </div> | |
| </section> | |
| ); | |
| } | |
| function DashboardView({ | |
| matchId, | |
| minute, | |
| maxDuration, | |
| probabilities, | |
| history, | |
| events, | |
| isPlaying, | |
| selectedFilter, | |
| onFilterChange, | |
| onTogglePlayback, | |
| onSetMinute, | |
| onBack, | |
| blueWin, | |
| }) { | |
| const feedScrollRef = useRef(null); | |
| const previousActiveCountRef = useRef(0); | |
| const visibleEvents = events.filter((event) => selectedFilter === 'all' || event.type === selectedFilter); | |
| const activeFeedEvents = visibleEvents | |
| .filter((event) => event.minute <= minute) | |
| .sort((a, b) => b.minute - a.minute); | |
| const matrixEvents = [...visibleEvents].sort((a, b) => a.minute - b.minute); | |
| useEffect(() => { | |
| if (!feedScrollRef.current) return; | |
| if (activeFeedEvents.length > previousActiveCountRef.current) { | |
| feedScrollRef.current.scrollTo({ top: 0, behavior: 'smooth' }); | |
| } | |
| previousActiveCountRef.current = activeFeedEvents.length; | |
| }, [activeFeedEvents.length, minute, selectedFilter]); | |
| return ( | |
| <section className="view-shell dashboard-shell rise"> | |
| <header className="dashboard-header"> | |
| <div className="header-left"> | |
| <button type="button" className="ghost-btn" onClick={onBack}> | |
| Back to Landing | |
| </button> | |
| <p className="target-match"> | |
| Reviewing <strong>{matchId}</strong> | |
| </p> | |
| </div> | |
| {blueWin !== null && ( | |
| <div className={`winner-badge ${blueWin ? 'blue-win' : 'red-win'}`}> | |
| <span className="winner-label">Match Winner</span> | |
| <span className="winner-name">{blueWin ? 'Blue Team' : 'Red Team'}</span> | |
| </div> | |
| )} | |
| </header> | |
| <main className="dashboard-grid"> | |
| <section className="panel primary-panel probability-panel"> | |
| <h2>Match Analysis β Blue vs Red Win Probability</h2> | |
| </section> | |
| <div className="models-grid models-grid-above"> | |
| {MODEL_META.map((model) => ( | |
| <ModelCard key={model.key} model={model} value={probabilities[model.key]} /> | |
| ))} | |
| </div> | |
| <section className="panel timeline-panel"> | |
| <p className="panel-kicker">Timeline & Playback</p> | |
| <h3>Match Flow</h3> | |
| <div className="status-row"> | |
| <span className="live-dot" /> | |
| <span>Minute {String(minute).padStart(2, '0')}</span> | |
| </div> | |
| <div className="analysis-controls"> | |
| <button type="button" className="ghost-btn playback-btn" onClick={onTogglePlayback}> | |
| {isPlaying ? 'Pause' : 'Play'} Timeline | |
| </button> | |
| <label htmlFor="minute-range" className="slider-label"> | |
| Time Window: {minute}m | |
| </label> | |
| <input | |
| id="minute-range" | |
| className="minute-slider" | |
| type="range" | |
| min="0" | |
| max={maxDuration} | |
| value={minute} | |
| onChange={(event) => onSetMinute(Number(event.target.value))} | |
| /> | |
| </div> | |
| <ProbabilityChart | |
| history={history} | |
| minute={minute} | |
| selectedFilter={selectedFilter} | |
| events={events} | |
| /> | |
| <div className="timeline-feed-scroll" ref={feedScrollRef}> | |
| <div className="timeline-list"> | |
| {activeFeedEvents.length === 0 ? ( | |
| <p className="feed-placeholder"> | |
| No active events yet. Press play or move the slider forward. | |
| </p> | |
| ) : ( | |
| activeFeedEvents.map((event, index) => ( | |
| <button | |
| key={`feed-${index}`} | |
| type="button" | |
| className={`timeline-item active ${index === 0 ? 'latest-item' : ''}`} | |
| onClick={() => onSetMinute(event.minute)} | |
| > | |
| <p className="timeline-clock">{event.clock}</p> | |
| <p className="timeline-text"> | |
| <span className={event.team === 'Blue' ? 'team-blue' : 'team-red'}> | |
| {event.team} | |
| </span>{' '} | |
| {event.text} | |
| </p> | |
| <span className="event-type-tag">{event.type}</span> | |
| </button> | |
| )) | |
| )} | |
| </div> | |
| </div> | |
| </section> | |
| <section className="panel turning-panel"> | |
| <p className="panel-kicker">Key Turning Points</p> | |
| <h3>Event Matrix</h3> | |
| <div className="filter-row" role="tablist" aria-label="Filter event types"> | |
| {EVENT_FILTERS.map((filter) => ( | |
| <button | |
| key={filter} | |
| type="button" | |
| className={`filter-chip ${selectedFilter === filter ? 'active' : ''}`} | |
| onClick={() => onFilterChange(filter)} | |
| style={ | |
| selectedFilter === filter | |
| ? { borderColor: EVENT_TYPE_COLORS[filter], color: EVENT_TYPE_COLORS[filter] } | |
| : undefined | |
| } | |
| > | |
| {filter} | |
| </button> | |
| ))} | |
| </div> | |
| <div className="turning-grid" aria-live="polite"> | |
| {matrixEvents.map((event, index) => { | |
| const isActive = minute >= event.minute; | |
| return ( | |
| <button | |
| key={`matrix-${index}`} | |
| type="button" | |
| className={`turning-card ${isActive ? 'active' : ''}`} | |
| onClick={() => onSetMinute(event.minute)} | |
| > | |
| <p className="timeline-clock">{event.clock}</p> | |
| <p className="timeline-text"> | |
| <span className={event.team === 'Blue' ? 'team-blue' : 'team-red'}> | |
| {event.team} | |
| </span>{' '} | |
| {event.text} | |
| </p> | |
| <span className="event-type-tag">{event.type}</span> | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| </section> | |
| </main> | |
| </section> | |
| ); | |
| } | |
| // βββ Root βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function App() { | |
| const [view, setView] = useState('landing'); | |
| const [matchId, setMatchId] = useState(''); | |
| const [showInputError, setShowInputError] = useState(false); | |
| const [isLoading, setIsLoading] = useState(false); | |
| const [fetchError, setFetchError] = useState(null); | |
| const [matchData, setMatchData] = useState(null); | |
| const [timeMin, setTimeMin] = useState(0); | |
| const [isPlaying, setIsPlaying] = useState(false); | |
| const [selectedFilter, setSelectedFilter] = useState('all'); | |
| // ββ Derived data ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const history = useMemo(() => { | |
| if (!matchData) return []; | |
| const { minutes, predictions } = matchData; | |
| return minutes.map((minute, i) => { | |
| const getProb = (key) => { | |
| const raw = predictions[key]; | |
| if (raw === undefined || raw === null) return 50; | |
| if (!Array.isArray(raw)) return raw <= 1.0 ? raw * 100 : raw; | |
| if (raw.length === 0) return 50; | |
| const val = raw[i] !== undefined ? raw[i] : raw[raw.length - 1]; | |
| return val <= 1.0 ? val * 100 : val; | |
| }; | |
| return { | |
| minute, | |
| xgboost: getProb('xgboost'), | |
| lstm: getProb('lstm'), | |
| logreg: getProb('logreg'), | |
| }; | |
| }); | |
| }, [matchData]); | |
| const maxDuration = history.length > 0 ? history[history.length - 1].minute : MATCH_DURATION; | |
| const probabilities = useMemo(() => { | |
| if (!history.length || timeMin === 0) return { xgboost: 0, lstm: 0, logreg: 0 }; | |
| const entry = history.find((h) => h.minute === timeMin) || history[history.length - 1]; | |
| return { xgboost: entry.xgboost, lstm: entry.lstm, logreg: entry.logreg }; | |
| }, [history, timeMin]); | |
| const events = useMemo(() => matchData?.events || [], [matchData]); | |
| // ββ Playback ticker βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| useEffect(() => { | |
| if (!isPlaying || view !== 'dashboard') return undefined; | |
| const interval = setInterval(() => { | |
| setTimeMin((prev) => { | |
| if (prev >= maxDuration) { | |
| setIsPlaying(false); | |
| return prev; | |
| } | |
| return prev + 1; | |
| }); | |
| }, 1200); | |
| return () => clearInterval(interval); | |
| }, [isPlaying, view, maxDuration]); | |
| // ββ Handlers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const handleExampleClick = (id) => { | |
| setMatchId(id); | |
| setShowInputError(false); | |
| setFetchError(null); | |
| }; | |
| const handleMatchIdChange = (value) => { | |
| setMatchId(value); | |
| // Clear the validation error as soon as the input becomes valid | |
| if (showInputError && MATCH_ID_REGEX.test(value)) { | |
| setShowInputError(false); | |
| } | |
| }; | |
| const handleSimulate = async (event) => { | |
| event.preventDefault(); | |
| const value = matchId.trim(); | |
| // Validate against the strict regex β not just "non-empty" | |
| if (!value || !MATCH_ID_REGEX.test(value)) { | |
| setShowInputError(true); | |
| return; | |
| } | |
| setShowInputError(false); | |
| setIsLoading(true); | |
| setFetchError(null); | |
| try { | |
| const isDev = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; | |
| const apiUrl = import.meta.env.VITE_API_URL || (isDev ? 'http://localhost:8000' : ''); | |
| const response = await fetch(`${apiUrl}/api/v1/predict/${value}`, { | |
| method: 'POST', | |
| headers: { | |
| 'Accept': 'application/json' | |
| } | |
| }); | |
| if (!response.ok) { | |
| const errorData = await response.json().catch(() => ({})); | |
| throw new Error(errorData.detail || `Server error: ${response.status}`); | |
| } | |
| const data = await response.json(); | |
| setMatchData(data); | |
| setView('dashboard'); | |
| setTimeMin(0); | |
| setIsPlaying(true); | |
| } catch (err) { | |
| setFetchError(err.message); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| const handleBackToLanding = () => { | |
| setView('landing'); | |
| setIsPlaying(false); | |
| }; | |
| // ββ Render ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| return ( | |
| <> | |
| <AnimatedBackground /> | |
| {view === 'landing' ? ( | |
| <LandingView | |
| matchId={matchId} | |
| onMatchIdChange={handleMatchIdChange} | |
| onSimulate={handleSimulate} | |
| onExampleClick={handleExampleClick} | |
| error={showInputError} | |
| isLoading={isLoading} | |
| fetchError={fetchError} | |
| /> | |
| ) : ( | |
| <DashboardView | |
| matchId={matchId} | |
| minute={timeMin} | |
| maxDuration={maxDuration} | |
| probabilities={probabilities} | |
| history={history} | |
| events={events} | |
| isPlaying={isPlaying} | |
| selectedFilter={selectedFilter} | |
| onFilterChange={setSelectedFilter} | |
| onTogglePlayback={() => setIsPlaying((current) => !current)} | |
| onSetMinute={(minute) => { | |
| setTimeMin(minute); | |
| setIsPlaying(false); | |
| }} | |
| onBack={handleBackToLanding} | |
| blueWin={matchData?.blue_win ?? null} | |
| /> | |
| )} | |
| <footer className="riot-disclaimer"> | |
| Rift Breakdown isn't endorsed by Riot Games and doesn't reflect the views or opinions | |
| of Riot Games or anyone officially involved in producing or managing Riot Games properties. | |
| Riot Games, and all associated properties are trademarks or registered trademarks of Riot Games, Inc. | |
| </footer> | |
| </> | |
| ); | |
| } | |
| export default App; |