Add POI overlay panel, markers, and map integration
- PoiPanel: Collapsible panel with category checkboxes, count badges, loading/error/zoom-too-low states - PoiMarkers: L.Marker with L.DivIcon per POI, click popup with name, hours, website, OSM link. z-index below route/waypoints. - PoiRefresher: Listens to map moveend, refreshes POIs via usePois hook - Wired into PlannerMap alongside existing controls Skipped markercluster (7.3) — can add later if density is an issue. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
375ff2fa13
commit
6aa2229b39
4 changed files with 162 additions and 8 deletions
|
|
@ -9,9 +9,11 @@ import { baseLayers, overlayLayers } from "@trails-cool/map";
|
|||
import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx";
|
||||
import { isOvernight } from "~/lib/overnight";
|
||||
import { setOvernight } from "~/lib/overnight";
|
||||
import { usePois } from "~/lib/use-pois";
|
||||
import { NoGoAreaLayer } from "./NoGoAreaLayer";
|
||||
import { ColoredRoute, findSegmentForPoint, type ColorMode } from "./ColoredRoute";
|
||||
import { RouteInteraction } from "./RouteInteraction";
|
||||
import { PoiPanel, PoiMarkers } from "./PoiPanel";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
function waypointIcon(index: number, overnight?: boolean, highlighted?: boolean): L.DivIcon {
|
||||
|
|
@ -231,9 +233,30 @@ function NoGoAreaButton({ active, onClick }: { active: boolean; onClick: () => v
|
|||
);
|
||||
}
|
||||
|
||||
function PoiRefresher({ poiState }: { poiState: ReturnType<typeof usePois> }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
const refresh = () => {
|
||||
const bounds = map.getBounds();
|
||||
const zoom = map.getZoom();
|
||||
poiState.refresh({
|
||||
south: bounds.getSouth(),
|
||||
west: bounds.getWest(),
|
||||
north: bounds.getNorth(),
|
||||
east: bounds.getEast(),
|
||||
}, zoom);
|
||||
};
|
||||
map.on("moveend", refresh);
|
||||
refresh();
|
||||
return () => { map.off("moveend", refresh); };
|
||||
}, [map, poiState.refresh]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlightedWaypoint, onImportError, days }: PlannerMapProps) {
|
||||
const { t } = useTranslation("planner");
|
||||
const [waypoints, setWaypoints] = useState<WaypointData[]>([]);
|
||||
const poiState = usePois();
|
||||
const [draggingOver, setDraggingOver] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
const [routeCoordinates, setRouteCoordinates] = useState<[number, number, number][] | null>(null);
|
||||
|
|
@ -468,6 +491,9 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted
|
|||
<CursorTracker awareness={yjs.awareness} />
|
||||
<NoGoAreaLayer noGoAreas={yjs.noGoAreas} doc={yjs.doc} enabled={noGoDrawing} onToggle={toggleNoGoDraw} />
|
||||
<NoGoAreaButton active={noGoDrawing} onClick={toggleNoGoDraw} />
|
||||
<PoiRefresher poiState={poiState} />
|
||||
<PoiMarkers poiState={poiState} />
|
||||
<PoiPanel poiState={poiState} />
|
||||
|
||||
{waypoints.map((wp, i) => (
|
||||
<Marker
|
||||
|
|
|
|||
128
apps/planner/app/components/PoiPanel.tsx
Normal file
128
apps/planner/app/components/PoiPanel.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
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";
|
||||
|
||||
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: -1000,
|
||||
});
|
||||
|
||||
const popupLines = [`<strong>${poi.name ?? cat.icon + " " + poi.category}</strong>`];
|
||||
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;
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { queryPois, OverpassRateLimitError, type Poi, type BBox } from "./overpass.ts";
|
||||
import { getCached, setCached } from "./poi-cache.ts";
|
||||
import { poiCategories, type PoiCategory } from "./poi-categories.ts";
|
||||
import { poiCategories } from "./poi-categories.ts";
|
||||
|
||||
const MIN_ZOOM = 12;
|
||||
const DEBOUNCE_MS = 500;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue