// ranking.jsx — Página dedicada del RANKING (podio + movimientos + tabla completa) // Autónoma: re-implementa los mismos cálculos que el index para no depender de app.jsx const { useState, useEffect, useRef } = React; // ---------- Directorio de pilotos ---------- const RK_DIR = (typeof window !== "undefined" && window.CDG_PILOTS) ? window.CDG_PILOTS : {}; const _RK_NORM = Object.fromEntries(Object.entries(RK_DIR).map(([k, v]) => [k.toLowerCase().trim(), v])); function pilotMeta(name) { if (!name) return {}; return _RK_NORM[name.toLowerCase().trim()] || {}; } function flagUrl(code) { return (!code || code.length !== 2) ? "" : `https://flagcdn.com/${code.toLowerCase()}.svg`; } function pilotHref(name, year, cat) { return `piloto.html?name=${encodeURIComponent(name)}&year=${year}&cat=${encodeURIComponent(cat)}`; } function parseTime(str) { if (!str || typeof str !== "string") return Infinity; const m = str.match(/(\d+):(\d+(?:\.\d+)?)/); if (!m) return Infinity; const ms = parseInt(m[1]) * 60000 + parseFloat(m[2]) * 1000; return ms > 0 ? ms : Infinity; } function availableYears() { const ys = new Set((window.CDG_RESULTS || []).map(r => r.year).filter(Boolean)); const arr = [...ys].sort((a, b) => b - a); if (arr.length === 0) arr.push(new Date().getFullYear()); return arr; } // ---------- Cálculo de standings ---------- function rankRaces(data) { const map = new Map(); const accumulate = (rows, isQual) => { rows.forEach(row => { const key = (row.handle || row.pilot).toUpperCase(); const cur = map.get(key) || { name: row.pilot, handle: row.handle || "", points: 0, races: new Set(), bestQual: null, bestBracket: null, qualPts: 0, bracketPts: 0, podiums: 0, wins: 0 }; const pts = Number(row.points || 0); cur.points += pts; if (isQual) cur.qualPts += pts; else cur.bracketPts += pts; if (row.pos === 1 || row.pos === 2 || row.pos === 3) cur.podiums += 1; if (!isQual && row.pos === 1) cur.wins += 1; if (row.time) { const t = parseTime(row.time); if (Number.isFinite(t)) { if (isQual && (cur.bestQual === null || t < cur.bestQual.ms)) cur.bestQual = { ms: t, str: row.time }; if (!isQual && (cur.bestBracket === null || t < cur.bestBracket.ms)) cur.bestBracket = { ms: t, str: row.time }; } } map.set(key, cur); }); }; data.forEach(race => { accumulate(race.qualification || [], true); accumulate(race.bracket || [], false); [...(race.qualification || []), ...(race.bracket || [])].forEach(row => { const key = (row.handle || row.pilot).toUpperCase(); const cur = map.get(key); if (cur) cur.races.add(race.id); }); }); return [...map.values()].map(p => ({ ...p, racesCount: p.races.size })) .sort((a, b) => b.points - a.points || (a.bestQual?.ms ?? Infinity) - (b.bestQual?.ms ?? Infinity)); } function computeRanking(year, category) { return rankRaces((window.CDG_RESULTS || []).filter(r => r.year === year && r.category === category)); } // ---------- Movimientos vs fecha anterior ---------- const MONTHS_ES = { ENE:0, FEB:1, MAR:2, ABR:3, MAY:4, JUN:5, JUL:6, AGO:7, SEP:8, OCT:9, NOV:10, DIC:11 }; function raceSortKey(race) { const d = race.date || ""; let mo = 0, day = 1; const mm = d.match(/\b(ENE|FEB|MAR|ABR|MAY|JUN|JUL|AGO|SEP|OCT|NOV|DIC)\b/i); if (mm) mo = MONTHS_ES[mm[1].toUpperCase()] || 0; const dm = d.match(/\d+/); if (dm) day = parseInt(dm[0], 10); return (race.year || 0) * 10000 + mo * 100 + day; } function rankingMoves(year, category) { const data = (window.CDG_RESULTS || []).filter(r => r.year === year && r.category === category); if (data.length < 2) return { moves: {}, lastDate: null }; const sorted = [...data].sort((a, b) => raceSortKey(a) - raceSortKey(b)); const maxKey = Math.max(...sorted.map(raceSortKey)); const prevRaces = sorted.filter(r => raceSortKey(r) < maxKey); const lastRace = sorted.find(r => raceSortKey(r) === maxKey); if (prevRaces.length === 0) return { moves: {}, lastDate: lastRace?.date || null }; const cur = rankRaces(sorted), prev = rankRaces(prevRaces); const prevPos = {}; prev.forEach((p, i) => { prevPos[(p.handle || p.name).toUpperCase()] = i; }); const moves = {}; cur.forEach((p, i) => { const key = (p.handle || p.name).toUpperCase(); const pp = prevPos[key]; if (pp === undefined) moves[key] = { move: "new", amt: 0 }; else if (pp > i) moves[key] = { move: "up", amt: pp - i }; else if (pp < i) moves[key] = { move: "down", amt: i - pp }; else moves[key] = { move: "same", amt: 0 }; }); return { moves, lastDate: lastRace?.date || null }; } // ---------- UI helpers ---------- function PilotAvatar({ name, size, cls }) { const meta = pilotMeta(name); const s = size || 54; if (meta.photo) return {name}; const initials = String(name || "?").split(/\s+/).map(w => w[0]).slice(0, 2).join("").toUpperCase(); return
{initials}
; } function MoveIndicator({ info, big }) { if (!info) return null; const c = big ? "rk-move big" : "rk-move"; if (info.move === "up") return ▲{info.amt > 1 ? {info.amt} : null}; if (info.move === "down") return ▼{info.amt > 1 ? {info.amt} : null}; if (info.move === "new") return NEW; return ; } function IgGlyph({ size = 12 }) { return (); } // Contador animado de puntos (fugacidad) function useCountUp(target, run) { const [val, setVal] = useState(0); useEffect(() => { if (!run) { setVal(target); return; } let raf, start; const dur = 900; const step = (ts) => { if (!start) start = ts; const p = Math.min(1, (ts - start) / dur); const eased = 1 - Math.pow(1 - p, 3); setVal(Math.round(target * eased)); if (p < 1) raf = requestAnimationFrame(step); }; raf = requestAnimationFrame(step); return () => cancelAnimationFrame(raf); }, [target, run]); return val; } // ---------- Podio ---------- function PodiumCard({ p, place, year, category, mv, animate }) { const pts = useCountUp(p.points, animate); const meta = pilotMeta(p.name); const flag = flagUrl(meta.country); const medal = place === 1 ? "ORO" : place === 2 ? "PLATA" : "BRONCE"; return (
{place}{medal}
{mv &&
}
{flag && {meta.country}} {p.name}
{meta.ig ? { e.preventDefault(); window.open(`https://instagram.com/${meta.ig}`, "_blank"); }}>@{meta.ig} : @—}
{pts}pts
{p.wins} {p.wins === 1 ? "win" : "wins"} {p.podiums} {p.podiums === 1 ? "podio" : "podios"} {p.racesCount} {p.racesCount === 1 ? "fecha" : "fechas"}
); } function Ranking() { const years = availableYears(); const currentYear = new Date().getFullYear(); const params = new URLSearchParams(location.search); const [category, setCategory] = useState(params.get("cat") === "TINY" ? "TINY" : "5\""); const [year, setYear] = useState(years.includes(currentYear) ? currentYear : years[0]); const [animate, setAnimate] = useState(false); useEffect(() => { setAnimate(false); const id = requestAnimationFrame(() => setAnimate(true)); return () => cancelAnimationFrame(id); }, [category, year]); const ranking = computeRanking(year, category); const { moves, lastDate } = rankingMoves(year, category); const catLabel = category === "TINY" ? "Tinywhoop" : "Open 5\""; const keyOf = p => (p.handle || p.name).toUpperCase(); const podium = ranking.slice(0, 3); const rest = ranking.slice(3); // Orden visual del podio: 2° · 1° · 3° const podiumOrder = [podium[1], podium[0], podium[2]].filter(Boolean); // Escaladores (mayores subidas) para el strip de movimientos const climbers = ranking .map((p, i) => ({ p, pos: i + 1, mv: moves[keyOf(p)] })) .filter(x => x.mv && (x.mv.move === "up" || x.mv.move === "new")) .sort((a, b) => (b.mv.move === "new" ? 1 : b.mv.amt) - (a.mv.move === "new" ? 1 : a.mv.amt)) .slice(0, 5); return ( <>
// RANKING OFICIAL · TEMPORADA {year}{year === currentYear ? " · EN CURSO" : ""}

{category === "TINY" ? <>Ranking
Tiny /26 : <>Ranking
5" /26}

La pelea por el campeonato del 502, fecha a fecha. Puntos de clasificación + bracket, con los movimientos de cada piloto respecto a la fecha anterior.

Elige categoría · son dos
Temporada
{ranking.length === 0 ? (
Sin resultados de {catLabel} para {year}.
) : (<> {/* PODIO */}

El podio

{catLabel} · {year}
{podiumOrder.map(p => ( ))}
{/* MOVIMIENTOS */} {climbers.length > 0 && (

En racha

Mayores subidas{lastDate ? ` · ${lastDate}` : ""}
{climbers.map(({ p, pos, mv }) => (
{p.name}
P{pos} · {p.points} pts
))}
)} {/* TABLA COMPLETA */}

Clasificación general

sube ·  igual ·  baja
{ranking.map((p, i) => { const meta = pilotMeta(p.name); const flag = flagUrl(meta.country); const mv = moves[keyOf(p)]; return (
{String(i + 1).padStart(2, "0")}
{flag && {meta.country}} {meta.ig ? @{meta.ig} : @—}
{p.racesCount} {p.racesCount === 1 ? "carrera" : "carreras"} {p.podiums} {p.podiums === 1 ? "podio" : "podios"} {p.bestQual && Mejor qual {p.bestQual.str}}
{p.points}
Puntos
Q {p.qualPts} · B {p.bracketPts}
); })}
Clasificación: 1°=35 · 2°=28 · 3°=25 · 4°=22…  |  Bracket top 4: 6 · 4 · 3 · 2
)}
); } ReactDOM.createRoot(document.getElementById("rk-root")).render();