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

214 lines
6.5 KiB
TypeScript

import { useEffect, useRef, useCallback } from "react";
import { useMap } from "react-leaflet";
import L from "leaflet";
import { Z_GHOST_WAYPOINT } from "@trails-cool/map-core";
interface RouteInteractionProps {
coordinates: [number, number, number][]; // [lon, lat, ele]
segmentBoundaries: number[];
onInsertWaypoint: (pointIndex: number, lat: number, lon: number) => void;
disabled?: boolean;
/** Set true to suspend ghost marker (e.g. while hovering/dragging a waypoint) */
suspendedRef?: React.RefObject<boolean>;
}
const SNAP_TOLERANCE_PX = 15;
const ghostIcon = L.divIcon({
className: "route-ghost-marker",
html: `<div style="
width:14px;height:14px;border-radius:50%;
background:white;border:3px solid #2563eb;
box-shadow:0 1px 4px rgba(0,0,0,0.3);
transform:translate(-7px,-7px);
cursor:grab;
"></div>`,
iconSize: [0, 0],
});
/**
* Route interaction layer: shows a ghost marker on hover that can be
* clicked or dragged to insert a waypoint. Follows brouter-web's pattern
* of suspending when the mouse is over a waypoint marker.
*/
export function RouteInteraction({
coordinates,
segmentBoundaries,
onInsertWaypoint,
disabled,
suspendedRef,
}: RouteInteractionProps) {
const map = useMap();
const markerRef = useRef<L.Marker | null>(null);
const draggingRef = useRef(false);
const snappedIdxRef = useRef(0);
const coordinatesRef = useRef(coordinates);
const segBoundsRef = useRef(segmentBoundaries);
const onInsertRef = useRef(onInsertWaypoint);
coordinatesRef.current = coordinates;
segBoundsRef.current = segmentBoundaries;
onInsertRef.current = onInsertWaypoint;
// Trailer lines (dashed lines from ghost to adjacent waypoints)
const trailer1Ref = useRef<L.Polyline | null>(null);
const trailer2Ref = useRef<L.Polyline | null>(null);
const findClosestOnRoute = useCallback((latlng: L.LatLng): { idx: number; lat: number; lon: number; distPx: number } | null => {
const coords = coordinatesRef.current;
if (coords.length < 2) return null;
let minDistPx = Infinity;
let bestIdx = 0;
let bestLat = 0;
let bestLon = 0;
// Find closest point on route in pixel space for accuracy
const clickPt = map.latLngToContainerPoint(latlng);
for (let i = 0; i < coords.length; i++) {
const c = coords[i]!;
const pt = map.latLngToContainerPoint([c[1], c[0]]);
const dx = pt.x - clickPt.x;
const dy = pt.y - clickPt.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < minDistPx) {
minDistPx = dist;
bestIdx = i;
bestLat = c[1];
bestLon = c[0];
}
}
return { idx: bestIdx, lat: bestLat, lon: bestLon, distPx: minDistPx };
}, [map]);
useEffect(() => {
if (disabled) return;
// Create persistent ghost marker (hidden until hover)
const marker = L.marker([0, 0], {
icon: ghostIcon,
draggable: true,
interactive: true,
zIndexOffset: Z_GHOST_WAYPOINT,
});
markerRef.current = marker;
// Trailer lines
const trailer1 = L.polyline([], { color: "#2563eb", weight: 2, dashArray: "6,8", opacity: 0 }).addTo(map);
const trailer2 = L.polyline([], { color: "#2563eb", weight: 2, dashArray: "6,8", opacity: 0 }).addTo(map);
trailer1Ref.current = trailer1;
trailer2Ref.current = trailer2;
let shown = false;
const showMarker = (lat: number, lon: number) => {
marker.setLatLng([lat, lon]);
if (!shown) {
marker.addTo(map);
shown = true;
}
};
const hideMarker = () => {
if (shown && !draggingRef.current) {
marker.remove();
shown = false;
}
};
// Listen for mousemove on the map (distance-based approach, not polyline events)
const onMouseMove = (e: L.LeafletMouseEvent) => {
// Suspended while hovering over or dragging a waypoint marker (brouter-web pattern)
if (draggingRef.current || suspendedRef?.current) {
hideMarker();
return;
}
const snap = findClosestOnRoute(e.latlng);
if (!snap || snap.distPx > SNAP_TOLERANCE_PX) {
hideMarker();
return;
}
snappedIdxRef.current = snap.idx;
showMarker(snap.lat, snap.lon);
};
const onMouseOut = () => {
hideMarker();
};
// Drag events on the ghost marker
marker.on("dragstart", () => {
draggingRef.current = true;
trailer1.setStyle({ opacity: 0.6 });
trailer2.setStyle({ opacity: 0.6 });
});
marker.on("drag", (e) => {
const latlng = (e.target as L.Marker).getLatLng();
// Update trailer lines to adjacent waypoints
const idx = snappedIdxRef.current;
const bounds = segBoundsRef.current;
const coords = coordinatesRef.current;
// Find which segment this point is in
let segIdx = 0;
for (let i = bounds.length - 1; i >= 0; i--) {
if (idx >= bounds[i]!) { segIdx = i; break; }
}
// Trailer to previous waypoint (segment start)
if (segIdx >= 0) {
const startCoordIdx = bounds[segIdx]!;
const c = coords[startCoordIdx];
if (c) trailer1.setLatLngs([latlng, [c[1], c[0]]]);
}
// Trailer to next waypoint (next segment start or end)
const nextStart = segIdx + 1 < bounds.length ? bounds[segIdx + 1]! : coords.length - 1;
const nc = coords[nextStart];
if (nc) trailer2.setLatLngs([latlng, [nc[1], nc[0]]]);
});
marker.on("dragend", (e) => {
const latlng = (e.target as L.Marker).getLatLng();
draggingRef.current = false;
trailer1.setStyle({ opacity: 0 });
trailer2.setStyle({ opacity: 0 });
marker.remove();
shown = false;
onInsertRef.current(snappedIdxRef.current, latlng.lat, latlng.lng);
});
// Click on ghost marker inserts at snapped position
marker.on("click", () => {
if (draggingRef.current) return;
const latlng = marker.getLatLng();
marker.remove();
shown = false;
onInsertRef.current(snappedIdxRef.current, latlng.lat, latlng.lng);
});
// Prevent double-click zoom when clicking ghost marker
marker.on("dblclick", (e) => {
L.DomEvent.stop(e.originalEvent);
});
map.on("mousemove", onMouseMove);
map.on("mouseout", onMouseOut);
return () => {
map.off("mousemove", onMouseMove);
map.off("mouseout", onMouseOut);
if (shown) marker.remove();
trailer1.remove();
trailer2.remove();
markerRef.current = null;
};
}, [map, disabled, findClosestOnRoute, suspendedRef]);
return null;
}