Files
MineGeo/components/GuessMap.tsx

203 lines
6.4 KiB
TypeScript

"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,
}: {
bounds: Bounds;
image: string;
pin?: Pin | null;
actual?: Pin | null;
onPin?: (p: Pin) => void;
onReady?: () => void;
onError?: (message: string) => void;
disabled?: boolean;
}) {
const { t } = useI18n();
const root = useRef<HTMLDivElement>(null),
markers = useRef<Leaflet.LayerGroup | null>(null),
lib = useRef<typeof Leaflet | null>(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<typeof setTimeout> | 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,
});
// 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);
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);
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);
})
.catch(() => fail("The map viewer could not initialize."));
return () => {
disposed = true;
clearTimeout(loadTimer);
cleanup();
};
}, [bounds.minX, bounds.maxX, bounds.minZ, bounds.maxZ, image, attempt]);
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={
disabled
? t("Minecraft map. North is up.")
: t(
"Minecraft map. North is up. Click to place a guess, or use arrow keys to pan and Enter to pin the center.",
)
}
/>
{error && (
<div className="map-load-error" role="alert">
<p>{t(error)}</p>
<button
className="button"
onClick={() => setAttempt((value) => value + 1)}
>
{t("Retry map")}
</button>
</div>
)}
</div>
);
}