From 6871b154db59a6c3041a48c986f66c81f6ab7047 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Thu, 16 Jul 2026 07:59:48 +0200 Subject: [PATCH 1/2] fix(planner): clicking a waypoint no longer adds a duplicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Waypoint markers had no click listener, so Leaflet routed the click to the map — MapClickHandler then added a new waypoint on top of the one you clicked (and made markers feel unresponsive). Add a click handler that consumes the event, so Leaflet treats the marker as the target and suppresses the map click. Co-Authored-By: Claude Opus 4.8 --- apps/planner/app/components/PlannerMap.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/planner/app/components/PlannerMap.tsx b/apps/planner/app/components/PlannerMap.tsx index 181cd01..d38b9ba 100644 --- a/apps/planner/app/components/PlannerMap.tsx +++ b/apps/planner/app/components/PlannerMap.tsx @@ -195,6 +195,11 @@ export function PlannerMap({ yjs, sessionId, onRouteRequest, highlightPosition, zIndexOffset={highlightedWaypoint === i ? Z_WAYPOINT_HIGHLIGHTED : Z_WAYPOINT} icon={waypointIcon(i, wp.overnight, highlightedWaypoint === i, !!wp.note)} eventHandlers={{ + // Consume the click on the marker. Without a click listener, + // Leaflet routes the click to the map (which adds a duplicate + // waypoint on top of this one). Registering it makes Leaflet + // treat the marker as the event target instead. + click: (e) => { e.originalEvent.stopPropagation(); }, mouseover: () => { routeInteractionSuspendedRef.current = true; }, mouseout: () => { if (!waypointDraggingRef.current) routeInteractionSuspendedRef.current = false; From c6ae08676beca4e5e232eed73f138c18fe19dff6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Thu, 16 Jul 2026 08:12:06 +0200 Subject: [PATCH 2/2] fix(planner): waypoint drag no longer snaps back on release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the waypoint marker into a React.memo'd component. The marker's position/icon were new references every render, so any unrelated PlannerMap re-render during a drag (e.g. the route-hover chart-sync state update) re-applied the stale saved position mid-drag — orphaning the drag so it never committed and the pin jumped back on release. Memoizing means unrelated re-renders no longer touch the marker, so the drag commits and the moved position persists. Co-Authored-By: Claude Opus 4.8 --- apps/planner/app/components/PlannerMap.tsx | 123 ++++++++++++++------- 1 file changed, 81 insertions(+), 42 deletions(-) diff --git a/apps/planner/app/components/PlannerMap.tsx b/apps/planner/app/components/PlannerMap.tsx index d38b9ba..cffe5d4 100644 --- a/apps/planner/app/components/PlannerMap.tsx +++ b/apps/planner/app/components/PlannerMap.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef } from "react"; +import { useState, useCallback, useRef, memo, type RefObject } from "react"; import { MapContainer, TileLayer, LayersControl, Marker, Polyline, Tooltip } from "react-leaflet"; import L from "leaflet"; import { useTranslation } from "react-i18next"; @@ -70,6 +70,70 @@ function dayLabelIcon(dayNumber: number, distanceKm: string): L.DivIcon { }); } +interface WaypointMarkerProps { + index: number; + lat: number; + lon: number; + overnight?: boolean; + highlighted: boolean; + note?: string; + onMove: (index: number, lat: number, lng: number) => void; + onContextMenu: (index: number, x: number, y: number) => void; + suspendRef: RefObject; + draggingRef: RefObject; + undoManager: YjsState["undoManager"]; +} + +/** + * A single waypoint marker. Memoized so unrelated PlannerMap re-renders (e.g. + * route-hover chart sync) don't re-apply `position`/`icon` mid-drag — which + * otherwise yanks the pin back to its saved spot and the drag never commits. + */ +const WaypointMarker = memo(function WaypointMarker({ + index, lat, lon, overnight, highlighted, note, + onMove, onContextMenu, suspendRef, draggingRef, undoManager, +}: WaypointMarkerProps) { + return ( + { e.originalEvent.stopPropagation(); }, + mouseover: () => { suspendRef.current = true; }, + mouseout: () => { if (!draggingRef.current) suspendRef.current = false; }, + dragstart: () => { + draggingRef.current = true; + undoManager.stopCapturing(); + suspendRef.current = true; + }, + dragend: (e) => { + draggingRef.current = false; + suspendRef.current = false; + const { lat: dLat, lng } = (e.target as L.Marker).getLatLng(); + onMove(index, dLat, lng); + }, + contextmenu: (e) => { + L.DomEvent.preventDefault(e.originalEvent); + const orig = e.originalEvent as MouseEvent; + onContextMenu(index, orig.clientX, orig.clientY); + }, + }} + > + {note && ( + + + {note.length > 120 ? note.slice(0, 120) + "…" : note} + + + )} + + ); +}); + interface PlannerMapProps { yjs: YjsState; sessionId: string; @@ -93,6 +157,9 @@ export function PlannerMap({ yjs, sessionId, onRouteRequest, highlightPosition, const [noGoDrawing, setNoGoDrawing] = useState(false); const toggleNoGoDraw = useCallback(() => setNoGoDrawing((v) => !v), []); const suppressMapClickRef = useRef(false); + const handleContextMenu = useCallback((index: number, x: number, y: number) => { + setContextMenu({ x, y, index }); + }, []); const routeInteractionSuspendedRef = useRef(false); const waypointDraggingRef = useRef(false); @@ -188,48 +255,20 @@ export function PlannerMap({ yjs, sessionId, onRouteRequest, highlightPosition, )} {waypoints.map((wp, i) => ( - { e.originalEvent.stopPropagation(); }, - mouseover: () => { routeInteractionSuspendedRef.current = true; }, - mouseout: () => { - if (!waypointDraggingRef.current) routeInteractionSuspendedRef.current = false; - }, - dragstart: () => { - waypointDraggingRef.current = true; - yjs.undoManager.stopCapturing(); - routeInteractionSuspendedRef.current = true; - }, - dragend: (e) => { - waypointDraggingRef.current = false; - routeInteractionSuspendedRef.current = false; - const { lat, lng } = e.target.getLatLng(); - moveWaypoint(i, lat, lng); - }, - contextmenu: (e) => { - L.DomEvent.preventDefault(e.originalEvent); - const orig = e.originalEvent as MouseEvent; - setContextMenu({ x: orig.clientX, y: orig.clientY, index: i }); - }, - }} - > - {wp.note && ( - - - {wp.note.length > 120 ? wp.note.slice(0, 120) + "…" : wp.note} - - - )} - + index={i} + lat={wp.lat} + lon={wp.lon} + overnight={wp.overnight} + highlighted={highlightedWaypoint === i} + note={wp.note} + onMove={moveWaypoint} + onContextMenu={handleContextMenu} + suspendRef={routeInteractionSuspendedRef} + draggingRef={waypointDraggingRef} + undoManager={yjs.undoManager} + /> ))} {days && days.length > 1 && days.map((day) => {