1// Cruza los horarios y los recorridos de varias carreras a la vez, cada una 2// con su propio GPX y su propia hora de salida â pensado para eventos con 3// distancias simultáneas o escalonadas (p.ej. Fedola 5K y 10K saliendo juntas 4// a las 18:00, o GuÃa de Isora con salidas 8:30/8:45/9:00). Reutiliza el mismo 5// motor de cálculo del simulador (parseGPX + buildSegments + getStateAtTime), 6// pero dibuja varias rutas y varios corredores (uno por carrera) en un único 7// mapa, con un reloj maestro en hora real del dÃa en vez de "tiempo 8// transcurrido desde cero" â porque cada carrera empieza en un momento distinto. 9 10import { parseGPX } from './gpx.js'; 11import { buildSegments, solveBasePaceFromTargetTime } from './pace.js'; 12import { timeAtDistance, getStateAtTime } from './simulation.js'; 13import { timeLabelToSeconds } from './utils.js'; 14 15// Escala de ritmos preconfigurados, repartida entre 10 corredores por 16// carrera: el rango depende del terreno ("terrenoDefecto" del manifest), ya 17// que una carrera de asfalto y una de montaña no corren ni de lejos a los 18// mismos ritmos. La tabla de avituallamientos usa este mismo rango, asà que 19// sus columnas "rápido"/"lento" representan exactamente al primer y último 20// corredor del mapa. 21const RUNNERS_PER_RACE = 10; 22const ROAD_FAST_SEC_PER_KM = 180; // 3:00 min/km 23const ROAD_SLOW_SEC_PER_KM = 390; // 6:30 min/km 24const MOUNTAIN_FAST_SEC_PER_KM = 300; // 5:00 min/km 25const MOUNTAIN_SLOW_SEC_PER_KM = 720; // 12:00 min/km 26const SWIM_FAST_SEC_PER_KM = 600; // 1:00 min/100m, en la unidad interna (x10) 27const SWIM_SLOW_SEC_PER_KM = 2100; // 3:30 min/100m 28 29function paceRangeFor(sport, terrain) { 30 if (sport === 'swim') return { fast: SWIM_FAST_SEC_PER_KM, slow: SWIM_SLOW_SEC_PER_KM }; 31 if (terrain === 'asfalto') return { fast: ROAD_FAST_SEC_PER_KM, slow: ROAD_SLOW_SEC_PER_KM }; 32 return { fast: MOUNTAIN_FAST_SEC_PER_KM, slow: MOUNTAIN_SLOW_SEC_PER_KM }; 33} 34 35function presetPaceSecPerKm(sport, terrain, idx) { 36 const { fast, slow } = paceRangeFor(sport, terrain); 37 const step = (slow - fast) / (RUNNERS_PER_RACE - 1); 38 return Math.round(fast + idx * step); 39} 40 41const RACE_COLORS = ['#ea580c', '#2563eb', '#16a34a', '#9333ea', '#db2777', '#0891b2', '#ca8a04', '#dc2626']; 42 43const POI_ICONS = { 44 start: { emoji: 'ð', bg: '#16a34a' }, 45 finish: { emoji: 'ð', bg: '#dc2626' }, 46 food: { emoji: 'ð¥¤', bg: '#2563eb' }, 47 default: { emoji: 'ð', bg: '#6b7280' }, 48}; 49 50const el = (id) => document.getElementById(id); 51const racesList = el('races-list'); 52const btnCalcular = el('btn-calcular'); 53const statusMsg = el('status-msg'); 54const legendEl = el('legend'); 55const resultsBody = el('results-body'); 56const resultsEmpty = el('results-empty'); 57 58const mapCard = el('map-card'); 59const timelineCard = el('timeline-card'); 60const mapClockTime = el('map-clock-time'); 61const btnPlayToggle = el('btn-play-toggle'); 62const btnGotoStart = el('btn-goto-start'); 63const selectSpeed = el('select-speed'); 64const btnFullscreen = el('btn-fullscreen'); 65const timelineSlider = el('timeline-slider'); 66const timelineStartLabel = el('timeline-start-label'); 67const timelineEndLabel = el('timeline-end-label'); 68 69let manifest = []; 70let map = null; 71let races = []; // [{entry, startMinutes, color, points, pois, totalDistKm, sport, segments, polyline, marker}] 72 73let masterStartSec = 0; // segundos desde medianoche 74let masterEndSec = 0; 75let nowSec = 0; 76let playing = false; 77let speedMultiplier = 30; 78let scrubbing = false; 79let lastFrameMs = null; 80 81function parseStartTimeInput(value) { 82 if (!value) return null; 83 const [h, m] = value.split(':').map(Number); 84 if (!isFinite(h) || !isFinite(m)) return null; 85 return h * 60 + m; 86} 87 88function formatClockFromSec(totalSec) { 89 const wrapped = ((Math.floor(totalSec) % 86400) + 86400) % 86400; 90 const h = Math.floor(wrapped / 3600); 91 const m = Math.floor((wrapped % 3600) / 60); 92 return `${String(h).padStart(2, '0')}
92:${String(m).padStart(2, '0')}`; 93} 94 95function poiIcon(sym) { 96 const cfg = POI_ICONS[sym] || POI_ICONS.default; 97 return L.divIcon({ 98 className: '', 99 html: `<div style="background:${cfg.bg}" class="poi-marker">${cfg.emoji}</div>`, 100 iconSize: [26, 26], 101 iconAnchor: [13, 13], 102 }); 103} 104 105// Con hasta 10 corredores por carrera, un icono grande tipo "runner-marker" 106// (como en el simulador principal, que solo pinta un corredor) saturarÃa el 107// mapa â aquà cada corredor es un punto pequeño de color, con el número dentro. 108function runnerDotIcon(color, num) { 109 return L.divIcon({ 110 className: '', 111 html: `<div style="width:16px;height:16px;border-radius:50%;background:${color};border:2px solid white;box-shadow:0 1px 3px rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;font-size:8px;font-weight:700;color:white;">${num}</div>`, 112 iconSize: [16, 16], 113 iconAnchor: [8, 8], 114 }); 115} 116 117// { cache: 'no-store' } evita que el navegador se quede con una copia vieja 118// del manifest o de un GPX en local, donde no hay cabeceras de caché que lo 119// eviten (a diferencia de la web publicada, que ya lleva su propio _headers). 120async function loadManifest() { 121 const res = await fetch('routes/manifest.json', { cache: 'no-store' }); 122 if (!res.ok) throw new Error('No se pudo leer routes/manifest.json'); 123 manifest = await res.json(); 124 racesList.innerHTML = manifest 125 .map((r) => { 126 const sport = r.deporte === 'swim' ? 'swim' : 'running'; 127 return `<div class="border border-stone-200 rounded-lg p-2.5" data-race-id="${r.id}"> 128 <label class="flex items-center gap-2 text-sm font-semibold mb-1.5"> 129 <input type="checkbox" data-field="check" /> 130 ${r.nombre} 131 </label> 132 <div class="grid grid-cols-2 gap-2 pl-6"> 133 <div> 134 <span class="text-[11px] text-stone-500 block">Hora de salida</span> 135 <input type="time" data-field="hora" value="${r.horaSalida || ''}" class="w-full border border-stone-300 rounded-md px-2 py-1 text-sm" /> 136 </div> 137 <div> 138 <span class="text-[11px] text-stone-500 block">Tiempo de corte</span> 139 <input type="text" data-field="corte" placeholder="7:00:00" value="${r.tiempoCorte || ''}" class="w-full border border-stone-300 rounded-md px-2 py-1 text-sm" /> 140 </div> 141 </div> 142 </div>`; 143 }) 144 .join(''); 145} 146 147async function computeRaceRows(entry, startMinutes, points, pois, sport, terrain, cutoffSec) { 148 const { fast: fastPace, slow: slowPaceDefault } = paceRangeFor(sport, terrain); 149 const slowPace = cutoffSec ? solveBasePaceFromTargetTime(points, cutoffSec, terrain, 'normal', sport) : slowPaceDefault; 150 const segsFast = buildSegments(points, fastPace, terrain, 'normal', sport); 151 const segsSlow = buildSegments(points, slowPace, terrain, 'normal', sport); 152 const startSec = startMinutes * 60; 153 return pois.map((poi) => { 154 const tFast = timeAtDistance(segsFast, poi.distKm); 155 const tSlow = timeAtDistance(segsSlow, poi.distKm); 156 return { 157 carrera: entry.nombre, 158 punto: poi.name, 159 distKm: poi.distKm, 160 horaRapido: formatClockFromSec(startSec + tFast), 161 horaLento: formatClockFromSec(startSec + tSlow), 162 sortKey: startSec + tFast, 163 }; 164 }); 165} 166 167function clearMap() { 168 if (map) { 169 map.remove(); 170 map = null; 171 } 172 races = []; 173} 174 175async function handleCalcular() { 176 const selected = []; 177 const rows = racesList.querySelectorAll('[data-race-id]'); 178 for (const row of rows) { 179 const check = row.querySelector('[data-field="check"]'); 180 if (!check.checked) continue; 181 const raceId = row.dataset.raceId; 182 const entry = manifest.find((r) => r.id === raceId); 183 const startMinutes = parseStartTimeInput(row.querySelector('[data-field="hora"]').value); 184 const corteText = row.querySelector('[data-field="corte"]').value.trim(); 185 if (!entry || startMinutes == null) continue; 186 selected.push({ entry, startMinutes, corteText }); 187 } 188 189 if (selected.length === 0) { 190 statusMsg.textContent = 'Marca al menos una carrera y ponle su hora de salida.'; 191 return; 192 } 193 194 btnCalcular.disabled = true; 195 statusMsg.textContent = 'Cargando...';
196 clearMap(); 197 198 // El mapa se crea con la caja ya visible (no oculta): si Leaflet se inicia 199 // con el contenedor en display:none, calcula el tamaño como 0x0 y las 200 // teselas salen en blanco aunque luego se muestre la caja. 201 mapCard.classList.remove('hidden'); 202 timelineCard.classList.remove('hidden'); 203 204 try { 205 // El control de zoom se mueve a la derecha porque la esquina superior 206 // izquierda la ocupa el reloj y los botones de reproducción superpuestos. 207 map = L.map('map', { zoomControl: false, attributionControl: true }); 208 L.control.zoom({ position: 'topright' }).addTo(map); 209 L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { 210 maxZoom: 19, 211 attribution: '© OpenStreetMap contributors', 212 }).addTo(map); 213 214 const allRows = []; 215 const allBounds = []; 216 217 for (let i = 0; i < selected.length; i++) { 218 const { entry, startMinutes, corteText } = selected[i]; 219 const res = await fetch(`routes/${encodeURIComponent(entry.archivo)}`, { cache: 'no-store' }); 220 if (!res.ok) throw new Error(`No se pudo leer routes/${entry.archivo}`); 221 const xmlText = await res.text(); 222 const parsed = parseGPX(xmlText); 223 224 const sport = entry.deporte === 'swim' ? 'swim' : 'running'; 225 const terrain = entry.terrenoDefecto || (sport === 'swim' ? 'aguas_abiertas' : 'tecnico'); 226 const color = RACE_COLORS[i % RACE_COLORS.length]; 227 228 const latlngs = parsed.points.map((p) => [p.lat, p.lon]); 229 const polyline = L.polyline(latlngs, { color, weight: 4, opacity: 0.85 }).addTo(map); 230 allBounds.push(polyline.getBounds()); 231 232 for (const poi of parsed.pois) { 233 L.marker([poi.lat, poi.lon], { icon: poiIcon(poi.sym) }) 234 .bindTooltip(`${entry.nombre} · ${poi.name}`, { direction: 'top', offset: [0, -12] }) 235 .addTo(map); 236 } 237 238 // 10 corredores por carrera, con el ritmo repartido entre el más rápido 239 // y el más lento (los mismos lÃmites que usa la tabla de avituallamientos). 240 // Si le has puesto un tiempo de corte a esta carrera (campo de la lista 241 // de la izquierda, o "tiempoCorte" del manifest si no lo tocas), el 242 // último corredor no usa el ritmo lento genérico de la escala: se 243 // calcula el ritmo exacto que hace que llegue justo a ese tiempo de 244 // corte en esta ruta concreta (mismo motor que "tiempo objetivo" del 245 // simulador principal). 246 const cutoffText = corteText || entry.tiempoCorte; 247 const cutoffSec = cutoffText ? timeLabelToSeconds(cutoffText) : null; 248 249 const runners = []; 250 for (let idx = 0; idx < RUNNERS_PER_RACE; idx++) { 251 const isLast = idx === RUNNERS_PER_RACE - 1; 252 const pace = 253 isLast && cutoffSec 254 ? solveBasePaceFromTargetTime(parsed.points, cutoffSec, terrain, 'normal', sport) 255 : presetPaceSecPerKm(sport, terrain, idx); 256 const segments = buildSegments(parsed.points, pace, terrain, 'normal', sport); 257 const totalTimeSec = segments.cumTimeSec[segments.cumTimeSec.length - 1]; 258 const marker = L.marker(latlngs[0], { icon: runnerDotIcon(color, idx + 1), zIndexOffset: 1000 }) 259 .bindTooltip(`${entry.nombre} · Corredor ${idx + 1}`, { direction: 'top', offset: [0, -10] }) 260 .addTo(map); 261 runners.push({ segments, totalTimeSec, marker }); 262 } 263 264 races.push({ 265 entry, 266 startMinutes, 267 color, 268 points: parsed.points, 269 pois: parsed.pois, 270 sport, 271 runners, 272 }); 273 274 const raceRows = await computeRaceRows(entry, startMinutes, parsed.points, parsed.pois, sport, terrain, cutoffSec); 275 allRows.push(...raceRows); 276 } 277 278 map.fitBounds( 279 allBounds.reduce((acc, b) => (acc ? acc.extend(b) : b), null), 280 { padding: [24, 24] } 281 ); 282 283 allRows.sort((a, b) => a.sortKey - b.sortKey); 284 renderResults(allRows); 285 renderLegend(); 286 287 masterStartSec = Math.min(...races.map((r) => r.startMinutes * 60)); 288 masterEndSec = Math.max( 289 ...races.flatMap((r) => r.runners.map((runner) => r.startMinutes * 60 + runner.totalTimeSec)) 290 ); 291 nowSec = masterStartSec; 292 timelineSlider.min = String(masterStartSec); 293 timelineSlider.max = String(Math.ceil(masterEndSec));
294 timelineSlider.value = String(masterStartSec); 295 timelineStartLabel.textContent = formatClockFromSec(masterStartSec); 296 timelineEndLabel.textContent = formatClockFromSec(masterEndSec); 297 298 playing = false; 299 updatePlayToggleLabel(); 300 renderFrame(); 301 302 statusMsg.textContent = `${selected.length} carrera(s) cargadas en el mapa.`; 303 } catch (err) { 304 statusMsg.textContent = `Error: ${err.message}`; 305 console.error(err); 306 mapCard.classList.add('hidden'); 307 timelineCard.classList.add('hidden'); 308 } finally { 309 btnCalcular.disabled = false; 310 } 311} 312 313function renderLegend() { 314 legendEl.classList.remove('hidden'); 315 legendEl.innerHTML = races 316 .map( 317 (r) => 318 `<div class="flex items-center gap-2 text-xs"> 319 <span class="w-3 h-3 rounded-full shrink-0" style="background:${r.color}"></span> 320 <span>${r.entry.nombre} â sale ${formatClockFromSec(r.startMinutes * 60)}</span> 321 </div>` 322 ) 323 .join(''); 324} 325 326function renderResults(rows) { 327 resultsEmpty.classList.toggle('hidden', rows.length > 0); 328 resultsBody.innerHTML = rows 329 .map( 330 (r) => `<tr class="border-b border-stone-100"> 331 <td class="py-1.5 pr-3 font-semibold tabular-nums">${r.horaRapido} â ${r.horaLento}</td> 332 <td class="py-1.5 pr-3">${r.carrera}</td> 333 <td class="py-1.5 pr-3">
333${r.punto}</td> 334 <td class="py-1.5 pr-3 text-stone-500">${r.distKm.toFixed(2)} km</td> 335 </tr>` 336 ) 337 .join(''); 338} 339 340// ---------------- reproducción del mapa combinado ---------------- 341 342function renderFrame() { 343 mapClockTime.textContent = formatClockFromSec(nowSec); 344 if (!scrubbing) timelineSlider.value = String(Math.floor(nowSec)); 345 346 for (const race of races) { 347 const elapsedSec = nowSec - race.startMinutes * 60; 348 for (const runner of race.runners) { 349 const clamped = Math.max(0, Math.min(elapsedSec, runner.totalTimeSec)); 350 const state = getStateAtTime(race.points, runner.segments, clamped); 351 runner.marker.setLatLng([state.lat, state.lon]); 352 runner.marker.setOpacity(elapsedSec < 0 ? 0.35 : 1); 353 } 354 } 355} 356 357function updatePlayToggleLabel() { 358 btnPlayToggle.textContent = playing ? 'â¸' : 'â¶'; 359 btnPlayToggle.title = playing ? 'Pausar' : 'Reproducir'; 360 btnPlayToggle.classList.toggle('bg-green-600', !playing); 361 btnPlayToggle.classList.toggle('bg-amber-500', playing); 362} 363 364btnPlayToggle.addEventListener('click', () => { 365 if (!races.length) return; 366 playing = !playing; 367 lastFrameMs = null; 368 updatePlayToggleLabel(); 369}); 370 371btnGotoStart.addEventListener('click', () => { 372 if (!races.length) return; 373 nowSec = masterStartSec; 374 playing = false; 375 updatePlayToggleLabel(); 376 renderFrame(); 377}); 378 379selectSpeed.addEventListener('change', () => { 380 speedMultiplier = Number(selectSpeed.value); 381}); 382 383// Pantalla completa del mapa combinado: igual que en el simulador principal, 384// por CSS propio (no la API de pantalla completa del navegador, que puede 385// venir bloqueada sin avisar en algunos casos). 386function isMapFullscreen() { 387 return mapCard.classList.contains('pantalla-completa'); 388} 389function updateFullscreenButton() { 390 btnFullscreen.textContent = isMapFullscreen() ? '⤫' : 'â¶'; 391 btnFullscreen.title = isMapFullscreen() ? 'Salir de pantalla completa' : 'Pantalla completa'; 392} 393function setMapFullscreen(on) { 394 mapCard.classList.toggle('pantalla-completa', on); 395 document.body.classList.toggle('map-fs-lock', on); 396 updateFullscreenButton(); 397 requestAnimationFrame(() => { if (map) map.invalidateSize(); }); 398} 399btnFullscreen.addEventListener('click', () => setMapFullscreen(!isMapFullscreen())); 400document.addEventListener('keydown', (e) => { 401 if (e.key === 'Escape' && isMapFullscreen()) setMapFullscreen(false); 402}); 403 404timelineSlider.addEventListener('pointerdown', () => { 405 scrubbing = true; 406}); 407timelineSlider.addEventListener('input', () => { 408 nowSec = Number(timelineSlider.value); 409 playing = false; 410 updatePlayToggleLabel(); 411 renderFrame(); 412}); 413timelineSlider.addEventListener('pointerup', () => { 414 scrubbing = false; 415}); 416 417function animationFrame(nowMs) { 418 if (playing && races.length) { 419 if (lastFrameMs == null) lastFrameMs = nowMs; 420 const realDeltaSec = (nowMs - lastFrameMs) / 1000; 421 lastFrameMs = nowMs; 422 nowSec += realDeltaSec * speedMultiplier; 423 if (nowSec >= masterEndSec) { 424 nowSec = masterEndSec; 425 playing = false; 426 updatePlayToggleLabel(); 427 } 428 renderFrame(); 429 } else { 430 lastFrameMs = nowMs; 431 } 432 requestAnimationFrame(animationFrame); 433} 434 435btnCalcular.addEventListener('click', handleCalcular); 436 437loadManifest() 438 .then(() => requestAnimationFrame(animationFrame)) 439 .catch((err) => { 440 statusMsg.textContent = `Error cargando routes/manifest.json: ${err.message}`; 441 console.error(err); 442 });
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.