// aero-radar.jsx — Full-page radar map with MapLibre GL basemap const REGION_DEFS = { south: { center: [-53.84, -29.69], zoom: 5.2 }, brazil: { center: [-52, -14 ], zoom: 3.6 }, global: { center: [-10, 20 ], zoom: 1.6 }, }; function PageRadar({ lang }) { const t = window.WX.T[lang]; const [layer, setLayer] = useState("composite"); const [satProduct, setSatProduct] = useState("geocolor"); const [frame, setFrame] = useState(11); const [playing, setPlaying] = useState(false); const [region, setRegion] = useState("south"); // Lifted map state — basemap lives in BigRadarMap, controls in this scope. const mapRef = useRef(null); const [cursor, setCursor] = useState({ lat: -29.69, lng: -53.84 }); const [pixelDbz, setPixelDbz] = useState(48); const [satTimestamp, setSatTimestamp] = useState(null); useEffect(() => { if (!playing) return; const id = setInterval(() => setFrame(f => (f + 1) % 12), 350); return () => clearInterval(id); }, [playing]); const frameLabel = (f) => f === 11 ? (lang === "pt" ? "AGORA" : "NOW") : `-${(11 - f) * 10}m`; // Map ctl helpers const zoomIn = () => mapRef.current?.zoomIn(); const zoomOut = () => mapRef.current?.zoomOut(); const goHome = () => mapRef.current?.flyTo({ ...REGION_DEFS[region], duration: 900 }); const resetNorth = () => mapRef.current?.rotateTo(0, { duration: 400 }); return (
{/* Top breadcrumb + title */}
{lang === "pt" ? "Início" : "Home"} {" / "}Radar

{lang === "pt" ? "Radar composto" : "Composite radar"}

{lang === "pt" ? "32 estações radar · banda S · resolução 1 km · varredura a cada 10 min · mapa base MapLibre" : "32 radar stations · S-band · 1 km resolution · 10-min sweep · MapLibre basemap"}
{/* Main interactive surface */}
{/* SIDEBAR */} {/* MAP SURFACE */}
{/* Satellite product picker (only when satellite layer is active) */} {layer === "satellite" && (
GOES-16 · {lang === "pt" ? "Produto" : "Product"}
{[ ["geocolor", "GeoColor"], ["13", lang === "pt" ? "IR · Banda 13" : "IR · Band 13"], ["08", lang === "pt" ? "Vapor d'água · 08" : "Water vapor · 08"], ].map(([k, label]) => ( ))} {satTimestamp && (
{lang === "pt" ? "Última" : "Latest"}: {satTimestamp}
)}
)} {/* Top overlays */}
{layer === "satellite" ? `GOES-16 · ${lang === "pt" ? "LOOP 3H" : "3H LOOP"}` : (playing ? (lang === "pt" ? "REPRODUZINDO" : "PLAYING") : frameLabel(frame).toUpperCase())}
{layer !== "satellite" && (
{lang === "pt" ? "Cursor" : "Cursor"}: {cursor.lat.toFixed(2)}°, {cursor.lng.toFixed(2)}°
{lang === "pt" ? "Pixel" : "Pixel"}: {pixelDbz} dBZ
)}
{/* Zoom + compass — only relevant for the Mercator basemap */} {layer !== "satellite" && (
N
)} {/* Bottom timeline scrubber — the GOES GIF carries its own animation */} {layer !== "satellite" && (
{Array.from({ length: 12 }).map((_, i) => ( ))}
)}
{/* Detected cells table */} {lang === "pt" ? "Células detectadas" : "Detected cells"}
); } function MapBtn({ children, onClick }) { return ( ); } function Toggle({ label, checked: initial = false }) { const [on, setOn] = useState(initial); return ( ); } // ───────────────────────────────────────────────────────────────────── // BigRadarMap — MapLibre GL basemap + SVG overlay projected from lat/lon. // All radar/satellite/lightning/wind layers live in the overlay. // ───────────────────────────────────────────────────────────────────── // Radar reflectivity cells — defined in geographic coordinates so they // stay anchored to the ground as the map pans/zooms. Radii in km. const RADAR_BLOBS = [ // Strong core just W of Santa Maria { lat: -29.55, lon: -54.55, r: 55, fill: '#5a9c5e', op: 0.55 }, { lat: -29.62, lon: -54.32, r: 38, fill: '#c5a23a', op: 0.7 }, { lat: -29.68, lon: -54.14, r: 24, fill: '#c97c3a', op: 0.78 }, { lat: -29.72, lon: -54.02, r: 14, fill: '#a8463a', op: 0.9 }, { lat: -29.74, lon: -53.96, r: 6, fill: '#7a2a6a', op: 0.95 }, // Secondary cell E (over SC/PR border) { lat: -26.5, lon: -50.0, r: 40, fill: '#5a9c5e', op: 0.45 }, { lat: -26.7, lon: -49.8, r: 22, fill: '#c5a23a', op: 0.55 }, // Northern Brazil convection { lat: -5.0, lon: -55.0, r: 90, fill: '#5a9c5e', op: 0.4 }, { lat: -4.5, lon: -54.5, r: 50, fill: '#c5a23a', op: 0.5 }, ]; const LIGHTNING = [ { lat: -29.65, lon: -54.10 }, { lat: -29.72, lon: -54.02 }, { lat: -29.74, lon: -53.96 }, { lat: -29.55, lon: -54.55 }, { lat: -29.62, lon: -54.30 }, { lat: -26.7, lon: -49.8 }, ]; const RADAR_SITES = [ { name: 'SMR', lat: -29.69, lon: -53.84, focus: true }, { name: 'POA', lat: -30.03, lon: -51.23 }, { name: 'SAO', lat: -23.55, lon: -46.63 }, { name: 'RIO', lat: -22.91, lon: -43.17 }, { name: 'BSB', lat: -15.78, lon: -47.93 }, { name: 'CWB', lat: -25.43, lon: -49.27 }, ]; // GOES-16 imagery served by NOAA NWS ArcGIS ImageServer as // reprojected-on-the-fly Web Mercator tiles. This lets us drop the image // directly on top of the MapLibre basemap with no projection mismatch. // {bbox-epsg-3857} is replaced by MapLibre with the tile's bbox in 3857. function goesTileUrl(product, time) { // ArcGIS service paths — these are the public NWS endpoints that serve // the GOES-East mosaics as time-aware ImageServers. const service = { geocolor: "GOES_East_GeoColor", "13": "GOES_East_Band13", "08": "GOES_East_Band8", }[product] || "GOES_East_GeoColor"; const base = `https://mapservices.weather.noaa.gov/raster/rest/services/obs/${service}/ImageServer/exportImage`; const params = [ "bbox={bbox-epsg-3857}", "bboxSR=3857", "imageSR=3857", "size=512,512", "format=png32", "transparent=true", "f=image", ]; if (time) params.push("time=" + time); return `${base}?${params.join("&")}`; } const GOES_PRODUCTS = { geocolor: { label: "GeoColor" }, "13": { label: "Band 13 IR" }, "08": { label: "Band 08 WV" }, }; function BigRadarMap({ layer, frame, region, lang, satProduct, mapRef, setCursor, setPixelDbz, setSatTimestamp }) { const containerRef = useRef(null); const [, setTick] = useState(0); // bump after map move/zoom to re-project overlay const [mapReady, setMapReady] = useState(false); // Init MapLibre once. useEffect(() => { if (!containerRef.current || !window.maplibregl) return; const mapStyle = { version: 8, sources: { 'carto-dark': { type: 'raster', tiles: [ 'https://a.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}@2x.png', 'https://b.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}@2x.png', 'https://c.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}@2x.png', 'https://d.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}@2x.png', ], tileSize: 256, attribution: '© OpenStreetMap · © CARTO', }, 'carto-labels': { type: 'raster', tiles: [ 'https://a.basemaps.cartocdn.com/dark_only_labels/{z}/{x}/{y}@2x.png', 'https://b.basemaps.cartocdn.com/dark_only_labels/{z}/{x}/{y}@2x.png', 'https://c.basemaps.cartocdn.com/dark_only_labels/{z}/{x}/{y}@2x.png', 'https://d.basemaps.cartocdn.com/dark_only_labels/{z}/{x}/{y}@2x.png', ], tileSize: 256, }, }, layers: [ { id: 'carto-dark', type: 'raster', source: 'carto-dark' }, { id: 'carto-labels', type: 'raster', source: 'carto-labels' }, ], }; let map; try { map = new window.maplibregl.Map({ container: containerRef.current, style: mapStyle, center: REGION_DEFS[region].center, zoom: REGION_DEFS[region].zoom, attributionControl: false, dragRotate: false, }); } catch (e) { // WebGL unavailable — overlay still renders projection-less. console.warn("MapLibre init failed:", e); return; } map.addControl(new window.maplibregl.AttributionControl({ compact: true }), 'top-left'); map.addControl(new window.maplibregl.ScaleControl({ maxWidth: 80, unit: 'metric' }), 'bottom-left'); mapRef.current = map; const bump = () => setTick(t => (t + 1) % 1000000); map.on('move', bump); map.on('zoom', bump); map.on('load', () => { setMapReady(true); bump(); }); map.on('mousemove', (e) => { setCursor && setCursor({ lat: e.lngLat.lat, lng: e.lngLat.lng }); // synthesize a dBZ value driven by distance to nearest blob let best = 0; for (const b of RADAR_BLOBS) { const dKm = haversine(e.lngLat.lat, e.lngLat.lng, b.lat, b.lon); if (dKm < b.r) best = Math.max(best, Math.round(60 - (dKm / b.r) * 30)); } setPixelDbz && setPixelDbz(best || 0); }); return () => { try { map.remove(); } catch (e) {} mapRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // FlyTo on region change. useEffect(() => { if (!mapRef.current) return; mapRef.current.flyTo({ ...REGION_DEFS[region], duration: 900 }); }, [region]); // GOES-16 image is rendered as a plain below the SVG overlay, // positioned via map.project() of the SSA corners. Plate-carrée corners // along constant lat/lon edges project to an axis-aligned rectangle in // Web Mercator, so a simple left/top/width/height suffices. Cross-origin // works without CORS headers (unlike MapLibre's image source which // needs CORS to upload pixels to a WebGL texture). const [satTick, setSatTick] = useState(0); useEffect(() => { const id = setInterval(() => setSatTick(t => t + 1), 5 * 60 * 1000); return () => clearInterval(id); }, []); useEffect(() => { const showSat = layer === "satellite"; if (!showSat) { setSatTimestamp && setSatTimestamp(null); return; } const d = new Date(); setSatTimestamp && setSatTimestamp( (lang === "pt" ? "loop " : "loop ") + d.toLocaleTimeString(lang === "pt" ? "pt-BR" : "en-US", { hour: "2-digit", minute: "2-digit" }) + " " + (window.WX.DATA.timezone_abbr || "GMT-3")); }, [layer, satProduct, satTick]); // Projection helpers (require map to be initialized) const map = mapRef.current; const project = (lon, lat) => { if (!map) return { x: -9999, y: -9999 }; const p = map.project([lon, lat]); return { x: p.x, y: p.y }; }; const kmToPx = (lat) => { if (!map) return 1; const a = map.project([0, lat]); const b = map.project([0.01, lat]); const pxPerDeg = Math.hypot(b.x - a.x, b.y - a.y) / 0.01; const kmPerDeg = 111.32 * Math.cos(lat * Math.PI / 180); return pxPerDeg / kmPerDeg; }; const seed = 1 + frame * 0.5; // Composite mode shows only radar + base map; satellite mode hides the // map and shows the GOES animated GIF in its native geostationary // projection (no reprojection artifacts). const goesActive = layer === "satellite"; let goesImg = null; if (goesActive) { const prod = GOES_PRODUCTS[satProduct] || GOES_PRODUCTS.geocolor; // Stable src across re-renders; cache-busted only when satTick or // satProduct change, so the animated GIF restarts only on actual refresh. const cacheKey = satTick + "-" + satProduct; goesImg = (
); } return ( <>
{/* GOES-16 sector image (cross-origin , no CORS needed for display) */} {goesImg} {/* SVG overlay anchored to lat/lon via map.project. Hidden in satellite mode (where we show the native GIF instead). */} {!goesActive && {/* RADAR REFLECTIVITY / COMPOSITE */} {(layer === 'composite' || layer === 'reflectivity') && ( {RADAR_BLOBS.map((b, i) => { const p = project(b.lon, b.lat); const r = Math.max(2, b.r * kmToPx(b.lat)); return ; })} )} {/* SATELLITE — real GOES-16 image is added to the map directly as an image source (see useEffect above). No SVG fallback needed. */} {/* LIGHTNING */} {layer === 'lightning' && ( {LIGHTNING.map((l, i) => { const p = project(l.lon, l.lat); const visible = (frame + i) % 3 === 0; return ( ); })} )} {/* WIND — barb grid generated in lat/lon */} {layer === 'wind' && map && ( )} {/* RANGE RINGS around focused site */} {layer === 'reflectivity' && (() => { const p = project(-53.84, -29.69); const ringKm = [60, 120, 180]; return ringKm.map(r => ( )); })()} {/* RADAR SITES */} {RADAR_SITES.map((s, i) => { const p = project(s.lon, s.lat); return ( {s.focus && ( )} {s.name} ); })} } ); } // Wind layer — generate a regular geographic grid of barbs. function WindBarbs({ map, project }) { const bounds = map.getBounds(); const lat0 = bounds.getSouth(), lat1 = bounds.getNorth(); const lon0 = bounds.getWest(), lon1 = bounds.getEast(); // 10x6 grid clipped to bounds; dir varies with longitude (decorative). const cols = 10, rows = 6; const barbs = []; for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { const lat = lat0 + ((r + 0.5) / rows) * (lat1 - lat0); const lon = lon0 + ((c + 0.5) / cols) * (lon1 - lon0); const p = project(lon, lat); const dir = (1.2 * Math.PI) + Math.sin(c * 0.6 + r * 0.4) * 0.35; const len = 22; const dx = Math.cos(dir) * len, dy = Math.sin(dir) * len; const tipX = p.x + dx / 2, tipY = p.y + dy / 2; const a = dir; barbs.push( ); } } return {barbs}; } // Great-circle distance in km function haversine(lat1, lon1, lat2, lon2) { const toRad = (x) => x * Math.PI / 180; const R = 6371; const dLat = toRad(lat2 - lat1); const dLon = toRad(lon2 - lon1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(a)); } // ───────────────────────────────────────────────────────────────────── // Cells table — unchanged // ───────────────────────────────────────────────────────────────────── function CellTable({ lang }) { const rows = [ { id: "C-024", lat: "-29.78", lon: "-54.12", dbz: 52, h: 8.4, mov: "↗ NE 32 km/h", impact: lang === "pt" ? "Severo" : "Severe" }, { id: "C-025", lat: "-29.42", lon: "-54.38", dbz: 44, h: 6.2, mov: "↗ NE 28 km/h", impact: lang === "pt" ? "Forte" : "Strong" }, { id: "C-026", lat: "-30.12", lon: "-53.66", dbz: 38, h: 5.0, mov: "→ E 22 km/h", impact: lang === "pt" ? "Moderado" : "Moderate" }, { id: "C-027", lat: "-28.94", lon: "-55.20", dbz: 32, h: 4.4, mov: "↗ NE 18 km/h", impact: lang === "pt" ? "Fraco" : "Weak" }, { id: "C-028", lat: "-29.65", lon: "-52.10", dbz: 28, h: 3.8, mov: "→ E 14 km/h", impact: lang === "pt" ? "Fraco" : "Weak" }, ]; return ( <>
ID {lang === "pt" ? "Posição" : "Position"} dBZ Top km {lang === "pt" ? "Movimento" : "Movement"} {lang === "pt" ? "Impacto" : "Impact"}
{rows.map((r, i) => (
{r.id} {r.lat}, {r.lon} 45 ? AERO.danger : r.dbz > 30 ? AERO.warm : AERO.ok, fontWeight: 600, }}>{r.dbz} {r.h} {r.mov} 45 ? AERO.danger : r.dbz > 30 ? AERO.warm : AERO.ok, }} /> {r.impact}
))} ); } Object.assign(window, { PageRadar });