"use client"; import { useI18n } from "@/components/I18nProvider"; import { useEffect, useRef, useState } from "react"; import type * as Leaflet from "leaflet"; import "leaflet/dist/leaflet.css"; import type { Bounds } from "@/shared/protocol"; export type Pin = { x: number; z: number }; export function GuessMap({ bounds, image, pin, actual, onPin, onReady, onError, disabled = false, zoom = true, compass = true, }: { bounds: Bounds; image: string; pin?: Pin | null; actual?: Pin | null; onPin?: (p: Pin) => void; onReady?: () => void; onError?: (message: string) => void; disabled?: boolean; zoom?: boolean; compass?: boolean; }) { const { t } = useI18n(); const root = useRef(null), markers = useRef(null), lib = useRef(null), [generation, setGeneration] = useState(0), [error, setError] = useState(""), [attempt, setAttempt] = useState(0); const callbacks = useRef({ onPin, onReady, onError, disabled }); callbacks.current = { onPin, onReady, onError, disabled }; useEffect(() => { let disposed = false; let cleanup = () => {}; setError(""); const fail = (message: string) => { if (disposed) return; setError(message); callbacks.current.onError?.(message); }; let loadTimer: ReturnType | undefined; import("leaflet") .then((L) => { if (disposed || !root.current) return; lib.current = L; const b = L.latLngBounds([ [-bounds.maxZ, bounds.minX], [-bounds.minZ, bounds.maxX], ]); const m = L.map(root.current, { crs: L.CRS.Simple, minZoom: -5, maxZoom: 3, zoomSnap: 0, maxBounds: b.pad(0.15), attributionControl: false, zoomControl: false, scrollWheelZoom: zoom, touchZoom: zoom, doubleClickZoom: zoom, boxZoom: zoom, }); if (zoom) L.control .zoom({ zoomInTitle: t("Zoom in"), zoomOutTitle: t("Zoom out") }) .addTo(m); // Install cleanup immediately, including when later initialization fails. let observer: ResizeObserver | undefined; cleanup = () => { observer?.disconnect(); markers.current = null; lib.current = null; m.remove(); }; const overlay = L.imageOverlay(image, b); loadTimer = setTimeout( () => fail("The surface map took too long to load. Reconnect to retry."), 30000, ); overlay.on("load", () => { clearTimeout(loadTimer); if (!disposed) { setError(""); callbacks.current.onReady?.(); } }); overlay.on("error", () => { clearTimeout(loadTimer); fail("The surface map could not load. Reconnect to retry."); }); overlay.addTo(m); m.setMaxZoom(Math.max(3, m.getBoundsZoom(b) + 3)); m.fitBounds(b, { padding: [0, 0], animate: false }); const fitZoom = m.getZoom(); m.setMinZoom(fitZoom); if (!zoom) m.setMaxZoom(fitZoom); markers.current = L.layerGroup().addTo(m); setGeneration((v) => v + 1); m.on("click", (e: Leaflet.LeafletMouseEvent) => { if (callbacks.current.disabled) return; const x = e.latlng.lng, z = -e.latlng.lat; if ( x >= bounds.minX && x <= bounds.maxX && z >= bounds.minZ && z <= bounds.maxZ ) callbacks.current.onPin?.({ x, z }); }); const keydown = (event: KeyboardEvent) => { if ( event.key !== "Enter" || event.target !== m.getContainer() || callbacks.current.disabled ) return; event.preventDefault(); const center = m.getCenter(); callbacks.current.onPin?.({ x: Math.max(bounds.minX, Math.min(bounds.maxX, center.lng)), z: Math.max(bounds.minZ, Math.min(bounds.maxZ, -center.lat)), }); }; m.getContainer().addEventListener("keydown", keydown); const removeMap = cleanup; cleanup = () => { m.getContainer().removeEventListener("keydown", keydown); removeMap(); }; let fittedZoom = fitZoom; observer = new ResizeObserver(() => { if (!root.current?.clientWidth || !root.current.clientHeight) return; // Expanding the map should reveal the whole survey at its new size. // Preserve a player's chosen zoom and center while inspecting terrain. const wasFitted = Math.abs(m.getZoom() - fittedZoom) < 0.01; m.invalidateSize({ pan: false }); m.setMinZoom(-5); // Unlock only while refitting a resized fixed-zoom map. if (!zoom) m.setMaxZoom(Infinity); fittedZoom = m.getBoundsZoom(b); m.setMaxZoom(Math.max(3, fittedZoom + 3)); if (wasFitted || m.getZoom() < fittedZoom) m.fitBounds(b, { padding: [0, 0], animate: false }); m.setMinZoom(fittedZoom); if (!zoom) m.setMaxZoom(fittedZoom); }); observer.observe(root.current); }) .catch(() => fail("The map viewer could not initialize.")); return () => { disposed = true; clearTimeout(loadTimer); cleanup(); }; }, [ bounds.minX, bounds.maxX, bounds.minZ, bounds.maxZ, image, attempt, zoom, ]); useEffect(() => { const L = lib.current, group = markers.current; if (!L || !group) return; group.clearLayers(); const marker = (p: Pin, answer = false) => L.marker([-p.z, p.x], { icon: L.divIcon({ className: "", html: `
`, iconSize: [14, 14], iconAnchor: [7, 7], }), }).addTo(group); if (pin) marker(pin); if (actual) marker(actual, true); if (pin && actual) L.polyline( [ [-pin.z, pin.x], [-actual.z, actual.x], ], { color: "#e6d494", weight: 2, dashArray: "5 5" }, ).addTo(group); }, [pin, actual, generation]); return (
{error && (

{t(error)}

)}
); }