import maplibregl from "maplibre-gl"; import { useEffect, useMemo, useRef, useState } from "react"; import { api } from "../api"; import type { Sentinel2AOI } from "../types"; import landGeo from "../assets/land-110m.json"; import CompareView from "./CompareView"; // Vendored Natural Earth 110 m land (public domain, inlined at build — no CDN at runtime). const LAND = landGeo as unknown as GeoJSON.FeatureCollection; interface Props { aois: Sentinel2AOI[]; } // Minimal self-hosted locator: dark land polygons + a pin per AOI. Clicking a pin selects it. // (A world basemap would need CDN tiles; the land outline keeps the app's no-CDN-at-runtime ethos.) function LocatorMap({ aois, selectedId, onSelect, }: { aois: Sentinel2AOI[]; selectedId: string; onSelect: (id: string) => void; }) { const div = useRef(null); const map = useRef(null); const markers = useRef>({}); useEffect(() => { if (!div.current || map.current) return; const m = new maplibregl.Map({ container: div.current, style: { version: 8, sources: {}, layers: [] }, center: [60, 25], zoom: 1, attributionControl: false, dragRotate: false, pitchWithRotate: false, renderWorldCopies: false, }); map.current = m; m.on("error", (e) => console.warn("locator map error:", e.error?.message ?? e)); m.on("load", () => { m.addSource("land", { type: "geojson", data: LAND }); m.addLayer({ id: "land-fill", type: "fill", source: "land", paint: { "fill-color": "#141d2b" } }); m.addLayer({ id: "land-line", type: "line", source: "land", paint: { "line-color": "#2b3a4f", "line-width": 0.6 }, }); // pins for (const a of aois) { const el = document.createElement("button"); el.className = "s2-pin"; el.type = "button"; el.title = a.title; el.setAttribute("aria-label", a.title); el.addEventListener("click", (ev) => { ev.stopPropagation(); onSelect(a.id); }); markers.current[a.id] = el; new maplibregl.Marker({ element: el, anchor: "center" }) .setLngLat(a.center) .addTo(m); } // frame all AOIs if (aois.length) { const b = new maplibregl.LngLatBounds(); aois.forEach((a) => b.extend(a.center)); m.fitBounds(b, { padding: 44, maxZoom: 3.4, duration: 0 }); } }); return () => { m.remove(); map.current = null; markers.current = {}; }; // markers/pins depend on the AOI set; it is stable for the app's lifetime // eslint-disable-next-line react-hooks/exhaustive-deps }, [aois]); // reflect the current selection on the pins useEffect(() => { for (const [id, el] of Object.entries(markers.current)) { el.classList.toggle("on", id === selectedId); } }, [selectedId]); return
; } const fmtCloud = (v: number | null | undefined) => v == null ? "—" : v < 0.1 ? "<0.1%" : `${v.toFixed(1)}%`; export default function Sentinel2View({ aois }: Props) { const [selectedId, setSelectedId] = useState(aois[0]?.id ?? ""); const [showOverlay, setShowOverlay] = useState(true); const [opacity, setOpacity] = useState(0.75); const selected = useMemo( () => aois.find((a) => a.id === selectedId) ?? aois[0], [aois, selectedId], ); useEffect(() => { if (!selectedId && aois.length) setSelectedId(aois[0].id); }, [aois, selectedId]); if (!selected) return
No Sentinel-2 AOIs baked.
; return ( <>
Sentinel-2 · 10 m/px. A deliberately modest, coarser-resolution track than the high-accuracy aerial mode — directionally correct on large real-world change, not a fine-grained detector. Curated real-world examples run through a Sentinel-2-native OSCD model; aerial models do not transfer to 10 m.
SITE {selected.title}
TILE {selected.tile}
THR {selected.threshold.toFixed(3)}
BANDS RGBN
GSD 10 m/px
MODEL OSCD
); }