"use client"; 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, }: { bounds: Bounds; image: string; pin?: Pin | null; actual?: Pin | null; onPin?: (p: Pin) => void; onReady?: () => void; onError?: (message: string) => void; disabled?: boolean; }) { const root = useRef(null), markers = useRef(null), lib = useRef(null), [generation, setGeneration] = useState(0); const callbacks = useRef({ onPin, onReady, onError, disabled }); callbacks.current = { onPin, onReady, onError, disabled }; useEffect(() => { let disposed = false; let cleanup = () => {}; 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, }); const overlay = L.imageOverlay(image, b); overlay.on("load", () => callbacks.current.onReady?.()); overlay.on("error", () => callbacks.current.onError?.( "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); 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 }); }); let fittedZoom = fitZoom; const observer = new ResizeObserver(() => { // 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(); m.setMinZoom(-5); 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); }); observer.observe(root.current); cleanup = () => { observer.disconnect(); markers.current = null; m.remove(); }; }) .catch(() => callbacks.current.onError?.("The map viewer could not initialize."), ); return () => { disposed = true; cleanup(); }; }, [bounds.minX, bounds.maxX, bounds.minZ, bounds.maxZ, image]); 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 (
); }