diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index f93c40f..7e7ddf9 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -570,6 +570,7 @@ export function App() { ); const clearRouteOutcome = useCallback(() => { + courseAssistant.stop(); routeRequestId.current += 1; routeWeatherRequestId.current += 1; setRoute(null); @@ -580,7 +581,20 @@ export function App() { setRouteWeatherError(null); setRouteWeatherLoading(false); setRouteLoading(false); - }, []); + }, [courseAssistant.stop]); + + const editRoute = useCallback(() => { + clearRouteOutcome(); + setPickMode(null); + }, [clearRouteOutcome]); + + const cancelRoute = useCallback(() => { + setRouteStart(null); + setDestination(null); + setWaypoints([]); + clearRouteOutcome(); + setPickMode(null); + }, [clearRouteOutcome]); const saveCurrentBoat = useCallback((profile: BoatProfile) => { const stored = persistBoatProfile(profile); @@ -675,11 +689,12 @@ export function App() { return; } + courseAssistant.stop(); setRoute(selected); if (activeVesselProfile) { loadRouteWeather(selected, activeVesselProfile); } - }, [activeVesselProfile, loadRouteWeather, routeOptions]); + }, [activeVesselProfile, courseAssistant.stop, loadRouteWeather, routeOptions]); const clearStart = useCallback(() => { setRouteStart(null); @@ -716,6 +731,7 @@ export function App() { }, [clearRouteOutcome]); const loadOfflineVoyage = useCallback((voyage: OfflineVoyage) => { + courseAssistant.stop(); routeRequestId.current += 1; routeWeatherRequestId.current += 1; setRouteStart(voyage.plan.start); @@ -737,7 +753,7 @@ export function App() { if (voyage.plan.vesselProfile && navigator.onLine) { loadRouteWeather(offlineRoute, voyage.plan.vesselProfile); } - }, [loadRouteWeather]); + }, [courseAssistant.stop, loadRouteWeather]); const uiMode = anchorWatch.phase !== "idle" ? "anchor" @@ -1022,6 +1038,8 @@ export function App() { onClearDestination={clearDestination} onUseGpsAsStart={useGpsAsStart} onSelectRoute={selectRouteOption} + onEditRoute={editRoute} + onCancelRoute={cancelRoute} guidanceActive={courseAssistant.active} onStartGuidance={startCourseAssistant} onEditBoat={() => { diff --git a/apps/web/src/components/MapView.tsx b/apps/web/src/components/MapView.tsx index abe84ac..f798869 100644 --- a/apps/web/src/components/MapView.tsx +++ b/apps/web/src/components/MapView.tsx @@ -417,8 +417,8 @@ export function MapView({ map.addSource("route-guidance", { type: "geojson", data: guidanceFeatures( - guidanceActiveRef.current ? positionRef.current : null, - guidanceActiveRef.current ? guidanceTargetRef.current : null + routeRef.current && guidanceActiveRef.current ? positionRef.current : null, + routeRef.current && guidanceActiveRef.current ? guidanceTargetRef.current : null ) }); map.addSource("anchor-watch", { @@ -950,13 +950,18 @@ export function MapView({ useEffect(() => { const map = mapRef.current; - if (!map?.isStyleLoaded()) { + const source = map?.getSource("route-guidance") as GeoJSONSource | undefined; + if (!source) { return; } - const source = map.getSource("route-guidance") as GeoJSONSource | undefined; - source?.setData(guidanceFeatures(guidanceActive ? position : null, guidanceActive ? guidanceTarget : null)); - }, [guidanceActive, guidanceTarget, position]); + source.setData( + guidanceFeatures( + route && guidanceActive ? position : null, + route && guidanceActive ? guidanceTarget : null + ) + ); + }, [guidanceActive, guidanceTarget, position, route]); useEffect(() => { const map = mapRef.current; @@ -987,42 +992,45 @@ export function MapView({ useEffect(() => { const map = mapRef.current; - if (!map?.isStyleLoaded()) { + const source = map?.getSource("start-point") as GeoJSONSource | undefined; + if (!source) { return; } - const source = map.getSource("start-point") as GeoJSONSource | undefined; - source?.setData(startPoint ? pointFeature(startPoint) : emptyPoint()); + source.setData(startPoint ? pointFeature(startPoint) : emptyPoint()); }, [startPoint]); useEffect(() => { const map = mapRef.current; - if (!map?.isStyleLoaded()) { + const source = map?.getSource("destination") as GeoJSONSource | undefined; + if (!source) { return; } - const source = map.getSource("destination") as GeoJSONSource | undefined; - source?.setData(destination ? pointFeature(destination) : emptyPoint()); + source.setData(destination ? pointFeature(destination) : emptyPoint()); }, [destination]); useEffect(() => { const map = mapRef.current; - if (!map?.isStyleLoaded()) { + const source = map?.getSource("waypoints") as GeoJSONSource | undefined; + if (!source) { return; } - const source = map.getSource("waypoints") as GeoJSONSource | undefined; - source?.setData(waypointFeatures(waypoints)); + source.setData(waypointFeatures(waypoints)); }, [waypoints]); useEffect(() => { const map = mapRef.current; - if (!map?.isStyleLoaded()) { + if (!map) { + return; + } + const source = map.getSource("route") as GeoJSONSource | undefined; + if (!source) { return; } - const source = map.getSource("route") as GeoJSONSource | undefined; - source?.setData(route ? routeFeature(route) : emptyLine()); + source.setData(route ? routeFeature(route) : emptyLine()); fitRouteOnMap(map, route); }, [route]); diff --git a/apps/web/src/components/RoutePlanner.tsx b/apps/web/src/components/RoutePlanner.tsx index 3a6afa4..a56700d 100644 --- a/apps/web/src/components/RoutePlanner.tsx +++ b/apps/web/src/components/RoutePlanner.tsx @@ -93,6 +93,8 @@ type RoutePlannerProps = { onClearDestination: () => void; onUseGpsAsStart: () => void; onSelectRoute: (routeId: string) => void; + onEditRoute?: () => void; + onCancelRoute?: () => void; guidanceActive?: boolean; onStartGuidance?: () => void; onEditBoat?: () => void; @@ -148,6 +150,8 @@ export function RoutePlanner({ onClearDestination, onUseGpsAsStart, onSelectRoute, + onEditRoute = () => undefined, + onCancelRoute = () => undefined, guidanceActive = false, onStartGuidance = () => undefined, onEditBoat = () => undefined, @@ -612,13 +616,29 @@ export function RoutePlanner({ )} - +
+ + +
{result && (
diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index adec154..d19f96d 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -1029,6 +1029,20 @@ button:disabled { font-weight: 800; } +.route-plan-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.route-plan-actions > button { + width: 100%; +} + +.route-cancel-action { + color: #8b3528; +} + .route-result[data-severity="ok"] { background: #dceee6; color: #196f5c; diff --git a/apps/web/tests/map-view.test.tsx b/apps/web/tests/map-view.test.tsx index a8faa3c..049e239 100644 --- a/apps/web/tests/map-view.test.tsx +++ b/apps/web/tests/map-view.test.tsx @@ -28,6 +28,7 @@ vi.mock("maplibre-gl", () => { layerDefinitions = new globalThis.Map(); renderedFeatures: any[] = []; zoom = 13; + styleLoaded = true; container: HTMLElement; fitBounds = vi.fn(() => this); easeTo = vi.fn(() => this); @@ -110,7 +111,7 @@ vi.mock("maplibre-gl", () => { } isStyleLoaded() { - return true; + return this.styleLoaded; } getZoom() { @@ -553,6 +554,128 @@ describe("MapView marine feature information", () => { ); }); + it("atomically replaces and clears route drawings while other map data is loading", async () => { + const stableOnPickCoordinate = vi.fn(); + const stableOnMapReady = vi.fn(); + const firstRoute: RouteResult = { + id: "first", + name: "Hauptroute", + geometry: { + type: "LineString", + coordinates: [ + [7.1, 53.1], + [7.2, 53.2], + [7.3, 53.3] + ] + }, + distanceNm: 10, + eta: null, + warnings: [], + dataSources: [], + routingMode: "fairway" + }; + const secondRoute: RouteResult = { + ...firstRoute, + id: "second", + name: "Alternative 1", + geometry: { + type: "LineString", + coordinates: [ + [8.1, 54.1], + [8.2, 54.2], + [8.3, 54.3], + [8.4, 54.4] + ] + } + }; + const view = render( + + ); + await act(async () => maplibreState.current.emit("load")); + + const routeSource = maplibreState.current.sources.get("route"); + const guidanceSource = maplibreState.current.sources.get("route-guidance"); + const startSource = maplibreState.current.sources.get("start-point"); + const destinationSource = maplibreState.current.sources.get("destination"); + const waypointSource = maplibreState.current.sources.get("waypoints"); + maplibreState.current.styleLoaded = false; + + view.rerender( + + ); + + expect(routeSource.setData).toHaveBeenLastCalledWith({ + type: "Feature", + properties: {}, + geometry: secondRoute.geometry + }); + + view.rerender( + + ); + + expect(routeSource.setData).toHaveBeenLastCalledWith({ + type: "Feature", + properties: {}, + geometry: { type: "LineString", coordinates: [] } + }); + expect(guidanceSource.setData).toHaveBeenLastCalledWith({ + type: "FeatureCollection", + features: [] + }); + expect(startSource.setData).toHaveBeenLastCalledWith({ + type: "FeatureCollection", + features: [] + }); + expect(destinationSource.setData).toHaveBeenLastCalledWith({ + type: "FeatureCollection", + features: [] + }); + expect(waypointSource.setData).toHaveBeenLastCalledWith({ + type: "FeatureCollection", + features: [] + }); + }); + it("focuses a route event requested by the shared navigation workspace", async () => { const stableOnMapReady = vi.fn(); const view = render( @@ -636,7 +759,14 @@ describe("MapView marine feature information", () => { startPoint={{ lat: 52, lon: 7 }} destination={{ lat: 52, lon: 7.1 }} pickMode={null} - route={null} + route={{ + geometry: { type: "LineString", coordinates: [[7, 52], [7.1, 52]] }, + distanceNm: 4, + eta: null, + warnings: [], + dataSources: [], + routingMode: "fairway" + }} guidanceActive guidanceTarget={{ lat: 52.001, lon: 7.01 }} onPickCoordinate={vi.fn()} diff --git a/apps/web/tests/route-planner.test.tsx b/apps/web/tests/route-planner.test.tsx index 02ea1e8..53c9992 100644 --- a/apps/web/tests/route-planner.test.tsx +++ b/apps/web/tests/route-planner.test.tsx @@ -383,6 +383,7 @@ describe("RoutePlanner", () => { }); it("opens the compact result view and keeps critical warnings above secondary route details", () => { + const onEditRoute = vi.fn(); const result = { ...routeOption("primary", "Hauptroute", 4), warnings: [ @@ -411,6 +412,7 @@ describe("RoutePlanner", () => { onClearDestination={vi.fn()} onUseGpsAsStart={vi.fn()} onSelectRoute={vi.fn()} + onEditRoute={onEditRoute} onCollapse={vi.fn()} /> ); @@ -422,9 +424,44 @@ describe("RoutePlanner", () => { expect(screen.queryByRole("button", { name: "Start auf Karte setzen" })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Plan ändern" })); + expect(onEditRoute).toHaveBeenCalledTimes(1); expect(screen.getByText("Route planen")).toBeVisible(); expect(screen.getByRole("button", { name: "Start auf Karte setzen" })).toBeVisible(); - expect(screen.getByRole("button", { name: "Zur Route" })).toBeVisible(); + }); + + it("explicitly discards a planned route through the parent state", () => { + const onCancelRoute = vi.fn(); + const result = routeOption("primary", "Hauptroute", 4); + + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Route verwerfen" })); + + expect(onCancelRoute).toHaveBeenCalledTimes(1); + expect(screen.getByText("Route planen")).toBeVisible(); }); it("filters retired fairway and depth warnings from route results", () => {