// app.jsx — CDG main app const { useState, useEffect, useRef } = React; const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "accent": "#ff3d00", "accent2": "#ffaa00", "speed": 1, "displayFont": "Space Grotesk", "heroVariant": "split", "palette": "fire" }/*EDITMODE-END*/; const PALETTES = { fire: { accent: "#ff3d00", accent2: "#ffaa00", glow: "rgba(255,61,0,0.55)" }, acid: { accent: "#c6ff00", accent2: "#00e5ff", glow: "rgba(198,255,0,0.55)" }, magma: { accent: "#ff1744", accent2: "#ff6e40", glow: "rgba(255,23,68,0.55)" }, voltage: { accent: "#7c4dff", accent2: "#ff4081", glow: "rgba(124,77,255,0.55)" } }; const FONT_PAIRS = { "Space Grotesk": "'Space Grotesk', system-ui, sans-serif", "Archivo Black": "'Archivo Black', system-ui, sans-serif", "Bebas Neue": "'Bebas Neue', system-ui, sans-serif", "Syne": "'Syne', system-ui, sans-serif" }; // ----- Countdown ----- function useCountdown(target) { const [t, setT] = useState(() => calc(target)); useEffect(() => { const id = setInterval(() => setT(calc(target)), 1000); return () => clearInterval(id); }, [target]); return t; } function calc(target) { const diff = Math.max(0, target - Date.now()); const d = Math.floor(diff / 86400000); const h = Math.floor((diff / 3600000) % 24); const m = Math.floor((diff / 60000) % 60); const s = Math.floor((diff / 1000) % 60); return { d, h, m, s }; } const pad = n => String(n).padStart(2, "0"); // ----- Reveal on scroll ----- function useReveal() { useEffect(() => { const els = document.querySelectorAll(".reveal"); // Mark as pending so they fade in on scroll els.forEach(el => { const rect = el.getBoundingClientRect(); // Only hide elements not yet on screen if (rect.top > window.innerHeight * 0.9) el.classList.add("pending"); else el.classList.add("visible"); }); const obs = new IntersectionObserver((entries) => { entries.forEach(e => { if (e.isIntersecting) { e.target.classList.remove("pending"); e.target.classList.add("visible"); } }); }, { threshold: 0.05, rootMargin: "0px 0px -10% 0px" }); els.forEach(el => obs.observe(el)); return () => obs.disconnect(); }, []); } // ----- Parallax ----- function useParallax() { useEffect(() => { const onScroll = () => { const y = window.scrollY; document.querySelectorAll("[data-parallax]").forEach(el => { const r = parseFloat(el.dataset.parallax); el.style.transform = `translateY(${y * r}px)`; }); }; window.addEventListener("scroll", onScroll, { passive: true }); return () => window.removeEventListener("scroll", onScroll); }, []); } // ----- DATA ----- // Calendar oficial CDG · Temporada 2026 (Tiny + Open 5") // status se calcula automáticamente desde la fecha (no editar manualmente) const RACES_RAW = [ { num: "01", date: "2026-02-22", day: "21–22", month: "FEB", name: "Tinywhoop · 1ra fecha", sub: "1ra fecha · Inicio de temporada", loc: "Guatemala City", cat: "TINY", tag: null }, { num: "02", date: "2026-02-22", day: "21–22", month: "FEB", name: "Gemfan LATAM Guatemala", sub: "Open 5\" · 1ra fecha", loc: "Guatemala City", cat: "5\"", tag: "LATAM" }, { num: "03", date: "2026-04-24", day: "24", month: "ABR", name: "Tinywhoop · 2da fecha", sub: "Categoría Tinywhoop", loc: "Guatemala City", cat: "TINY", tag: null }, { num: "04", date: "2026-04-25", day: "25", month: "ABR", name: "Open 5\" · 2da fecha", sub: "Categoría Open 5\"", loc: "Guatemala City", cat: "5\"", tag: null }, { num: "05", date: "2026-05-15", day: "15", month: "MAY", name: "Tinywhoop · 3ra fecha", sub: "Categoría Tinywhoop", loc: "Por confirmar", cat: "TINY", tag: null }, { num: "06", date: "2026-05-16", day: "16", month: "MAY", name: "Open 5\" · 3ra fecha", sub: "MultiGP Regional Qualifier", loc: "Por confirmar", cat: "5\"", tag: "RQ" }, { num: "07", date: "2026-05-29", day: "29", month: "MAY", name: "Tinywhoop · 4ta fecha", sub: "Categoría Tinywhoop", loc: "Por confirmar", cat: "TINY", tag: null }, { num: "08", date: "2026-05-30", day: "30", month: "MAY", name: "Open 5\" · 4ta fecha", sub: "Global Qualifier", loc: "Por confirmar", cat: "5\"", tag: "GQ" }, { num: "09", date: "2026-07-03", day: "03", month: "JUL", name: "Tinywhoop · 5ta fecha", sub: "Categoría Tinywhoop", loc: "Por confirmar", cat: "TINY", tag: null }, { num: "10", date: "2026-07-04", day: "04", month: "JUL", name: "Open 5\" · 5ta fecha", sub: "Global Qualifier", loc: "Por confirmar", cat: "5\"", tag: "GQ" }, { num: "11", date: "2026-09-11", day: "11", month: "SEP", name: "Tinywhoop · 6ta fecha", sub: "Categoría Tinywhoop", loc: "Por confirmar", cat: "TINY", tag: null }, { num: "12", date: "2026-09-12", day: "12", month: "SEP", name: "Open 5\" · 6ta fecha", sub: "Categoría Open 5\"", loc: "Por confirmar", cat: "5\"", tag: null }, { num: "13", date: "2026-11-13", day: "13", month: "NOV", name: "Tinywhoop · 7ma fecha", sub: "Final Tinywhoop", loc: "Por confirmar", cat: "TINY", tag: null }, { num: "14", date: "2026-11-14", day: "14", month: "NOV", name: "Open 5\" · 7ma fecha", sub: "Final Open 5\"", loc: "Por confirmar", cat: "5\"", tag: null } ]; // Auto-derive status (done / live / upcoming) from today's date AND the real race hours // Tiny corre 5:00–10:00 PM · 5" corre 8:00 AM–6:00 PM (hora Guatemala, GT-6). const TODAY = new Date(); const TIME_BY_CAT = { "TINY": "5:00 – 10:00 PM", "5\"": "8:00 AM – 6:00 PM", "AMBAS": "Todo el día" }; const HOURS_BY_CAT = { "TINY": { start: 17, end: 22 }, "5\"": { start: 8, end: 18 }, "AMBAS": { start: 8, end: 22 } }; // Sede por defecto cuando aún no se confirma: 5" → Florencia · Tiny → VIP Location (rotativa) const DEFAULT_VENUE = { "5\"": "Parque Ecológico Florencia", "TINY": "VIP Location · sede rotativa", "AMBAS": "Centro Español" }; const pad2 = n => String(n).padStart(2, "0"); const RACES = RACES_RAW.map(r => { const hrs = HOURS_BY_CAT[r.cat] || { start: 8, end: 18 }; const startTs = new Date(`${r.date}T${pad2(hrs.start)}:00:00-06:00`).getTime(); const endTs = new Date(`${r.date}T${pad2(hrs.end)}:00:00-06:00`).getTime(); const now = TODAY.getTime(); let status, label; if (now < startTs) { status = "upcoming"; label = r.sub?.startsWith("Final") ? "Final" : "Próxima"; } else if (now <= endTs) { status = "live"; label = "En vivo"; } else { status = "done"; label = "Completada"; } const loc = (r.loc === "Por confirmar") ? (DEFAULT_VENUE[r.cat] || r.loc) : r.loc; return { ...r, loc, status, label, time: TIME_BY_CAT[r.cat] || "Por confirmar", timestamp: startTs, startTs, endTs }; }); // Next upcoming race for hero countdown function nextUpcomingRace() { return RACES.find(r => r.status === "upcoming") || RACES[RACES.length - 1]; } // ¿Hay una fecha corriendo ahora mismo? (status auto = "live", o forzado desde config.liveOverride) function getActiveRace() { const cfg = (typeof window !== "undefined" && window.CDG_CONFIG) || {}; const live = RACES.find(r => r.status === "live"); if (live) return { race: live, cfg }; if (cfg.liveOverride) return { race: nextUpcomingRace(), cfg }; return null; } // Mapea una carrera del calendario a su entrada de resultados (para deep-link) function resultForRace(race) { const list = (typeof window !== "undefined" && window.CDG_RESULTS) || []; return list.find(r => r.id && r.id.startsWith(race.date) && r.category === race.cat) || null; } const VENUES = { tiny: [ { name: "C.C. Tikal Futura", loc: "Calz. Roosevelt, Zona 11 · Ciudad de Guatemala", type: "Indoor", note: "Explanada comercial bajo techo.", maps: "Tikal Futura, Calzada Roosevelt, Guatemala" }, { name: "Centro Español", loc: "Ciudad de Guatemala", type: "Ambas", note: "Club deportivo y social · sede híbrida (Tiny + 5\").", maps: "Centro Español de Guatemala" }, { name: "Design Center · Sótano", loc: "Blvd. Los Próceres, Zona 10 · Ciudad de Guatemala", type: "Indoor", note: "Pista técnica en niveles de parqueo.", maps: "Design Center, Boulevard Los Próceres, Zona 10, Guatemala" } ], open: [ { name: "Parque Ecológico Florencia", loc: "Santa Lucía Milpas Altas, Sacatepéquez", type: "Outdoor", note: "Reserva boscosa · Km 35 ruta a Antigua.", maps: "Parque Ecológico Florencia, Santa Lucía Milpas Altas, Sacatepéquez" }, { name: "Estadio Carlos Enrique Cuc Cuevas", loc: "Santa Lucía Milpas Altas, Sacatepéquez", type: "Outdoor", note: "Estadio municipal.", maps: "Estadio Carlos Enrique Cuc Cuevas, Santa Lucía Milpas Altas" }, { name: "Finca Cienaguilla", loc: "Guatemala", type: "Outdoor", note: "Circuito en finca privada.", url: "https://www.instagram.com/fincacienaguillagt/", link: "ig", maps: "Finca Cienaguilla, Guatemala" }, { name: "Parque Erick Barrondo", loc: "28 av., Zona 7 · Ciudad de Guatemala", type: "Outdoor", note: "El parque más grande de la capital.", maps: "Parque Erick Barrondo, Zona 7, Guatemala" }, { name: "Tribu Terrace", loc: "Ciudad de Guatemala", type: "Outdoor", note: "Terraza rooftop para eventos.", url: "https://tributerrace.com", link: "web", maps: "Tribu Terrace, Guatemala" }, { name: "Centro Español", loc: "Ciudad de Guatemala", type: "Ambas", note: "Club deportivo y social · sede híbrida (Tiny + 5\").", maps: "Centro Español de Guatemala" } ] }; const mapsUrl = q => `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(q)}`; const VIDEOS = [ { title: "Highlights · Volcán Pacaya", sub: "RD 02 · 4 min", live: true, duration: "LIVE" }, { title: "POV · Vuelta rápida", sub: "Lap récord", live: false, duration: "2:14" }, { title: "Crash compilation", sub: "Temporada 2026", live: false, duration: "3:48" }, { title: "Tras bambalinas", sub: "CDG · 502", live: false, duration: "5:21" }, { title: "Tutorial: Setup 5\"", sub: "Guía CDG", live: false, duration: "8:02" } ]; const SPONSORS = [ { n: "Expression", s: "Patrocinador oficial" }, { n: "Herbaclinic", s: "Patrocinador oficial" }, { n: "Schatten", s: "Patrocinador oficial" }, { n: "BetaFPV", s: "Tinywhoop" }, { n: "Gemfan", s: "Hélices" } ]; // ----- DIRECTORIO DE PILOTOS ----- // Mapa central: nombre en results.js → { ig, country } // ig = solo el usuario (sin @, sin link). country = código ISO 2 letras (GT, SV, MX…) // Pilotos sin ig todavía → se muestran sin link. Pilotos sin country → sin bandera. // Directorio central de pilotos → data/pilots.js (window.CDG_PILOTS) const PILOT_DIR = (typeof window !== "undefined" && window.CDG_PILOTS) ? window.CDG_PILOTS : {}; // Lookup tolerante a mayúsculas/espacios const _PILOTS_NORM = Object.fromEntries( Object.entries(PILOT_DIR).map(([k, v]) => [k.toLowerCase().trim(), v]) ); function pilotMeta(name) { if (!name) return {}; return _PILOTS_NORM[name.toLowerCase().trim()] || {}; } // Código ISO 2 letras → URL de imagen de bandera (se ve igual en todos los SO, // a diferencia del emoji que en Windows aparece como letras) function flagUrl(code) { if (!code || code.length !== 2) return ""; return `https://flagcdn.com/${code.toLowerCase()}.svg`; } // ----- HERO ----- function Hero({ t }) { // ¿Estamos corriendo? → la portada se convierte en un AVISO en vivo const activeInfo = getActiveRace(); const next = nextUpcomingRace(); const target = next.timestamp; const c = useCountdown(target); const countdownLabel = `${next.num} · ${next.name.toUpperCase()} · ${next.day} ${next.month}`; // Datos del aviso "en vivo" let live = null; if (activeInfo) { const r = activeInfo.race; const cfg = activeInfo.cfg; const venue = (cfg.liveVenue && cfg.liveVenue.trim()) || r.loc || "Sede por confirmar"; live = { name: r.name, cat: r.cat === "TINY" ? "Tinywhoop" : (r.cat === "5\"" ? "Open 5\"" : "Tiny + Open 5\""), date: `${r.day} ${r.month}`, time: r.time, venue, note: (cfg.liveNote && cfg.liveNote.trim()) || "" }; } return (
{live ? "// ESTAMOS CORRIENDO · EN VIVO AHORA" : "// CAMPEONATO DRONES GUATEMALA · TEMPORADA 2026"}
CDG

{live ? <>EstamosCorriendo : <>AgendaAnual /26}

Categoría5" Open
CategoríaTinywhoop
Sedes09 Venues
{live ? <>Ver transmisión Ver calendario : <>Inscríbete Ver stream }
{live ? (
Estamos corriendo · En vivo
{live.name}
{live.cat} · CDG 2026
Fecha{live.date}
Horario{live.time}
Sede{live.venue}
{live.note &&
Nota{live.note}
}
Ver transmisión en vivo
) : (
{countdownLabel}
{pad(c.d)}
Días
{pad(c.h)}
Horas
{pad(c.m)}
Min
{pad(c.s)}
Seg
)}
ALT 14m VEL 124kph REC ● 5.8GHz · CH4
Drone FPV de carreras — CDG
FPV RACING CAMPEONATO 2026 5" OPEN TINYWHOOP 502 ANTIGUA SAN PEDRO SULA TIKAL ESPORTS GAMING FPV RACING CAMPEONATO 2026 5" OPEN TINYWHOOP 502 ANTIGUA SAN PEDRO SULA TIKAL ESPORTS GAMING
); } // ----- ABOUT ----- function About() { return (
SOBRE EL CAMPEONATO / 01

La velocidad
tiene nuevo
código de área.

Tercera temporada del Campeonato Drones Guatemala. Catorce fechas, dos categorías, un solo objetivo: llevar el FPV racing centroamericano a los volcanes, las selvas y las pistas más rápidas del 502.

Categoría A

5" Open

Quads de competición sin restricciones de motor, hasta 6S.

Frame5"
Bat6S
Peso≤ 800g
InscripciónQ150 / fecha Horario8 AM – 6 PM
Categoría B

Tinywhoop

Micro drones indoor, 1S–2S. Pista técnica y multinivel.

Frame65–75mm
Bat1–2S
Peso≤ 40g
InscripciónQ100 / fecha Horario5 – 10 PM
14
Fechas / 2026
02
Categorías
RQ
Regional Qualifier
GQ
Global Qualifier
220
Km/h pico
5.8
GHz · 8 canales
); } // ----- CALENDAR ----- const MONTH_SHORT = ["ENE","FEB","MAR","ABR","MAY","JUN","JUL","AGO","SEP","OCT","NOV","DIC"]; const MONTH_LONG = ["Enero","Febrero","Marzo","Abril","Mayo","Junio","Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre"]; const WEEKDAYS = ["Lun","Mar","Mié","Jue","Vie","Sáb","Dom"]; function Calendar() { const [view, setView] = useState("grid"); // "grid" (cuadrícula por meses) | "list" const [filter, setFilter] = useState("ALL"); // Agrupar carreras por mes (YYYY-MM) const monthsMap = {}; RACES.forEach(r => { const k = r.date.slice(0, 7); (monthsMap[k] = monthsMap[k] || []).push(r); }); const monthKeys = Object.keys(monthsMap).sort(); // Mes por defecto = mes de la próxima fecha, si no el primero const next = nextUpcomingRace(); const defKey = next ? next.date.slice(0, 7) : monthKeys[0]; const [month, setMonth] = useState(monthKeys.includes(defKey) ? defKey : monthKeys[0]); // Carreras del mes seleccionado, ya filtradas por categoría const passCat = r => filter === "ALL" || r.cat === filter || r.cat === "AMBAS"; const monthRaces = (monthsMap[month] || []).filter(passCat); const [selId, setSelId] = useState(monthRaces[0]?.num || null); useEffect(() => { setSelId((monthsMap[month] || []).filter(passCat)[0]?.num || null); }, [month, filter]); // Construir la cuadrícula del mes const [yy, mm] = month.split("-").map(Number); const monthIdx = mm - 1; const firstDow = (new Date(yy, monthIdx, 1).getDay() + 6) % 7; // Lunes = 0 const daysInMonth = new Date(yy, monthIdx + 1, 0).getDate(); const todayKey = `${TODAY.getFullYear()}-${String(TODAY.getMonth() + 1).padStart(2, "0")}-${String(TODAY.getDate()).padStart(2, "0")}`; // Mapa día → carreras const racesByDay = {}; monthRaces.forEach(r => { const d = parseInt(r.date.slice(8, 10), 10); (racesByDay[d] = racesByDay[d] || []).push(r); }); const sel = RACES.find(r => r.num === selId) || null; const catName = c => c === "TINY" ? "Tinywhoop" : (c === "5\"" ? "Open 5\"" : "Tiny + Open 5\""); const catCls = c => c === "TINY" ? "tiny" : (c === "AMBAS" ? "both" : ""); const selRes = sel ? resultForRace(sel) : null; const cells = []; for (let i = 0; i < firstDow; i++) cells.push(
); for (let d = 1; d <= daysInMonth; d++) { const dayRaces = racesByDay[d] || []; const isToday = `${month}-${String(d).padStart(2, "0")}` === todayKey; const selectedHere = dayRaces.some(r => r.num === selId); cells.push(
setSelId(dayRaces[0].num) : undefined} >
{String(d).padStart(2, "0")}
{dayRaces.map(r => (
{ e.stopPropagation(); setSelId(r.num); }} > {r.cat === "TINY" ? "Tiny" : r.cat === "5\"" ? "5\"" : "Ambas"}
))}
); } return (
AGENDA ANUAL · TEMPORADA 2026 / 02

Catorce fechas.
Dos categorías.

{[ { id: "ALL", label: "Todas" }, { id: "TINY", label: "Tinywhoop" }, { id: "5\"", label: "Open 5\"" } ].map(f => ( ))}
Tinywhoop Open 5" RQMultiGP Regional Qualifier GQGlobal Qualifier MultiGP Regional Series Central America
{view === "grid" ? (
{/* Selector de meses */}
{monthKeys.map(k => { const [ky, km] = k.split("-").map(Number); const count = (monthsMap[k] || []).filter(passCat).length; return ( ); })}
{WEEKDAYS.map(w =>
{w}
)}
{cells}
{/* Panel de detalle */}
{!sel ? (
— Sin fechas de esta categoría en {MONTH_LONG[monthIdx]} —
) : (<>
{sel.label}
{sel.day} {sel.month} · {yy}
{sel.name}{sel.tag ? {sel.tag} : null}
Categoría
{catName(sel.cat)}
Horario
{sel.time}
Sede
{sel.loc}
{sel.status === "done" && selRes ? ( Ver resultados oficiales ) : sel.status === "done" ? (
Resultados oficiales próximamente.
) : sel.status === "live" ? ( Ver transmisión en vivo ) : ( Inscríbete a esta fecha )}
{sel.sub}
)}
) : (
{RACES.filter(passCat).map(r => { const res = resultForRace(r); const href = (r.status === "done" && res) ? `resultados.html?race=${res.id}` : null; const Row = href ? "a" : "div"; return (
F/{r.num}
{r.month}{r.day}
{r.name} {r.tag && {r.tag}}
{r.sub}
{r.loc==="Por confirmar" ? <>Por confirmar : r.loc}
{r.label}
); })}
)}
); } function Venues() { const groups = [ { key: "tiny", label: "Tinywhoop", sub: "Indoor · técnico", list: VENUES.tiny, cls: "tiny" }, { key: "open", label: "Open 5\"", sub: "Outdoor · velocidad", list: VENUES.open, cls: "" } ]; return (
VENUES · SEDES 2026 / 04

Donde se
vuela.

Los trazados cambian en cada fecha — estas son las sedes donde corre el campeonato.

{groups.map(g => (

{g.label}

{g.sub}
{g.list.map((v, i) => (
{String(i + 1).padStart(2, "0")} {v.type}
{v.url ? {v.name}{v.link === "ig" ? : "↗"} :
{v.name}
} {v.loc &&
{v.loc}
} {v.note &&
{v.note}
} {v.maps && ( Cómo llegar )}
))}
))}
VIP LOCATION

Sede rotativa

Esta localidad cambia cada fecha. Confirmamos la ubicación exacta a través de nuestras redes — escríbenos y te pasamos el punto exacto de cada evento.

Instagram TikTok
); } // ----- RANKING HELPERS ----- // Auto-compute ranking from window.CDG_RESULTS function computeRanking(year, category) { const data = (window.CDG_RESULTS || []).filter(r => r.year === year && r.category === category); return rankRaces(data); } // Computa standings a partir de una lista arbitraria de carreras (para comparar fechas) function rankRaces(data) { const map = new Map(); // handle/name -> { name, handle, points, races, bestQual, bestBracket } 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 }; 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; // Track best time (ignora placeholders en cero / vacíos) 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 => { race.races = race.id; accumulate(race.qualification || [], true); accumulate(race.bracket || [], false); // mark races counted [...(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 parseTime(str) { // "0:42.18" → ms ; tiempos vacíos o en cero (placeholders de bracket) = inválidos 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; } // Enlace a la página de detalle del piloto (historial qualy + bracket) function pilotHref(name, year, cat) { return `piloto.html?name=${encodeURIComponent(name)}&year=${year}&cat=${encodeURIComponent(cat)}`; } // Orden cronológico de una carrera (para saber cuál es la fecha más reciente) 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; } // Devuelve el movimiento de cada piloto respecto a la fecha anterior: // { KEY: { move:'up'|'down'|'same'|'new', amt:Number } } function rankingMoves(year, category) { const data = (window.CDG_RESULTS || []).filter(r => r.year === year && r.category === category); if (data.length < 2) return {}; 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); if (prevRaces.length === 0) return {}; const cur = rankRaces(sorted); const 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; } // Avatar del piloto (foto recortada o iniciales como respaldo) function PilotAvatar({ name, size }) { 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}
; } // Indicador de movimiento ▲/▼/– respecto a la fecha anterior function MoveIndicator({ info }) { if (!info) return null; 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 ; } // ----- RANKING TEASER (el ranking completo vive en ranking.html) ----- function RankingTeaser() { const years = availableYears(); const currentYear = new Date().getFullYear(); const defaultYear = years.includes(currentYear) ? currentYear : years[0]; const [year] = useState(defaultYear); const [category, setCategory] = useState("5\""); const ranking = computeRanking(year, category); const moves = 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 order = [podium[1], podium[0], podium[2]].filter(Boolean); const rankingUrl = `ranking.html?cat=${category === "TINY" ? "TINY" : "5\""}`; return (
RANKING ANUAL DE PILOTOS / 03

Los más
rápidos del 502.

La clasificación general de la temporada, fecha a fecha. Mira el podio, los movimientos y la tabla completa.

{ranking.length === 0 ? (
Sin resultados de {catLabel} para {year}.
) : (
{order.map(p => { const place = ranking.indexOf(p) + 1; const meta = pilotMeta(p.name); const flag = flagUrl(meta.country); const mv = moves[keyOf(p)]; return (
{place}{place === 1 ? "ORO" : place === 2 ? "PLATA" : "BRONCE"}
{mv &&
}
{flag && {meta.country}}{p.name}
{p.points}pts
{p.podiums} {p.podiums === 1 ? "podio" : "podios"} {p.racesCount} {p.racesCount === 1 ? "fecha" : "fechas"}
); })}
)}
Ver ranking completo {catLabel} · {ranking.length} {ranking.length === 1 ? "piloto" : "pilotos"} · Temporada {year}
); } // ----- GALLERY ----- function Gallery() { return (
GALERÍA · POV / HIGHLIGHTS / 05
Próximamente

Galería
en camino.

Estamos preparando los mejores POV, highlights y crashes de la temporada. Vuelve pronto — esta sección se llena con material de cada fecha.

Míralo en Instagram
); } // ----- STREAM ----- function Stream() { const cfg = window.CDG_CONFIG || {}; const url = (cfg.streamUrl || "").trim(); const label = cfg.streamLabel || "TRANSMISIÓN OFICIAL · 4K"; const subtitle = cfg.streamSubtitle || ""; // Extract YouTube video ID or channel handle for embed const embedUrl = getYouTubeEmbed(url); return (
TRANSMISIÓN EN VIVO / 06

Stream
oficial.

{embedUrl ? (
EN VIVO
{label}{subtitle && · {subtitle}}
) : (
PRÓXIMA TRANSMISIÓN
EN
VIVO
El link del stream se configura en data/config.js
)} {url && ( Abrir en YouTube )}
); } function getYouTubeEmbed(url) { if (!url) return null; // Channel live: youtube.com/@handle/live → use channel's live embed via channel id requires API, use /live URL as embed // Best approach: try video id patterns const patterns = [ /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/live\/|youtube\.com\/v\/)([A-Za-z0-9_-]{11})/ ]; for (const p of patterns) { const m = url.match(p); if (m) return `https://www.youtube.com/embed/${m[1]}?autoplay=0&rel=0`; } // Channel handle live (e.g. youtube.com/@CanalCDG/live or youtube.com/channel/UCXXX/live) const handleMatch = url.match(/youtube\.com\/(@[A-Za-z0-9_.-]+)\/live/); if (handleMatch) { // YouTube doesn't allow embedding the @handle/live page directly via iframe; // safest fallback: embed using the channel handle stream redirect via livestream URL return `https://www.youtube.com/embed/live_stream?channel=${handleMatch[1].replace("@","")}`; } const channelMatch = url.match(/youtube\.com\/channel\/([A-Za-z0-9_-]+)/); if (channelMatch) { return `https://www.youtube.com/embed/live_stream?channel=${channelMatch[1]}`; } return null; } // ----- SPONSORS ----- function Sponsors() { return (
PATROCINADORES OFICIALES / 07

Los que hacen
posible el ruido.

{SPONSORS.map(s => (
{s.n}{s.s}
))}
); } // ----- REGISTER ----- function Register() { const [cat, setCat] = useState("5\" Open"); const [submitted, setSubmitted] = useState(false); return (
REGISTRO · TEMPORADA 26 / 08

Inscríbete
al 502.

Cupos limitados por categoría. La inscripción es por fecha e incluye placa de competidor, telemetría, pase de pits para 2 personas y kit oficial CDG·26.

Tinywhoop
Q. 100 / carrera
Open 5"
Q. 150 / carrera
Menores
Acompañados por un tutor
Sede
Hangar CDG · Z.10 Capital
Contacto
cdgfpv@gmail.com
{ e.preventDefault(); const f = e.target; const g = n => (f.elements[n]?.value || "").trim(); const subject = `Inscripción CDG·26 — ${cat} — ${g("nombre")} ${g("apellido")}`; const body = `Quiero inscribirme al Campeonato Drones Guatemala 2026.\n\n` + `Categoría: ${cat}\n` + `Nombre: ${g("nombre")} ${g("apellido")}\n` + `Handle FPV: ${g("handle")}\n` + `Email: ${g("email")}\n` + `Teléfono: ${g("telefono")}\n` + `Departamento: ${g("depto")}\n` + `Cuota: ${cat === "Tinywhoop" ? "Q100" : "Q150"} / carrera\n`; window.location.href = `mailto:cdgfpv@gmail.com?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; setSubmitted(true); }}>
setCat("5\" Open")}>5" Open
setCat("Tinywhoop")}>Tinywhoop
Cuota {cat} {cat === "Tinywhoop" ? "Q100" : "Q150"} / carrera
); } // ----- NAV + FOOTER ----- function Nav() { const [open, setOpen] = useState(false); useEffect(() => { document.body.style.overflow = open ? "hidden" : ""; return () => { document.body.style.overflow = ""; }; }, [open]); const links = [ { href: "#campeonato", label: "Campeonato", num: "01" }, { href: "#calendario", label: "Calendario", num: "02" }, { href: "resultados.html", label: "Resultados", num: "03" }, { href: "ranking.html", label: "Ranking", num: "04" }, { href: "global.html", label: "Global (GQ)", num: "05" }, { href: "#venues", label: "Venues", num: "06" }, { href: "noticias.html", label: "Noticias", num: "07" } ]; return ( ); } function Footer() { return ( ); } // ----- MAIN APP ----- function App() { const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); useReveal(); useParallax(); // Apply tweaks to CSS variables useEffect(() => { const pal = PALETTES[t.palette] || PALETTES.fire; const root = document.documentElement; // Palette overrides accent unless user explicitly changed accent root.style.setProperty("--accent", t.accent || pal.accent); root.style.setProperty("--accent-2", t.accent2 || pal.accent2); root.style.setProperty("--accent-glow", pal.glow); root.style.setProperty("--speed", String(t.speed)); root.style.setProperty("--display", FONT_PAIRS[t.displayFont] || FONT_PAIRS["Space Grotesk"]); }, [t]); return ( <>