beta3's picture
Add examples
2eb6221 verified
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&#39;t endorsed by Riot Games and doesn&#39;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;