import maplibregl from "maplibre-gl"; import { useEffect, useRef, useState } from "react"; // A fixed geographic quad to place the (non-geo) image tiles into MapLibre. Square so 256² tiles // keep their aspect ratio. TL, TR, BR, BL as [lng, lat]. const COORDS: [ [number, number], [number, number], [number, number], [number, number], ] = [ [-0.02, 0.02], [0.02, 0.02], [0.02, -0.02], [-0.02, -0.02], ]; const BOUNDS: [[number, number], [number, number]] = [ [-0.02, -0.02], [0.02, 0.02], ]; interface Props { beforeUrl: string; afterUrl: string; overlayUrl: string | null; overlayOpacity: number; showOverlay: boolean; loading?: boolean; legendLabel?: string; gsdLabel?: string; // corner GSD chip; defaults to the aerial track's 0.5 m/px } // Cover-fit: fill the whole stage edge-to-edge (no letterbox voids), cropping any overflow. The // square image quad is grown until it covers the viewport's LONGER axis; overflow is clipped by // .compare-wrap, pan/zoom reaches the cropped areas, and the overlay tracks via map.project(). // On an ultra-wide stage a 1024px tile can't fill without upscaling — MapLibre's linear resampling // keeps that smooth (never blocky), and zooming recovers native pixels. function fitCover(map: maplibregl.Map): void { const cam = map.cameraForBounds(BOUNDS, { padding: 0 }); // "contain" zoom (fits the shorter axis) if (!cam || cam.zoom == null) return; const c = map.getContainer(); const vw = c.clientWidth; const vh = c.clientHeight; if (!vw || !vh) return; // bump the contain zoom by log2(longer/shorter) so the square also covers the longer axis const coverBump = Math.log2(Math.max(vw, vh) / Math.min(vw, vh)); map.jumpTo({ center: cam.center, zoom: cam.zoom + coverBump }); } function makeMap(container: HTMLDivElement): maplibregl.Map { const map = new maplibregl.Map({ container, style: { version: 8, sources: {}, layers: [] }, center: [0, 0], zoom: 12, attributionControl: false, dragRotate: false, pitchWithRotate: false, renderWorldCopies: false, // pan + zoom are on by default (dragPan / scrollZoom / dblclick / touch); rotate/pitch stay off }); map.on("load", () => fitCover(map)); return map; } // Base imagery as a MapLibre raster image layer. (The change overlay is *not* a MapLibre layer — a // second stacked image-source raster proved unreliable to paint; it is an aligned HTML // positioned over the map via map.project(), which always renders and gives smooth CSS opacity.) function setImage(map: maplibregl.Map, url: string): void { const src = map.getSource("img") as maplibregl.ImageSource | undefined; if (src) { src.updateImage({ url, coordinates: COORDS }); return; } map.addSource("img", { type: "image", url, coordinates: COORDS }); map.addLayer({ id: "img", type: "raster", source: "img", // explicit linear resampling: smooth photographic interpolation, never nearest-neighbor blocks paint: { "raster-opacity": 1, "raster-fade-duration": 0, "raster-resampling": "linear" }, }); } export default function CompareView({ beforeUrl, afterUrl, overlayUrl, overlayOpacity, showOverlay, loading = false, legendLabel, gsdLabel = "SOURCE GSD 0.5 m/px", }: Props) { const beforeDiv = useRef(null); const afterDiv = useRef(null); const beforeMap = useRef(null); const afterMap = useRef(null); const overlayImg = useRef(null); const syncing = useRef(false); const [ready, setReady] = useState(false); const [split, setSplit] = useState(50); // divider position, % const dragging = useRef(false); const wrap = useRef(null); // Position the overlay to exactly cover the image quad, in the before map's screen space // (the two maps are view-synced, so this also lines up under the clipped after map). const alignOverlay = () => { const map = beforeMap.current; const el = overlayImg.current; if (!map || !el) return; const tl = map.project(COORDS[0]); const br = map.project(COORDS[2]); el.style.left = `${tl.x}px`; el.style.top = `${tl.y}px`; el.style.width = `${br.x - tl.x}px`; el.style.height = `${br.y - tl.y}px`; }; // create the two maps once, keep their views in sync, and keep the overlay aligned useEffect(() => { if (!beforeDiv.current || !afterDiv.current || beforeMap.current) return; const bm = makeMap(beforeDiv.current); const am = makeMap(afterDiv.current); beforeMap.current = bm; afterMap.current = am; bm.on("error", (e) => console.warn("map error (before):", e.error?.message ?? e)); am.on("error", (e) => console.warn("map error (after):", e.error?.message ?? e)); const sync = (src: maplibregl.Map, dst: maplibregl.Map) => () => { if (syncing.current) return; syncing.current = true; dst.jumpTo({ center: src.getCenter(), zoom: src.getZoom(), bearing: src.getBearing(), pitch: src.getPitch(), }); syncing.current = false; alignOverlay(); }; bm.on("move", sync(bm, am)); am.on("move", sync(am, bm)); // on container resize, re-cover the stage from the before map; its jumpTo syncs the after map bm.on("resize", () => { fitCover(bm); alignOverlay(); }); bm.on("idle", alignOverlay); let loaded = 0; const onLoad = () => { loaded += 1; if (loaded === 2) { setReady(true); alignOverlay(); } }; bm.on("load", onLoad); am.on("load", onLoad); return () => { bm.remove(); am.remove(); beforeMap.current = null; afterMap.current = null; setReady(false); }; }, []); // update imagery when the pair changes useEffect(() => { if (!ready || !beforeMap.current || !afterMap.current) return; setImage(beforeMap.current, beforeUrl); setImage(afterMap.current, afterUrl); alignOverlay(); }, [ready, beforeUrl, afterUrl]); // keep the overlay aligned whenever it (re)appears useEffect(() => { alignOverlay(); }, [ready, overlayUrl]); // clip the top (after) map to the right of the divider useEffect(() => { if (afterDiv.current) afterDiv.current.style.clipPath = `inset(0 0 0 ${split}%)`; }, [split]); const onPointerMove = (e: React.PointerEvent) => { if (!dragging.current || !wrap.current) return; const rect = wrap.current.getBoundingClientRect(); const pct = ((e.clientX - rect.left) / rect.width) * 100; setSplit(Math.max(4, Math.min(96, pct))); }; return (
(dragging.current = false)} onPointerLeave={() => (dragging.current = false)} >
BEFORE
AFTER
CHANGE MASK{legendLabel ? ` · ${legendLabel}` : ""}
{gsdLabel}
{ dragging.current = true; (e.target as HTMLElement).setPointerCapture?.(e.pointerId); }} >
{loading && (
Analyzing
)}
); }