Extract all Leaflet zIndexOffset values into z-index.ts constants: POI markers (-1000) < cursors (-1000) < ghost waypoint (-100) < waypoint markers (1000) < highlight dot (2000) Replaced CircleMarker highlight with a Marker+DivIcon so it participates in z-index ordering above waypoint markers. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
136 lines
5.3 KiB
TypeScript
136 lines
5.3 KiB
TypeScript
import { useState, useEffect, useRef } from "react";
|
|
import L from "leaflet";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useMap } from "react-leaflet";
|
|
import { poiCategories } from "~/lib/poi-categories";
|
|
import type { PoiState } from "~/lib/use-pois";
|
|
import { Z_POI_MARKER } from "~/lib/z-index";
|
|
|
|
interface PoiPanelProps {
|
|
poiState: PoiState;
|
|
}
|
|
|
|
export function PoiPanel({ poiState }: PoiPanelProps) {
|
|
const { t } = useTranslation("planner");
|
|
const [open, setOpen] = useState(false);
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (ref.current) L.DomEvent.disableClickPropagation(ref.current);
|
|
}, []);
|
|
|
|
const countByCategory = new Map<string, number>();
|
|
for (const poi of poiState.pois) {
|
|
countByCategory.set(poi.category, (countByCategory.get(poi.category) ?? 0) + 1);
|
|
}
|
|
|
|
return (
|
|
<div ref={ref} className="leaflet-top leaflet-right" style={{ marginTop: 120 }}>
|
|
<div className="leaflet-control">
|
|
<button
|
|
onClick={() => setOpen((v) => !v)}
|
|
className="flex h-8 w-8 items-center justify-center rounded bg-white text-base shadow-md hover:bg-gray-50"
|
|
title={t("poi.toggle")}
|
|
>
|
|
📍
|
|
</button>
|
|
{open && (
|
|
<div className="mt-1 w-48 rounded bg-white p-2 shadow-lg">
|
|
<p className="mb-1 text-xs font-semibold text-gray-600">{t("poi.title")}</p>
|
|
|
|
{poiState.status === "zoom_too_low" && (
|
|
<p className="text-xs text-amber-600">{t("poi.zoomIn")}</p>
|
|
)}
|
|
{poiState.status === "rate_limited" && (
|
|
<p className="text-xs text-red-600">{t("poi.rateLimited")}</p>
|
|
)}
|
|
{poiState.status === "error" && (
|
|
<p className="text-xs text-red-600">{t("poi.error")}</p>
|
|
)}
|
|
{poiState.status === "loading" && (
|
|
<p className="text-xs text-blue-600">{t("poi.loading")}</p>
|
|
)}
|
|
|
|
<div className="mt-1 space-y-0.5">
|
|
{poiCategories.map((cat) => {
|
|
const count = countByCategory.get(cat.id) ?? 0;
|
|
const enabled = poiState.enabledCategories.includes(cat.id);
|
|
return (
|
|
<label key={cat.id} className="flex cursor-pointer items-center gap-1.5 rounded px-1 py-0.5 hover:bg-gray-50">
|
|
<input
|
|
type="checkbox"
|
|
checked={enabled}
|
|
onChange={() => poiState.toggleCategory(cat.id)}
|
|
className="h-3.5 w-3.5 rounded border-gray-300"
|
|
/>
|
|
<span className="text-sm">{cat.icon}</span>
|
|
<span className="flex-1 text-xs text-gray-700">{t(cat.name)}</span>
|
|
{enabled && count > 0 && (
|
|
<span className="text-[10px] tabular-nums text-gray-400">{count}</span>
|
|
)}
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function PoiMarkers({ poiState }: PoiPanelProps) {
|
|
const map = useMap();
|
|
const layerRef = useRef<L.LayerGroup>(L.layerGroup());
|
|
|
|
useEffect(() => {
|
|
layerRef.current.addTo(map);
|
|
return () => { layerRef.current.remove(); };
|
|
}, [map]);
|
|
|
|
useEffect(() => {
|
|
const group = layerRef.current;
|
|
group.clearLayers();
|
|
|
|
const catMap = new Map(poiCategories.map((c) => [c.id, c]));
|
|
|
|
for (const poi of poiState.pois) {
|
|
const cat = catMap.get(poi.category);
|
|
if (!cat) continue;
|
|
|
|
const marker = L.marker([poi.lat, poi.lon], {
|
|
icon: L.divIcon({
|
|
className: "",
|
|
html: `<div style="
|
|
width:20px;height:20px;border-radius:50%;
|
|
background:white;
|
|
display:flex;align-items:center;justify-content:center;
|
|
font-size:12px;
|
|
border:2px solid ${cat.color};
|
|
box-shadow:0 1px 3px rgba(0,0,0,0.2);
|
|
transform:translate(-10px,-10px);
|
|
">${cat.icon}</div>`,
|
|
iconSize: [0, 0],
|
|
}),
|
|
zIndexOffset: Z_POI_MARKER,
|
|
});
|
|
|
|
const popupLines = [`<strong>${poi.name ?? cat.icon + " " + poi.category}</strong>`];
|
|
popupLines.push(`<span style="font-size:11px;color:#888">${cat.icon} ${cat.id.replace(/_/g, " ")}</span>`);
|
|
if (poi.tags.description) popupLines.push(`<span style="font-size:12px">${poi.tags.description}</span>`);
|
|
const addr = [poi.tags["addr:street"], poi.tags["addr:housenumber"]].filter(Boolean).join(" ");
|
|
const addrCity = [addr, poi.tags["addr:postcode"], poi.tags["addr:city"]].filter(Boolean).join(", ");
|
|
if (addrCity) popupLines.push(`📍 ${addrCity}`);
|
|
const phone = poi.tags.phone ?? poi.tags["contact:phone"];
|
|
if (phone) popupLines.push(`📞 <a href="tel:${phone}">${phone}</a>`);
|
|
if (poi.tags.opening_hours) popupLines.push(`🕐 ${poi.tags.opening_hours}`);
|
|
if (poi.tags.website) popupLines.push(`<a href="${poi.tags.website}" target="_blank" rel="noopener">Website</a>`);
|
|
popupLines.push(`<a href="https://www.openstreetmap.org/node/${poi.id}" target="_blank" rel="noopener" style="font-size:11px;color:#666">OSM</a>`);
|
|
|
|
marker.bindPopup(popupLines.join("<br>"), { maxWidth: 200 });
|
|
group.addLayer(marker);
|
|
}
|
|
}, [poiState.pois]);
|
|
|
|
return null;
|
|
}
|