All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 5m17s
268 lines
8.3 KiB
TypeScript
268 lines
8.3 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,
|
|
zoom = true,
|
|
pan = 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;
|
|
pan?: boolean;
|
|
compass?: 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,
|
|
zoomControl: false,
|
|
scrollWheelZoom: zoom && (pan ? true : "center"),
|
|
touchZoom: zoom && (pan ? true : "center"),
|
|
doubleClickZoom: zoom && (pan ? true : "center"),
|
|
boxZoom: zoom && pan,
|
|
dragging: pan,
|
|
keyboard: false,
|
|
});
|
|
if (zoom)
|
|
L.control
|
|
.zoom({ zoomInTitle: t("Zoom in"), zoomOutTitle: t("Zoom out") })
|
|
.addTo(m);
|
|
// Leaflet's keyboard handler couples panning and +/- zoom. Gate them
|
|
// separately so a fixed map cannot be moved or zoomed by keyboard.
|
|
if (pan && zoom) m.keyboard.enable();
|
|
// 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.target === m.getContainer() &&
|
|
pan &&
|
|
!zoom &&
|
|
["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(
|
|
event.key,
|
|
)
|
|
) {
|
|
event.preventDefault();
|
|
m.panBy([
|
|
event.key === "ArrowLeft"
|
|
? -80
|
|
: event.key === "ArrowRight"
|
|
? 80
|
|
: 0,
|
|
event.key === "ArrowUp"
|
|
? -80
|
|
: event.key === "ArrowDown"
|
|
? 80
|
|
: 0,
|
|
]);
|
|
return;
|
|
}
|
|
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,
|
|
pan,
|
|
]);
|
|
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}
|
|
tabIndex={0}
|
|
style={{ height: "100%", width: "100%" }}
|
|
role="application"
|
|
aria-label={
|
|
!pan
|
|
? t(
|
|
"Minecraft map. Click to place a guess, or press Enter to pin the center.",
|
|
)
|
|
: !compass
|
|
? t(
|
|
"Minecraft map. Click to place a guess, or use arrow keys to pan and Enter to pin the center.",
|
|
)
|
|
: 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>
|
|
);
|
|
}
|