// 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 });