trails/apps/planner/app/components/MapHelpers.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

275 lines
8.8 KiB
TypeScript

import { useEffect, useState, useRef } from "react";
import { useMap, useMapEvents, Marker } from "react-leaflet";
import L from "leaflet";
import type { YjsState } from "~/lib/use-yjs";
import { overlayLayers } from "@trails-cool/map";
import { usePois } from "~/lib/use-pois";
import { Z_CURSOR } from "@trails-cool/map-core";
// Exposes the Leaflet map instance on window.__leafletMap for E2E testing and external integrations.
export function MapExposer() {
const map = useMap();
useEffect(() => {
if (typeof window !== "undefined") {
window.__leafletMap = map;
}
}, [map]);
return null;
}
// Fits the map to the route bounds once on first load.
export function RouteFitter({ coordinates }: { coordinates: [number, number, number][] | null }) {
const map = useMap();
const hasFitted = useRef(false);
useEffect(() => {
if (hasFitted.current || !coordinates || coordinates.length < 2) return;
const bounds = L.latLngBounds(
coordinates.map((c) => [c[1]!, c[0]!] as [number, number]),
);
if (!bounds.isValid()) return;
const raf = requestAnimationFrame(() => {
map.invalidateSize();
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 16 });
hasFitted.current = true;
});
return () => cancelAnimationFrame(raf);
}, [coordinates, map]);
return null;
}
// Routes map click events to a callback, with a suppressRef for one-shot suppression (e.g. after route insert).
export function MapClickHandler({
onAdd,
suppressRef,
}: {
onAdd: (lat: number, lng: number) => void;
suppressRef: React.RefObject<boolean>;
}) {
useMapEvents({
click(e) {
if (suppressRef.current) {
suppressRef.current = false;
return;
}
onAdd(e.latlng.lat, e.latlng.lng);
},
});
return null;
}
// Broadcasts the local user's cursor position via Yjs awareness and renders remote cursors.
export function CursorTracker({ awareness }: { awareness: YjsState["awareness"] }) {
const map = useMap();
const [cursors, setCursors] = useState<
Map<number, { lat: number; lng: number; color: string; name: string }>
>(new Map());
useEffect(() => {
const localId = awareness.clientID;
const handleMouseMove = (e: L.LeafletMouseEvent) => {
awareness.setLocalStateField("mapCursor", { lat: e.latlng.lat, lng: e.latlng.lng });
};
const handleMouseOut = () => {
awareness.setLocalStateField("mapCursor", null);
};
map.on("mousemove", handleMouseMove);
map.on("mouseout", handleMouseOut);
const updateCursors = () => {
const states = awareness.getStates();
const newCursors = new Map<number, { lat: number; lng: number; color: string; name: string }>();
states.forEach((state, clientId) => {
if (clientId !== localId && state.mapCursor && state.user) {
newCursors.set(clientId, {
lat: state.mapCursor.lat,
lng: state.mapCursor.lng,
color: state.user.color,
name: state.user.name,
});
}
});
setCursors(newCursors);
};
awareness.on("change", updateCursors);
return () => {
map.off("mousemove", handleMouseMove);
map.off("mouseout", handleMouseOut);
awareness.off("change", updateCursors);
};
}, [map, awareness]);
return (
<>
{Array.from(cursors.entries()).map(([clientId, cursor]) => (
<Marker
key={clientId}
position={[cursor.lat, cursor.lng]}
zIndexOffset={Z_CURSOR}
icon={L.divIcon({
className: "",
html: `<div style="position:relative;z-index:400;pointer-events:none">
<svg width="16" height="20" viewBox="0 0 16 20" style="filter:drop-shadow(0 1px 2px rgba(0,0,0,0.3))">
<path d="M0 0L16 12L8 12L4 20Z" fill="${cursor.color}" />
</svg>
<span style="
position:absolute;left:16px;top:2px;
background:${cursor.color};color:white;
padding:1px 6px;border-radius:6px;
font-size:11px;font-weight:500;white-space:nowrap;
box-shadow:0 1px 3px rgba(0,0,0,0.2);
line-height:1.4;
">${cursor.name}</span>
</div>`,
iconSize: [0, 0],
})}
/>
))}
</>
);
}
// Leaflet control button for toggling no-go area drawing mode.
export function NoGoAreaButton({ active, onClick }: { active: boolean; onClick: () => void }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (ref.current) L.DomEvent.disableClickPropagation(ref.current);
}, []);
return (
<div ref={ref} className="leaflet-top leaflet-left" style={{ marginTop: 80 }}>
<div className="leaflet-control leaflet-bar">
<a
href="#"
role="button"
title={active ? "Cancel no-go area" : "Draw no-go area"}
onClick={(e) => { e.preventDefault(); onClick(); }}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 30,
height: 30,
fontSize: 16,
background: active ? "#fecaca" : "white",
color: active ? "#dc2626" : "#333",
}}
>
</a>
</div>
</div>
);
}
// Keeps Yjs routeData and the Leaflet LayersControl in sync (both directions).
export function OverlaySync({
yjs,
onOverlayChange,
onBaseLayerChange,
}: {
yjs: YjsState;
onOverlayChange: (ids: string[]) => void;
onBaseLayerChange: (name: string) => void;
}) {
const map = useMap();
const suppressRef = useRef(false);
useEffect(() => {
const handleAdd = (e: L.LayersControlEvent) => {
if (suppressRef.current) return;
const layer = overlayLayers.find((l) => l.name === e.name);
if (!layer) return;
const raw = yjs.routeData.get("overlays") as string | undefined;
const current: string[] = raw ? JSON.parse(raw) : [];
if (!current.includes(layer.id)) {
const updated = [...current, layer.id];
yjs.routeData.set("overlays", JSON.stringify(updated));
onOverlayChange(updated);
}
};
const handleRemove = (e: L.LayersControlEvent) => {
if (suppressRef.current) return;
const layer = overlayLayers.find((l) => l.name === e.name);
if (!layer) return;
const raw = yjs.routeData.get("overlays") as string | undefined;
const current: string[] = raw ? JSON.parse(raw) : [];
const updated = current.filter((id) => id !== layer.id);
yjs.routeData.set("overlays", JSON.stringify(updated));
onOverlayChange(updated);
};
const handleBaseChange = (e: L.LayersControlEvent) => {
if (suppressRef.current) return;
yjs.routeData.set("baseLayer", e.name);
};
map.on("overlayadd", handleAdd as L.LeafletEventHandlerFn);
map.on("overlayremove", handleRemove as L.LeafletEventHandlerFn);
map.on("baselayerchange", handleBaseChange as L.LeafletEventHandlerFn);
return () => {
map.off("overlayadd", handleAdd as L.LeafletEventHandlerFn);
map.off("overlayremove", handleRemove as L.LeafletEventHandlerFn);
map.off("baselayerchange", handleBaseChange as L.LeafletEventHandlerFn);
};
}, [map, yjs, onOverlayChange]);
useEffect(() => {
const handleChange = () => {
const raw = yjs.routeData.get("overlays") as string | undefined;
if (raw) {
try { onOverlayChange(JSON.parse(raw)); } catch { /* ignore */ }
}
const base = yjs.routeData.get("baseLayer") as string | undefined;
if (base) onBaseLayerChange(base);
};
yjs.routeData.observe(handleChange);
handleChange();
return () => yjs.routeData.unobserve(handleChange);
}, [yjs, onOverlayChange, onBaseLayerChange]);
return null;
}
// Triggers POI refreshes on map move and category changes.
export function PoiRefresher({ poiState }: { poiState: ReturnType<typeof usePois> }) {
const map = useMap();
const refreshRef = useRef(poiState.refresh);
refreshRef.current = poiState.refresh;
useEffect(() => {
const refresh = () => {
const bounds = map.getBounds();
const zoom = map.getZoom();
refreshRef.current(
{ south: bounds.getSouth(), west: bounds.getWest(), north: bounds.getNorth(), east: bounds.getEast() },
zoom,
);
};
map.on("moveend", refresh);
return () => { map.off("moveend", refresh); };
}, [map]);
const prevCategories = useRef(poiState.enabledCategories);
useEffect(() => {
if (prevCategories.current === poiState.enabledCategories) return;
prevCategories.current = poiState.enabledCategories;
const bounds = map.getBounds();
const zoom = map.getZoom();
poiState.refresh(
{ south: bounds.getSouth(), west: bounds.getWest(), north: bounds.getNorth(), east: bounds.getEast() },
zoom,
);
}, [map, poiState.enabledCategories, poiState.refresh]);
return null;
}