127 lines
6.1 KiB
TypeScript
127 lines
6.1 KiB
TypeScript
"use client";
|
|
|
|
import type { ReactNode } from "react";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { groupMapLocations } from "@/lib/user-location-map";
|
|
import type { UserMapLocation } from "./user-world-map";
|
|
|
|
export function MapViewToggle({ locations, children }: { locations: UserMapLocation[]; children: ReactNode }) {
|
|
const [view, setView] = useState<"overview" | "interactive">("overview");
|
|
|
|
return (
|
|
<div className="mt-6">
|
|
<div aria-label="Map view" className="flex flex-wrap gap-2" role="group">
|
|
<button aria-controls="map-overview-panel" aria-pressed={view === "overview"} className={`border px-4 py-2 font-mono text-[10px] font-bold uppercase ${view === "overview" ? "border-ink bg-ink text-canvas" : "border-line"}`} id="map-overview-tab" onClick={() => setView("overview")} type="button">World overview</button>
|
|
<button aria-controls="map-interactive-panel" aria-pressed={view === "interactive"} className={`border px-4 py-2 font-mono text-[10px] font-bold uppercase ${view === "interactive" ? "border-ink bg-ink text-canvas" : "border-line"}`} id="map-interactive-tab" onClick={() => setView("interactive")} type="button">Interactive OpenStreetMap</button>
|
|
</div>
|
|
<p className="mt-2 max-w-2xl text-[10px] leading-4 text-muted">Selecting the interactive view requests map tiles from OpenStreetMap, which receives your IP address, the portal origin, and the geographic area being viewed.</p>
|
|
<div aria-labelledby="map-overview-tab" hidden={view !== "overview"} id="map-overview-panel" role="region">{children}</div>
|
|
<div aria-labelledby="map-interactive-tab" hidden={view !== "interactive"} id="map-interactive-panel" role="region">
|
|
{view === "interactive" && <InteractiveMap locations={locations} />}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function InteractiveMap({ locations }: { locations: UserMapLocation[] }) {
|
|
const container = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!container.current) return;
|
|
let cancelled = false;
|
|
let cleanup = () => {};
|
|
|
|
void import("leaflet").then((leaflet) => {
|
|
if (cancelled || !container.current) return;
|
|
const map = leaflet.map(container.current, { minZoom: 1, worldCopyJump: true }).setView([20, 0], 2);
|
|
leaflet.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>',
|
|
maxZoom: 19,
|
|
referrerPolicy: "strict-origin-when-cross-origin",
|
|
}).addTo(map);
|
|
|
|
const bounds: [number, number][] = [];
|
|
for (const group of groupMapLocations(locations)) {
|
|
const firstUser = group.locations[0]!;
|
|
const isGrouped = group.count > 1;
|
|
const marker = isGrouped
|
|
? leaflet.marker([group.latitude, group.longitude], {
|
|
icon: leaflet.divIcon({
|
|
className: "map-user-cluster",
|
|
html: `<span aria-hidden="true">${group.count}</span>`,
|
|
iconAnchor: [18, 18],
|
|
iconSize: [36, 36],
|
|
}),
|
|
keyboard: true,
|
|
}).addTo(map)
|
|
: leaflet.circleMarker([group.latitude, group.longitude], {
|
|
radius: 8,
|
|
color: "#eee8d8",
|
|
weight: 3,
|
|
fillColor: "#a32f1b",
|
|
fillOpacity: 1,
|
|
}).addTo(map);
|
|
const tooltip = document.createElement("span");
|
|
tooltip.textContent = isGrouped
|
|
? `${group.count} users · ${group.nicknames.join(" · ")}`
|
|
: `${firstUser.nickname} · ${firstUser.location}`;
|
|
marker.bindTooltip(tooltip, { direction: "top" });
|
|
|
|
if (isGrouped) {
|
|
const popup = document.createElement("div");
|
|
const heading = document.createElement("strong");
|
|
heading.textContent = `${group.count} users near ${firstUser.location}`;
|
|
popup.append(heading);
|
|
const list = document.createElement("ul");
|
|
for (const user of group.locations) {
|
|
const item = document.createElement("li");
|
|
const link = document.createElement("a");
|
|
link.href = `/admin/users/${user.userId}`;
|
|
link.textContent = user.nickname;
|
|
item.append(link);
|
|
list.append(item);
|
|
}
|
|
popup.append(list);
|
|
marker.bindPopup(popup);
|
|
} else {
|
|
marker.on("click", () => window.location.assign(`/admin/users/${firstUser.userId}`));
|
|
}
|
|
|
|
const element = marker.getElement();
|
|
const label = isGrouped
|
|
? `${group.count} users near ${firstUser.location}: ${group.nicknames.join(", ")}`
|
|
: `${firstUser.nickname}, ${firstUser.location}`;
|
|
element?.setAttribute("aria-label", label);
|
|
element?.setAttribute("role", isGrouped ? "button" : "link");
|
|
element?.setAttribute("tabindex", "0");
|
|
if (isGrouped) {
|
|
element?.setAttribute("aria-haspopup", "dialog");
|
|
element?.setAttribute("aria-expanded", "false");
|
|
marker.on("popupopen", () => element?.setAttribute("aria-expanded", "true"));
|
|
marker.on("popupclose", () => element?.setAttribute("aria-expanded", "false"));
|
|
}
|
|
element?.addEventListener("focus", () => marker.openTooltip());
|
|
element?.addEventListener("blur", () => marker.closeTooltip());
|
|
element?.addEventListener("keydown", (event) => {
|
|
const keyboardEvent = event as KeyboardEvent;
|
|
if (keyboardEvent.key === "Enter" || keyboardEvent.key === " ") {
|
|
keyboardEvent.preventDefault();
|
|
if (isGrouped) marker.openPopup();
|
|
else window.location.assign(`/admin/users/${firstUser.userId}`);
|
|
}
|
|
});
|
|
bounds.push([group.latitude, group.longitude]);
|
|
}
|
|
if (bounds.length) map.fitBounds(bounds, { padding: [40, 40], maxZoom: 6 });
|
|
cleanup = () => map.remove();
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
cleanup();
|
|
};
|
|
}, [locations]);
|
|
|
|
return <div aria-label="Interactive map of latest approximate user locations" className="mt-3 h-[32rem] max-h-[70vh] min-h-80 border border-line" ref={container} role="region" />;
|
|
}
|