// LeafletMap.jsx — real geographic map with Leaflet + Supabase data // Uses getSupabase() from Data.jsx (loaded earlier) const LINE_COLORS = { A: '#0078B1', B: '#A72126', C: '#09517D', D: '#006515', E: '#52267D', H: '#D4A200', U: '#FF8C3A', P: '#FFC85C', }; const MAP_ALIASES = { 'Plaza Miserere': 'Plaza De Miserere', 'L. N. Alem': 'Leandro N. Alem', 'Carlos Pellegrini': 'C. Pellegrini', 'Medrano': 'Almagro - Medrano', 'J. M. de Rosas': 'Juan Manuel De Rosas - Villa Urquiza', 'General San Martín': 'San Martin', 'Scalabrini Ortiz': 'R.Scalabrini Ortiz', 'José M. Moreno': 'Jose Maria Moreno', }; function normStation(s) { return s .normalize('NFD').replace(/[\u0300-\u036f]/g, '') .toLowerCase() .replace(/\bav\.\s*/g, 'avenida ') .replace(/\s*-\s*.+$/, '') .trim(); } const BA_CENTER = [-34.615, -58.443]; const BA_ZOOM = 13; const BA_ZOOM_MINI = 12; const TILE_LIGHT = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'; const TILE_DARK = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'; const TILE_ATTR = '© OSM © CARTO'; function getTheme() { return document.documentElement.getAttribute('data-theme') || 'light'; } // ── Interpolation ───────────────────────────────────────────────────────────── // Each "train" = one forecast record: {nextStop, prevStop, line_code, direction_id} // nextStop: {lat, lng, arrival_epoch, departure_epoch} ← where it's heading // prevStop: same shape, or null if this is the first stop on the line // // We estimate that the train departed prevStop ~60 s before arriving at nextStop. // This gives smooth movement across the 5-second animation interval. const TRANSIT_ESTIMATE = 60; // seconds between adjacent stops (estimate) function interpolateTrainPosition(now, train) { const { nextStop, prevStop } = train; // ① Dwelling at next stop if (now >= nextStop.arrival_epoch && now < nextStop.departure_epoch) { return { lat: nextStop.lat, lng: nextStop.lng, atStation: true }; } // ② Already departed – hold at stop until next 30 s refresh brings new data if (now >= nextStop.departure_epoch) { return { lat: nextStop.lat, lng: nextStop.lng, atStation: false }; } // ③ En route: interpolate from prevStop toward nextStop const fromLat = prevStop ? prevStop.lat : nextStop.lat; const fromLng = prevStop ? prevStop.lng : nextStop.lng; const depPrev = nextStop.arrival_epoch - TRANSIT_ESTIMATE; if (now >= depPrev) { const t = Math.min((now - depPrev) / TRANSIT_ESTIMATE, 1); return { lat: fromLat + (nextStop.lat - fromLat) * t, lng: fromLng + (nextStop.lng - fromLng) * t, atStation: false, }; } // ④ Before even departing prevStop – show at prevStop return { lat: fromLat, lng: fromLng, atStation: true }; } // ── Train marker icon ───────────────────────────────────────────────────────── function makeTrainIcon(color, isLight) { return L.divIcon({ className: '', html: `
`, iconSize: [22, 22], iconAnchor: [11, 11], }); } // ───────────────────────────────────────────────────────────────────────────── // LeafletMap component // ───────────────────────────────────────────────────────────────────────────── function LeafletMap({ selectedLines = ['A','B','C','D','E','H','U','P'], showTrains = true, showEntrances = false, mini = false, onLineTap, }) { const divRef = React.useRef(null); const mapRef = React.useRef(null); const tileLayerRef = React.useRef(null); const polyRefs = React.useRef({}); const stationRefs = React.useRef({}); const trainMarkerRefs = React.useRef({}); // trainId (trip_id_stop_id) → L.marker const entranceRefs = React.useRef([]); const userMarkerRef = React.useRef(null); const watchIdRef = React.useRef(null); const scheduleRef = React.useRef({}); // trainId → {line_code, direction_id, nextStop, prevStop} const fetchTimerRef = React.useRef(null); const animTimerRef = React.useRef(null); const themeObserverRef = React.useRef(null); // live refs for toggle state (readable from timer callbacks) const showTrainsRef = React.useRef(showTrains); const showEntrancesRef = React.useRef(showEntrances); const selectedLinesRef = React.useRef(selectedLines); React.useEffect(() => { showTrainsRef.current = showTrains; }, [showTrains]); React.useEffect(() => { showEntrancesRef.current = showEntrances; }, [showEntrances]); React.useEffect(() => { selectedLinesRef.current = selectedLines; }, [selectedLines]); // ── init map once ────────────────────────────────────────── React.useEffect(() => { if (mapRef.current) return; const map = L.map(divRef.current, { center: BA_CENTER, zoom: mini ? BA_ZOOM_MINI : BA_ZOOM, zoomControl: false, attributionControl: !mini, dragging: !mini, scrollWheelZoom: !mini, doubleClickZoom: !mini, touchZoom: !mini, keyboard: !mini, }); const tileUrl = getTheme() === 'dark' ? TILE_DARK : TILE_LIGHT; tileLayerRef.current = L.tileLayer(tileUrl, { attribution: TILE_ATTR, maxZoom: 19 }).addTo(map); // Watch for dark/light theme changes and swap tile layer themeObserverRef.current = new MutationObserver(() => { const url = getTheme() === 'dark' ? TILE_DARK : TILE_LIGHT; if (tileLayerRef.current) { tileLayerRef.current.setUrl(url); } }); themeObserverRef.current.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); if (!mini) L.control.zoom({ position: 'bottomright' }).addTo(map); mapRef.current = map; // Static layers loadLines(map); loadEntrances(map); if (!mini) { // User location (point 3) if (navigator.geolocation) { watchIdRef.current = navigator.geolocation.watchPosition( pos => updateUserLocation(map, pos.coords.latitude, pos.coords.longitude), () => {}, { enableHighAccuracy: true, maximumAge: 5000 } ); } // Live train fetch/animation — disabled (representación en vivo desactivada) // fetchSchedule(map); // fetchTimerRef.current = setInterval(() => fetchSchedule(map), 30000); // animTimerRef.current = setInterval(() => renderTrains(map), 5000); } return () => { if (fetchTimerRef.current) clearInterval(fetchTimerRef.current); if (animTimerRef.current) clearInterval(animTimerRef.current); if (themeObserverRef.current) themeObserverRef.current.disconnect(); if (watchIdRef.current != null) navigator.geolocation.clearWatch(watchIdRef.current); map.remove(); mapRef.current = null; }; }, []); // ── showTrains toggle ────────────────────────────────────── React.useEffect(() => { const map = mapRef.current; if (!map || mini) return; if (!showTrains) { Object.values(trainMarkerRefs.current).forEach(m => { if (map.hasLayer(m)) map.removeLayer(m); }); } else { renderTrains(map); } }, [showTrains]); // ── showEntrances toggle ─────────────────────────────────── React.useEffect(() => { const map = mapRef.current; if (!map) return; entranceRefs.current.forEach(m => { if (showEntrances) { if (!map.hasLayer(m)) m.addTo(map); } else { if (map.hasLayer(m)) map.removeLayer(m); } }); }, [showEntrances]); // ── selectedLines filter ─────────────────────────────────── React.useEffect(() => { const map = mapRef.current; if (!map) return; Object.entries(polyRefs.current).forEach(([code, poly]) => { if (selectedLines.includes(code)) { if (!map.hasLayer(poly)) poly.addTo(map); } else { if (map.hasLayer(poly)) map.removeLayer(poly); } }); Object.entries(stationRefs.current).forEach(([code, markers]) => { markers.forEach(m => { if (selectedLines.includes(code)) { if (!map.hasLayer(m)) m.addTo(map); } else { if (map.hasLayer(m)) map.removeLayer(m); } }); }); }, [selectedLines]); // ── helpers ──────────────────────────────────────────────── async function loadLines(map) { try { const { data, error } = await getSupabase().rpc('get_stations_with_lines'); if (error || !data) return; const grouped = {}; data.forEach(row => { if (!grouped[row.line_code]) grouped[row.line_code] = {}; const ll = [row.lat, row.lng]; grouped[row.line_code][row.canonical_name] = ll; grouped[row.line_code][normStation(row.canonical_name)] = ll; }); LINES.forEach(lineData => { const code = lineData.id; const cmap = grouped[code]; if (!cmap) return; const pairs = lineData.stations.map(name => { const alias = MAP_ALIASES[name]; const ll = cmap[name] || (alias && cmap[alias]) || (alias && cmap[normStation(alias)]) || cmap[normStation(name)] || null; return ll ? { name, ll } : null; }).filter(Boolean); if (pairs.length < 2) return; const latlngs = pairs.map(p => p.ll); const color = LINE_COLORS[code] || '#888'; const poly = L.polyline(latlngs, { color, weight: mini ? 4 : 5, opacity: 0.85, lineJoin: 'round' }); if (selectedLines.includes(code)) poly.addTo(map); if (!mini) poly.on('click', () => onLineTap && onLineTap(code)); polyRefs.current[code] = poly; const markers = pairs.map(({ name, ll }) => { const isClosed = typeof CLOSED_TODAY !== 'undefined' && CLOSED_TODAY.includes(name); const m = L.circleMarker(ll, { radius: mini ? 3 : 5, color, weight: 2, fillColor: isClosed ? '#FF6B6B' : 'white', fillOpacity: 1, opacity: isClosed ? 0.7 : 1, }); if (!mini) m.bindTooltip(name, { direction: 'top', offset: [0, -6], className: 'leaflet-station-tip' }); if (selectedLines.includes(code)) m.addTo(map); return m; }); stationRefs.current[code] = markers; }); } catch (e) { console.warn('LeafletMap: loadLines error', e); } } async function loadEntrances(map) { try { const { data, error } = await getSupabase().rpc('get_entrances_geo'); if (error || !data) return; data.forEach(en => { if (!en.lat || !en.lng) return; const color = LINE_COLORS[en.line_code] || '#888'; const isLight = en.line_code === 'H' || en.line_code === 'P'; const icon = L.divIcon({ className: '', html: `
`, iconSize: [8, 8], iconAnchor: [4, 4], }); const m = L.marker([en.lat, en.lng], { icon, zIndexOffset: 100 }); if (!mini) { const parts = []; if (en.destino_boca) parts.push(en.destino_boca); if (en.is_accessible) parts.push('♿ Accesible'); if (en.ascensor) parts.push('Ascensor'); if (en.escalera_mecanica) parts.push('Escalera mecánica'); if (en.closes_on_weekend) parts.push('Cierra fines de semana'); m.bindTooltip( `${en.station_name} · L${en.line_code}
${parts.join(' · ') || 'Boca de acceso'}`, { direction: 'top', offset: [0, -6], className: 'leaflet-entrance-tip' } ); } if (showEntrancesRef.current) m.addTo(map); entranceRefs.current.push(m); }); } catch (e) { console.warn('LeafletMap: loadEntrances error', e); } } // Fetch schedule from DB (called every 30 s). // The RPC now returns ONE ROW PER PHYSICAL TRAIN (= one row per stop on the line), // because the BA API encodes multiple simultaneous trains under the same trip_id. async function fetchSchedule(map) { try { const { data, error } = await getSupabase().rpc('get_trips_with_schedule'); if (error || !data) { console.warn('LeafletMap: fetchSchedule', error); return; } // 1. Group stops by trip_id and sort in physical travel order const byTrip = {}; data.forEach(row => { if (!byTrip[row.trip_id]) byTrip[row.trip_id] = []; byTrip[row.trip_id].push(row); }); // 2. For each trip build a map: trainId → {line_code, direction_id, nextStop, prevStop} // trainId = `${trip_id}_${stop_id}` — one unique train per stop const trains = {}; Object.entries(byTrip).forEach(([tripId, stops]) => { // is_ascending is the same for all rows of a trip; use the first row const isAsc = stops[0]?.is_ascending !== false; // Sort ascending by stop_num; for descending trips the "previous" stop // in travel direction is the one with the HIGHER index in this sorted array const sorted = [...stops].sort((a, b) => a.stop_num - b.stop_num); sorted.forEach((stop, idx) => { // prevStop in physical travel direction: // ascending trip (B11): came from lower stop_num → sorted[idx-1] // descending trip (B01): came from higher stop_num → sorted[idx+1] const prevStop = isAsc ? (idx > 0 ? sorted[idx - 1] : null) : (idx < sorted.length - 1 ? sorted[idx + 1] : null); trains[`${tripId}_${stop.stop_id}`] = { line_code: stop.line_code, direction_id: stop.direction_id, nextStop: { lat: stop.lat, lng: stop.lng, arrival_epoch: stop.arrival_epoch, departure_epoch: stop.departure_epoch, stop_id: stop.stop_id, }, // prevStop only needs coords; epoch is used just for dwell check (unused here) prevStop: prevStop ? { lat: prevStop.lat, lng: prevStop.lng } : null, }; }); }); scheduleRef.current = trains; renderTrains(map); } catch (e) { console.warn('LeafletMap: fetchSchedule error', e); } } // Recompute positions every 5 s using client-side interpolation. // scheduleRef now holds ONE entry per physical train (trip_id + stop_id). function renderTrains(map) { if (!showTrainsRef.current) return; const now = Date.now() / 1000; const trains = scheduleRef.current; const lines = selectedLinesRef.current; Object.entries(trains).forEach(([trainId, train]) => { if (!lines.includes(train.line_code)) { const m = trainMarkerRefs.current[trainId]; if (m && map.hasLayer(m)) map.removeLayer(m); return; } const pos = interpolateTrainPosition(now, train); if (!pos) return; const color = LINE_COLORS[train.line_code] || '#888'; const ll = [pos.lat, pos.lng]; if (trainMarkerRefs.current[trainId]) { trainMarkerRefs.current[trainId].setLatLng(ll); if (!map.hasLayer(trainMarkerRefs.current[trainId])) { trainMarkerRefs.current[trainId].addTo(map); } } else { const icon = makeTrainIcon(color); const m = L.marker(ll, { icon, zIndexOffset: 400 }); const dir = train.direction_id === 0 ? '→' : '←'; m.bindTooltip(`Línea ${train.line_code} ${dir}`, { direction: 'top', offset: [0, -13], className: 'leaflet-train-tip', }); m.addTo(map); trainMarkerRefs.current[trainId] = m; } }); // Remove stale markers (trains no longer in schedule) Object.keys(trainMarkerRefs.current).forEach(trainId => { if (!trains[trainId]) { const m = trainMarkerRefs.current[trainId]; if (map.hasLayer(m)) map.removeLayer(m); delete trainMarkerRefs.current[trainId]; } }); } // User location — pulsing blue dot (point 3) function updateUserLocation(map, lat, lng) { if (userMarkerRef.current) { userMarkerRef.current.setLatLng([lat, lng]); return; } const icon = L.divIcon({ className: '', html: `
`, iconSize: [20, 20], iconAnchor: [10, 10], }); userMarkerRef.current = L.marker([lat, lng], { icon, zIndexOffset: 1000 }).addTo(map); userMarkerRef.current.bindTooltip('Tu ubicación', { permanent: false, direction: 'top' }); } return
; } Object.assign(window, { LeafletMap });