// 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" ? "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 */}
{lang === "pt" ? "Camada" : "Layer"}
{[
["composite", lang === "pt" ? "Composto radar+sat" : "Radar + satellite"],
["reflectivity", lang === "pt" ? "Refletividade" : "Reflectivity"],
["satellite", lang === "pt" ? "Satélite (GOES-16)" : "Satellite (GOES-16)"],
["lightning", lang === "pt" ? "Descargas atmosféricas" : "Lightning"],
["wind", lang === "pt" ? "Vento 10 m" : "Wind 10 m"],
].map(([k, label]) => (
setLayer(k)} style={{
textAlign: 'left', padding: '8px 12px', borderRadius: 4,
background: layer === k ? AERO.ink : 'transparent',
color: layer === k ? AERO.paper : AERO.ink,
border: 'none', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 13,
}}>{label}
))}
{lang === "pt" ? "Região" : "Region"}
{[
["south", lang === "pt" ? "Sul do Brasil" : "Southern Brazil"],
["brazil", "Brasil"],
["global", lang === "pt" ? "Global" : "Global"],
].map(([k, label]) => (
setRegion(k)} style={{
textAlign: 'left', padding: '8px 12px', borderRadius: 4,
background: region === k ? AERO.paperDeep : 'transparent',
color: AERO.ink, border: 'none', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 13,
fontWeight: region === k ? 500 : 400,
}}>{label}
))}
{lang === "pt" ? "Sobreposições" : "Overlays"}
{t.reflectivity} (dBZ)
{["#3a6f4b", "#5a9c5e", "#c5a23a", "#c97c3a", "#a8463a", "#7a2a6a"].map((c, i) =>
)}
5 20 30 40 50 60+
{lang === "pt" ? "Última varredura" : "Last sweep"}: 08:30 GMT-3
{lang === "pt" ? "Próxima" : "Next"}: 08:40 GMT-3
{lang === "pt" ? "Latência" : "Latency"}: 230 ms
{/* 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]) => (
setSatProduct(k)} style={{
padding: '4px 10px', borderRadius: 4,
background: satProduct === k ? 'rgba(255,255,255,0.18)' : 'transparent',
color: satProduct === k ? '#fff' : 'rgba(255,255,255,0.65)',
border: 'none', cursor: 'pointer', textAlign: 'left',
fontFamily: "'JetBrains Mono', monospace", fontSize: 10.5,
letterSpacing: 0.5,
}}>{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" && (
setPlaying(p => !p)} style={{
width: 34, height: 34, borderRadius: 17,
border: '0.5px solid rgba(255,255,255,0.3)',
background: 'rgba(255,255,255,0.08)', color: '#fff',
cursor: 'pointer', display: 'flex',
alignItems: 'center', justifyContent: 'center',
}}>
{playing
?
: }
{Array.from({ length: 12 }).map((_, i) => (
setFrame(i)} style={{
position: 'absolute', left: `${(i / 11) * 100}%`, top: 8,
transform: 'translateX(-50%)',
background: 'transparent', border: 'none', padding: 0, cursor: 'pointer',
color: '#fff',
}}>
{frameLabel(i)}
))}
)}
{/* Detected cells table */}
{lang === "pt" ? "Células detectadas" : "Detected cells"}
);
}
function MapBtn({ children, onClick }) {
return (
{children}
);
}
function Toggle({ label, checked: initial = false }) {
const [on, setOn] = useState(initial);
return (
setOn(!on)}>
{label}
);
}
// ─────────────────────────────────────────────────────────────────────
// 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 });