PageSourceSearch

https://simuladorcanaryrun.netlify.app/js/horarios.js

js simuladorcanaryrun.netlify.app collected 2026-10-03 11:24:41 UTC 17,151 bytes, 442 lines download raw bytes

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: '&copy; 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.