Files
MineGeo/components/GuessMap.tsx

141 lines
4.3 KiB
TypeScript

"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<HTMLDivElement>(null),
markers = useRef<Leaflet.LayerGroup | null>(null),
lib = useRef<typeof Leaflet | null>(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: `<div class="pin-marker ${answer ? "actual-marker" : ""}"></div>`,
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 (
<div className="map-surface">
<div
ref={root}
style={{ height: "100%", width: "100%" }}
role="application"
aria-label="Minecraft map. North is up. Click to place a guess."
/>
</div>
);
}