trails/apps/planner/app/components/PoiPanel.tsx
Ullrich Schäfer 985ec54023
chore(ts): replace 7 \as unknown as\ shims with proper types
Most of the existing coercions are legitimate (Drizzle raw-SQL row
shapes, linkedom DOMParser interop, fit-file-parser's loose \`any\`
callback API) — leaving those is honest. The ones cleaned up here
were shims around APIs that have real types we just hadn't wired:

- **\`window.__leafletMap\`** (4 sites: MapHelpers.tsx, SessionView.tsx ×3)
  Added \`Window\` declaration in \`apps/planner/app/types/global.d.ts\`.
  Callers now use plain \`window.__leafletMap\` typed as
  \`L.Map | undefined\`.

- **\`L.markerClusterGroup\`** (PoiPanel.tsx) — leaflet.markercluster
  augments the global \`L\` namespace at runtime but ships no types.
  Added a \`declare module \"leaflet\"\` block in the same global.d.ts
  with a minimal signature for what we actually use.

- **\`L.DomEvent.preventDefault / .stop\`** taking a Leaflet event
  rather than a native one (PlannerMap.tsx, RouteInteraction.tsx,
  NoGoAreaLayer.tsx). Leaflet events carry the native event under
  \`.originalEvent\`; using that drops the cast entirely.

- **\`_creds as unknown as OAuthCredentials\`** orphan in
  wahoo/webhook.ts — \`_creds\` was unused inside the callback (the
  void-cast was a no-op preserving the type for documentation).
  Replaced with \`async ()\`, dropped the unused import.

Net: 26 \`as unknown as\` / \`as any\` sites → 19, all remaining ones
gated by external lib interop or Drizzle raw-SQL.

Full repo: pnpm typecheck / lint / test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 01:02:25 +02:00

176 lines
7.1 KiB
TypeScript

import { useState, useEffect, useRef } from "react";
import L from "leaflet";
import { useTranslation } from "react-i18next";
import { useMap } from "react-leaflet";
import { poiCategories, Z_POI_MARKER } from "@trails-cool/map-core";
import type { PoiState } from "~/lib/use-pois";
import "leaflet.markercluster/dist/MarkerCluster.css";
import "leaflet.markercluster/dist/MarkerCluster.Default.css";
interface PoiPanelProps {
poiState: PoiState;
onAddWaypoint?: (lat: number, lon: number, name?: string) => void;
}
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, onAddWaypoint }: PoiPanelProps) {
const map = useMap();
const layerRef = useRef<L.LayerGroup | null>(null);
useEffect(() => {
let mounted = true;
// Dynamic import to avoid bundling markercluster when POIs aren't used
import("leaflet.markercluster").then(() => {
if (!mounted) return;
// After import, L.markerClusterGroup is available (typed via
// module augmentation in app/types/global.d.ts).
const cluster = L.markerClusterGroup({
maxClusterRadius: 40,
disableClusteringAtZoom: 15,
showCoverageOnHover: false,
});
layerRef.current = cluster;
cluster.addTo(map);
}).catch(() => {
// Fallback to plain layer group if markercluster fails to load
if (!mounted) return;
layerRef.current = L.layerGroup();
layerRef.current.addTo(map);
});
return () => { mounted = false; layerRef.current?.remove(); };
}, [map]);
// Event delegation for "Add as waypoint" buttons in popups
useEffect(() => {
const container = map.getContainer();
const handler = (e: MouseEvent) => {
const btn = (e.target as HTMLElement).closest(".poi-add-wp") as HTMLElement | null;
if (!btn || !onAddWaypoint) return;
const lat = parseFloat(btn.dataset.lat!);
const lon = parseFloat(btn.dataset.lon!);
const name = btn.dataset.name || undefined;
onAddWaypoint(lat, lon, name);
map.closePopup();
};
container.addEventListener("click", handler);
return () => container.removeEventListener("click", handler);
}, [map, onAddWaypoint]);
useEffect(() => {
const group = layerRef.current;
if (!group) return;
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>`);
if (onAddWaypoint) {
popupLines.push(`<button class="poi-add-wp" data-lat="${poi.lat}" data-lon="${poi.lon}" data-name="${(poi.name ?? "").replace(/"/g, "&quot;")}" style="margin-top:4px;padding:2px 8px;font-size:11px;background:#2563eb;color:white;border:none;border-radius:4px;cursor:pointer">+ Add as waypoint</button>`);
}
marker.bindPopup(popupLines.join("<br>"), { maxWidth: 200 });
group.addLayer(marker);
}
}, [poiState.pois]);
return null;
}