/* =========================================================================== EcoEsperanza — Mapa de Sensores Ambientales A full-screen environmental-sensor explorer inspired by openSenseMap, with an original EcoEsperanza identity. React (inline) + Leaflet + Leaflet.marker- cluster. Adapted from the requested React/Vite/Tailwind/React-Leaflet stack to this project's runtime (inline Babel + design-system CSS tokens). DATA: real openSenseMap public API with a 24h localStorage cache + graceful fallback to a worldwide seed dataset (so the UI is always complete, and the live integration is one toggle away). The cache TTL is a single constant — change CACHE_TTL to go hourly, or swap loadStations() for a backend call. =========================================================================== */ const { useState, useEffect, useMemo, useRef, useCallback } = React; const Icon = ({ n, ...p }) => ; function drawIcons() { if (window.lucide) window.lucide.createIcons(); } /* ---- Config -------------------------------------------------------------- */ const OSM_API = 'https://api.opensensemap.org'; const CACHE_KEY = 'eco_sensormap_v1'; const CACHE_TTL = 24 * 60 * 60 * 1000; // 24h — set to 60*60*1000 for hourly // EcoEsperanza's own senseBoxes (openSenseMap box ids). These are fetched // individually and highlighted in gold, even if outside the bulk response. // Add a station: paste its id from opensensemap.org/explore/ const ECO_BOX_IDS = [ '6a05f0a0d46e9100071fef83', // EPA1 ]; const ACTIVE_WINDOW = 24 * 60 * 60 * 1000; // a station is "activa" if it reported < 24h ago // Live mode pulls the WHOLE openSenseMap network (no bbox). Clustering renders it. const LIVE_CAP = 15000; // safety cap on rendered live boxes /* ---- Sensor type normalisation ------------------------------------------- */ const SENSOR_TYPES = [ { key: 'temp', label: 'Temperatura', unit: '°C', icon: 'thermometer', re: /temp/i }, { key: 'hum', label: 'Humedad', unit: '%', icon: 'droplets', re: /hum|feucht/i }, { key: 'pm25', label: 'PM2.5', unit: 'µg/m³', icon: 'wind', re: /pm.?2\.?5|pm25/i }, { key: 'pm10', label: 'PM10', unit: 'µg/m³', icon: 'cloud', re: /pm.?10/i }, { key: 'pres', label: 'Presión', unit: 'hPa', icon: 'gauge', re: /press|druck|presi/i }, ]; const typeOf = (title, unit) => { const t = `${title || ''} ${unit || ''}`; const hit = SENSOR_TYPES.find(s => s.re.test(t)); return hit ? hit.key : null; }; const typeMeta = (key) => SENSOR_TYPES.find(s => s.key === key) || { label: key, unit: '', icon: 'activity' }; /* ---- Air-quality colour by PM2.5 (µg/m³) --------------------------------- */ const pmBand = (pm) => pm == null ? { label: 'Sin dato', hex: '#93A399' } : pm <= 12 ? { label: 'Buena', hex: '#2BA557' } : pm <= 35 ? { label: 'Moderada', hex: '#E9A81C' } : pm <= 55 ? { label: 'Dañina (sensibles)', hex: '#C98A00' } : { label: 'Dañina', hex: '#C0392B' }; const pmOf = (st) => { const s = st.sensors.find(x => x.type === 'pm25'); return s && s.value != null ? s.value : null; }; const isActive = (st) => st.updatedAt != null && (Date.now() - st.updatedAt) < ACTIVE_WINDOW; function timeAgo(ts) { if (ts == null) return 'sin datos'; const s = Math.max(0, Math.round((Date.now() - ts) / 1000)); if (s < 60) return `hace ${s}s`; const m = Math.round(s / 60); if (m < 60) return `hace ${m} min`; const h = Math.round(m / 60); if (h < 24) return `hace ${h} h`; return `hace ${Math.round(h / 24)} d`; } /* ---- Coarse country lookup for live boxes (no country field in OSM) ------ */ const COUNTRY_BOXES = [ ['Colombia', -79, -4, -67, 13], ['Brasil', -74, -34, -34, 5], ['Argentina', -73, -55, -53, -21], ['España', -10, 36, 4, 44], ['Alemania', 5.8, 47, 15.1, 55], ['Francia', -5, 41, 9, 51], ['Reino Unido', -8, 50, 2, 59], ['México', -118, 14, -86, 33], ['Perú', -82, -19, -68, 0], ['Chile', -76, -56, -66, -17], ['Ecuador', -81, -5, -75, 2], ['EE. UU.', -125, 25, -66, 49], ]; const countryAt = (lat, lng) => { const hit = COUNTRY_BOXES.find(([, w, s, e, n]) => lng >= w && lng <= e && lat >= s && lat <= n); return hit ? hit[0] : 'Otro'; }; /* ========================================================================= SEED dataset — worldwide, deterministic. Includes EcoEsperanza's own 7. ========================================================================= */ function rng(seed) { let s = seed % 2147483647; if (s <= 0) s += 2147483646; return () => (s = (s * 16807) % 2147483647) / 2147483647; } const SEED_CITIES = [ ['Bogotá', 'Colombia', 4.61, -74.08, 6], ['Medellín', 'Colombia', 6.25, -75.56, 4], ['Cali', 'Colombia', 3.45, -76.53, 3], ['São Paulo', 'Brasil', -23.55, -46.63, 8], ['Rio de Janeiro', 'Brasil', -22.91, -43.20, 5], ['Brasília', 'Brasil', -15.79, -47.88, 3], ['Provincia de Buenos Aires', 'Argentina', -34.60, -58.38, 6], ['Córdoba', 'Argentina', -31.42, -64.18, 3], ['Santiago', 'Chile', -33.45, -70.65, 5], ['Lima', 'Perú', -12.05, -77.04, 4], ['Quito', 'Ecuador', -0.18, -78.47, 3], ['Ciudad de México', 'México', 19.43, -99.13, 8], ['Guadalajara', 'México', 20.67, -103.35, 4], ['Nueva York', 'EE. UU.', 40.71, -74.00, 6], ['Los Ángeles', 'EE. UU.', 34.05, -118.24, 5], ['Chicago', 'EE. UU.', 41.88, -87.63, 4], ['Toronto', 'Canadá', 43.65, -79.38, 4], ['Madrid', 'España', 40.42, -3.70, 6], ['Barcelona', 'España', 41.39, 2.17, 5], ['Valencia', 'España', 39.47, -0.38, 5], ['Berlín', 'Alemania', 52.52, 13.40, 10], ['Hamburgo', 'Alemania', 53.55, 10.00, 6], ['Múnich', 'Alemania', 48.14, 11.58, 6], ['París', 'Francia', 48.85, 2.35, 6], ['Londres', 'Reino Unido', 51.51, -0.13, 6], ['Ámsterdam', 'Países Bajos', 52.37, 4.90, 4], ['Roma', 'Italia', 41.90, 12.50, 4], ['Lisboa', 'Portugal', 38.72, -9.14, 3], ['El Cairo', 'Egipto', 30.04, 31.24, 3], ['Lagos', 'Nigeria', 6.52, 3.38, 3], ['Nairobi', 'Kenia', -1.29, 36.82, 3], ['Ciudad del Cabo', 'Sudáfrica', -33.92, 18.42, 3], ['Delhi', 'India', 28.61, 77.21, 6], ['Bombay', 'India', 19.08, 72.88, 4], ['Bangkok', 'Tailandia', 13.76, 100.50, 4], ['Yakarta', 'Indonesia', -6.21, 106.85, 4], ['Tokio', 'Japón', 35.68, 139.69, 6], ['Seúl', 'Corea del Sur', 37.57, 126.98, 4], ['Pekín', 'China', 39.90, 116.40, 5], ['Sídney', 'Australia', -33.87, 151.21, 4], ]; function buildSeed() { const out = []; // EcoEsperanza's own network (highlighted) const eco = [ ['EPA1 · Provincia de Buenos Aires', 'Argentina', -38.821392, -61.373956, 24, 18.5, 64], ['Ebog1 · Bogotá', 'Colombia', 4.658722, -74.059544, 20, 14.6, 76], ['Ebog2 · Bogotá', 'Colombia', 4.659200, -74.058900, 23, 14.4, 77], ['Eval1 · Valencia', 'España', 39.466978, -0.355008, 10, 21.8, 66], ['Eval2 · Valencia', 'España', 39.467450, -0.354400, 13, 21.6, 67], ['Esao1 · São Paulo', 'Brasil', -23.588842, -46.730317, 17, 26.5, 60], ['Esao2 · São Paulo', 'Brasil', -23.588300, -46.729700, 19, 26.7, 59], ['Bogotá · Centro', 'Colombia', 4.6097, -74.0817, 22, 14.2, 78], ['Bogotá · Kennedy', 'Colombia', 4.6280, -74.1500, 31, 15.1, 74], ['São Paulo · Sé', 'Brasil', -23.5505, -46.6333, 18, 26.8, 61], ['São Paulo · Pinheiros', 'Brasil', -23.5670, -46.7020, 15, 27.4, 58], ['Valencia · Ciutat Vella', 'España', 39.4699, -0.3763, 9, 22.1, 65], ['Valencia · Cabanyal', 'España', 39.4720, -0.3290, 12, 22.6, 69], ['Provincia de Buenos Aires · Centro', 'Argentina', -34.6037, -58.3816, 29, 19.4, 70], ]; eco.forEach((e, i) => out.push({ id: 'eco-' + i, name: e[0], country: e[1], lat: e[2], lng: e[3], eco: true, updatedAt: Date.now() - (3 + i) * 60 * 1000, sensors: [ { type: 'temp', value: e[5] }, { type: 'hum', value: e[6] }, { type: 'pm25', value: e[4] }, { type: 'pm10', value: Math.round(e[4] * 1.6) }, { type: 'pres', value: 1010 + (i % 5) }, ], })); // Surrounding community/citizen stations SEED_CITIES.forEach((c, ci) => { const r = rng(ci + 7); for (let i = 0; i < c[4]; i++) { const lat = c[2] + (r() - 0.5) * 0.22, lng = c[3] + (r() - 0.5) * 0.22; const has = (p) => r() < p; const sensors = []; sensors.push({ type: 'temp', value: +(8 + r() * 26).toFixed(1) }); sensors.push({ type: 'hum', value: Math.round(40 + r() * 50) }); if (has(0.85)) sensors.push({ type: 'pm25', value: Math.round(4 + r() * 60) }); if (has(0.6)) sensors.push({ type: 'pm10', value: Math.round(8 + r() * 80) }); if (has(0.5)) sensors.push({ type: 'pres', value: Math.round(995 + r() * 30) }); const inactive = r() < 0.16; out.push({ id: `seed-${ci}-${i}`, name: `${c[0]} #${i + 1}`, country: c[1], lat, lng, eco: false, updatedAt: inactive ? Date.now() - (ACTIVE_WINDOW + r() * 5 * ACTIVE_WINDOW) : Date.now() - r() * ACTIVE_WINDOW * 0.9, sensors, }); } }); return out; } const SEED = buildSeed(); /* ========================================================================= openSenseMap service + cache ========================================================================= */ function normalizeBox(b) { const coords = (b.currentLocation && b.currentLocation.coordinates) || (b.loc && b.loc[0] && b.loc[0].geometry && b.loc[0].geometry.coordinates); if (!coords) return null; const [lng, lat] = coords; const sensors = (b.sensors || []).map(s => ({ type: typeOf(s.title, s.unit), title: s.title, unit: s.unit, value: s.lastMeasurement ? +parseFloat(s.lastMeasurement.value).toFixed(1) : null, })).filter(s => s.type); return { id: b._id, name: b.name || 'senseBox', country: countryAt(lat, lng), lat, lng, eco: ECO_BOX_IDS.includes(b._id), updatedAt: b.lastMeasurementAt ? Date.parse(b.lastMeasurementAt) : null, sensors, }; } /* Fetch the pinned EcoEsperanza boxes by id (real name + coords, highlighted). */ async function fetchPinned() { const results = await Promise.all(ECO_BOX_IDS.map(async (id) => { try { const res = await fetch(`${OSM_API}/boxes/${id}?format=json`); if (!res.ok) return null; const b = await res.json(); const st = normalizeBox(b); if (st) st.eco = true; return st; } catch { return null; } })); return results.filter(Boolean); } async function fetchLive() { const ctrl = new AbortController(); const to = setTimeout(() => ctrl.abort(), 15000); try { // Whole network, with last measurements. Cached 24h so this runs once a day. const res = await fetch(`${OSM_API}/boxes?format=json&full=true`, { signal: ctrl.signal }); if (!res.ok) throw new Error('osm ' + res.status); const boxes = await res.json(); const norm = boxes.map(normalizeBox).filter(Boolean).filter(s => s.sensors.length).slice(0, LIVE_CAP); if (!norm.length) throw new Error('empty'); // pin EcoEsperanza's own boxes (real data) at the front, de-duplicated const pinned = await fetchPinned(); const pinnedIds = new Set(pinned.map(s => s.id)); return pinned.concat(norm.filter(s => !pinnedIds.has(s.id))); } finally { clearTimeout(to); } } const readCache = () => { try { const c = JSON.parse(localStorage.getItem(CACHE_KEY)); return c && Array.isArray(c.data) ? c : null; } catch { return null; } }; const writeCache = (data) => { try { localStorage.setItem(CACHE_KEY, JSON.stringify({ ts: Date.now(), data })); } catch {} }; /* ========================================================================= Map (Leaflet + clustering) — imperative, synced to props via effects ========================================================================= */ function SensorMap({ stations, selectedId, onSelect, registerFocus }) { const elRef = useRef(null), mapRef = useRef(null), clusterRef = useRef(null), marksRef = useRef({}); useEffect(() => { if (!window.L || !elRef.current || mapRef.current) return; const map = window.L.map(elRef.current, { scrollWheelZoom: true, worldCopyJump: true }).setView([15, -30], 2); window.L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18, attribution: '© OpenStreetMap' }).addTo(map); const cluster = window.L.markerClusterGroup({ maxClusterRadius: 48, showCoverageOnHover: false, chunkedLoading: true }); map.addLayer(cluster); mapRef.current = map; clusterRef.current = cluster; setTimeout(() => map.invalidateSize(), 200); return () => { map.remove(); mapRef.current = null; }; }, []); // rebuild markers when the filtered list changes useEffect(() => { const L = window.L, cluster = clusterRef.current, map = mapRef.current; if (!L || !cluster) return; cluster.clearLayers(); marksRef.current = {}; stations.forEach(st => { const pm = pmOf(st); const band = pmBand(pm); const color = st.eco ? '#E9A81C' : (isActive(st) ? band.hex : '#93A399'); const m = L.circleMarker([st.lat, st.lng], { radius: st.eco ? 9 : 7, color: '#fff', weight: st.eco ? 3 : 2, fillColor: color, fillOpacity: 1, }); m.bindPopup(popupHTML(st)); m.bindTooltip(st.name + (st.eco ? ' · EcoEsperanza' : ''), { direction: 'top', offset: [0, -6], className: 'sx-tt' }); m.on('click', () => onSelect(st.id)); marksRef.current[st.id] = m; cluster.addLayer(m); }); if (stations.length && map) { const g = L.featureGroup(Object.values(marksRef.current)); const b = g.getBounds(); if (b.isValid()) map.fitBounds(b, { padding: [40, 40], maxZoom: 6 }); } }, [stations]); // expose a focus(id) fn to the parent (list click → fly + open popup) useEffect(() => { registerFocus((id) => { const m = marksRef.current[id], cluster = clusterRef.current, map = mapRef.current; if (!m || !cluster || !map) return; cluster.zoomToShowLayer(m, () => { map.setView(m.getLatLng(), Math.max(map.getZoom(), 11), { animate: true }); m.openPopup(); }); }); }, [registerFocus]); return
; } function popupHTML(st) { const rows = st.sensors.map(s => { const meta = typeMeta(s.type); return `
${meta.label}${s.value == null ? '—' : s.value} ${s.unit || meta.unit}
`; }).join(''); return `
${st.name}${st.eco ? 'EcoEsperanza' : ''}
` + `
${st.country} · actualizado ${timeAgo(st.updatedAt)}
` + `
${rows || 'Sin sensores'}
`; } /* ========================================================================= Sidebar ========================================================================= */ function Sidebar({ stations, total, active, filters, setFilters, selectedId, onSelect, open }) { const countries = useMemo(() => [...new Set(SEED.concat([]).map(s => s.country))], []); const liveCountries = useMemo(() => [...new Set(stations.map(s => s.country))].sort(), [stations]); return ( ); } /* ========================================================================= App ========================================================================= */ function App() { const [all, setAll] = useState(SEED); const [source, setSource] = useState('muestra'); // 'muestra' | 'vivo' const [loading, setLoading] = useState(false); const [updatedAt, setUpdatedAt] = useState(Date.now()); const [selectedId, setSelectedId] = useState(null); const [sideOpen, setSideOpen] = useState(false); const [dark, setDark] = useState(false); const [filters, setFilters] = useState({ q: '', types: [], country: '', status: 'all' }); const focusRef = useRef(() => {}); const loadLive = useCallback(async (force) => { const cached = readCache(); if (!force && cached && Date.now() - cached.ts < CACHE_TTL) { setAll(cached.data); setSource('vivo'); setUpdatedAt(cached.ts); return; } setLoading(true); try { const data = await fetchLive(); writeCache(data); setAll(data); setSource('vivo'); setUpdatedAt(Date.now()); } catch (e) { setAll(SEED); setSource('muestra'); setUpdatedAt(Date.now()); } finally { setLoading(false); } }, []); // On mount: use fresh cache, else attempt live in the background (seed shows instantly). useEffect(() => { const cached = readCache(); if (cached && Date.now() - cached.ts < CACHE_TTL) { setAll(cached.data); setSource('vivo'); setUpdatedAt(cached.ts); } else { loadLive(false); } }, [loadLive]); useEffect(() => { document.documentElement.dataset.theme = dark ? 'dark' : ''; }, [dark]); useEffect(() => { drawIcons(); }); const total = all.length; const active = useMemo(() => all.filter(isActive).length, [all]); const filtered = useMemo(() => { const q = filters.q.trim().toLowerCase(); return all.filter(st => { if (q && !st.name.toLowerCase().includes(q) && !st.country.toLowerCase().includes(q)) return false; if (filters.country && st.country !== filters.country) return false; if (filters.status === 'active' && !isActive(st)) return false; if (filters.status === 'inactive' && isActive(st)) return false; if (filters.types.length && !filters.types.every(t => st.sensors.some(s => s.type === t))) return false; return true; }); }, [all, filters]); const onSelect = useCallback((id) => { setSelectedId(id); focusRef.current(id); if (window.innerWidth <= 860) setSideOpen(false); }, []); const registerFocus = useCallback((fn) => { focusRef.current = fn; }, []); return (
Mapa de Sensores Ambientales Red global de monitoreo · datos abiertos · EcoEsperanza
{source === 'vivo' ? 'En vivo' : 'Muestra'} · {timeAgo(updatedAt)} Volver al sitio
Calidad del aire (PM2.5)
Buena ≤ 12
Moderada ≤ 35
Dañina > 55
EcoEsperanza
{loading &&
Consultando openSenseMap…
}
); } ReactDOM.createRoot(document.getElementById('root')).render(); setTimeout(drawIcons, 60); setTimeout(drawIcons, 300);