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 (
);
}
function LandingView({ matchId, onMatchIdChange, onSimulate, onExampleClick, error, isLoading, fetchError }) {
return (
Post-Match Analyzer
Rift Breakdown
Analyze a finished match and inspect how the win probability evolved through each key moment.
Region available: {SUPPORTED_REGION}
{API_ROUTING_LABEL}
Player-friendly insights
Timeline breakdown
{/* ── ML Models badge ── */}
Powered by
{MODEL_META.map((model) => (
{model.label}
))}
{/* ── Example match IDs ── */}
Try an example:
{EXAMPLE_MATCH_IDS.map((id) => (
))}
);
}
function ModelCard({ model, value }) {
const redValue = 100 - value;
return (
{model.short}
{model.label}
Blue: {value.toFixed(1)}%
Red: {redValue.toFixed(1)}%
);
}
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 (
{axisMarks.map((mark) => (
{mark}m
))}
);
}
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 (
Match Analysis — Blue vs Red Win Probability
{MODEL_META.map((model) => (
))}
Timeline & Playback
Match Flow
Minute {String(minute).padStart(2, '0')}
onSetMinute(Number(event.target.value))}
/>
{activeFeedEvents.length === 0 ? (
No active events yet. Press play or move the slider forward.
) : (
activeFeedEvents.map((event, index) => (
))
)}
Key Turning Points
Event Matrix
{EVENT_FILTERS.map((filter) => (
))}
{matrixEvents.map((event, index) => {
const isActive = minute >= event.minute;
return (
);
})}
);
}
// ─── 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 (
<>
{view === 'landing' ? (
) : (
setIsPlaying((current) => !current)}
onSetMinute={(minute) => {
setTimeMin(minute);
setIsPlaying(false);
}}
onBack={handleBackToLanding}
blueWin={matchData?.blue_win ?? null}
/>
)}
>
);
}
export default App;