The first commit, the core and all files is adding to repo
This commit is contained in:
124
components/GuessMap.tsx
Normal file
124
components/GuessMap.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"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,
|
||||
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.fitBounds(b, { padding: [5, 5] });
|
||||
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 observer = new ResizeObserver(() => m.invalidateSize());
|
||||
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, 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user