diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 7e7ddf9..bd2c952 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -155,6 +155,7 @@ export function App() { const [routeLoading, setRouteLoading] = useState(false); const [routeError, setRouteError] = useState(null); const [mapReady, setMapReady] = useState(false); + const [followGpsPosition, setFollowGpsPosition] = useState(false); const [mapFocusRequest, setMapFocusRequest] = useState<{ key: string; coordinate: Coordinate; @@ -273,6 +274,11 @@ export function App() { setActiveTool("route"); setWorkspaceSheetState("half"); }, [anchorWatch.phase, compass, courseAssistant, gps, route]); + useEffect(() => { + if (!courseAssistant.active) { + setFollowGpsPosition(false); + } + }, [courseAssistant.active]); const anchorPanelVisible = activeTool === "anchor"; const closeWorkspace = useCallback(() => setActiveTool(null), []); const selectTool = useCallback((tool: NavigationToolId) => { @@ -830,8 +836,11 @@ export function App() { waypoints={waypoints} pickMode={pickMode} route={route} + navigationActive={courseAssistant.active} guidanceActive={courseAssistant.active && Boolean(activeGuidance)} guidanceTarget={activeGuidance?.lookaheadPoint ?? null} + followPosition={followGpsPosition} + onFollowPositionChange={setFollowGpsPosition} anchorPoint={anchorWatch.anchorPoint} anchorAlarmRadiusM={anchorWatch.anchorPoint ? anchorWatch.settings.alarmRadiusM : null} anchorWatchActive={anchorWatch.phase === "armed"} diff --git a/apps/web/src/components/MapView.tsx b/apps/web/src/components/MapView.tsx index f798869..e425da7 100644 --- a/apps/web/src/components/MapView.tsx +++ b/apps/web/src/components/MapView.tsx @@ -16,8 +16,11 @@ type MapViewProps = { waypoints?: Coordinate[]; pickMode: "start" | "destination" | "waypoint" | null; route: RouteResult | null; + navigationActive?: boolean; guidanceActive?: boolean; guidanceTarget?: Coordinate | null; + followPosition?: boolean; + onFollowPositionChange?: (follow: boolean) => void; anchorPoint?: Coordinate | null; anchorAlarmRadiusM?: number | null; anchorWatchActive?: boolean; @@ -71,8 +74,11 @@ export function MapView({ waypoints = [], pickMode, route, + navigationActive = false, guidanceActive = false, guidanceTarget = null, + followPosition = false, + onFollowPositionChange, anchorPoint = null, anchorAlarmRadiusM = null, anchorWatchActive = false, @@ -98,6 +104,7 @@ export function MapView({ const accuracyMRef = useRef(accuracyM); const guidanceActiveRef = useRef(guidanceActive); const guidanceTargetRef = useRef(guidanceTarget); + const onFollowPositionChangeRef = useRef(onFollowPositionChange); const anchorPointRef = useRef(anchorPoint); const anchorAlarmRadiusMRef = useRef(anchorAlarmRadiusM); const anchorWatchActiveRef = useRef(anchorWatchActive); @@ -114,6 +121,10 @@ export function MapView({ applyMapFocusRequest(mapRef.current, focusRequest); }, [focusRequest]); + useEffect(() => { + onFollowPositionChangeRef.current = onFollowPositionChange; + }, [onFollowPositionChange]); + const clearContactFeatures = useCallback((map: MapLibreMap) => { const hadContactFeatures = contactFeaturesRef.current.length > 0; contactFeaturesRef.current = []; @@ -174,6 +185,9 @@ export function MapView({ map.addControl(new maplibregl.AttributionControl({ compact: true }), "bottom-right"); map.addControl(new maplibregl.ScaleControl({ unit: "nautical" }), "bottom-left"); + map.on("dragstart", () => { + onFollowPositionChangeRef.current?.(false); + }); mapRef.current = map; map.on("load", () => { @@ -937,7 +951,15 @@ export function MapView({ const source = map.getSource("user-position") as GeoJSONSource | undefined; source?.setData(userPositionFeatures(position, accuracyM)); - if (!hasCenteredOnFirstFixRef.current) { + if (followPosition) { + hasCenteredOnFirstFixRef.current = true; + map.easeTo({ + center: [position.lon, position.lat], + zoom: Math.max(map.getZoom(), 13), + duration: 500, + essential: true + }); + } else if (!hasCenteredOnFirstFixRef.current) { hasCenteredOnFirstFixRef.current = true; map.easeTo({ center: [position.lon, position.lat], @@ -946,7 +968,7 @@ export function MapView({ essential: true }); } - }, [accuracyM, position]); + }, [accuracyM, followPosition, position]); useEffect(() => { const map = mapRef.current; @@ -1075,6 +1097,7 @@ export function MapView({ data-pick-mode={pickMode ?? "none"} data-has-route={Boolean(route)} data-guidance-active={guidanceActive} + data-follow-position={followPosition} >
{pickMode && ( @@ -1101,9 +1124,43 @@ export function MapView({ diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index d19f96d..2a6c9f9 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -455,6 +455,12 @@ button:disabled { right: 62px; } +.locate-toggle[data-active="true"] { + background: #0f4c5c; + color: #ffffff; + border-color: rgba(255, 255, 255, 0.55); +} + .anchor-toggle { z-index: 10; top: calc(env(safe-area-inset-top) + 116px); diff --git a/apps/web/tests/map-view.test.tsx b/apps/web/tests/map-view.test.tsx index 049e239..7e39a2a 100644 --- a/apps/web/tests/map-view.test.tsx +++ b/apps/web/tests/map-view.test.tsx @@ -788,6 +788,71 @@ describe("MapView marine feature information", () => { ); expect(maplibreState.markerConstructions).toBe(0); }); + + it("keeps the GPS position centered while navigation follow mode is active", async () => { + const onFollowPositionChange = vi.fn(); + const onMapReady = vi.fn(); + const onPickCoordinate = vi.fn(); + const view = render( + + ); + await act(async () => maplibreState.current.emit("load")); + + const followButton = screen.getByRole("button", { + name: "GPS-Folge-Modus aktivieren" + }); + expect(followButton).toHaveAttribute("aria-pressed", "false"); + fireEvent.click(followButton); + expect(onFollowPositionChange).toHaveBeenCalledWith(true); + expect(maplibreState.current.flyTo).toHaveBeenCalledWith({ + center: [7, 52], + zoom: 13, + essential: true + }); + + view.rerender( + + ); + + expect( + screen.getByRole("button", { name: "GPS-Folge-Modus beenden" }) + ).toHaveAttribute("aria-pressed", "true"); + expect(maplibreState.current.easeTo).toHaveBeenLastCalledWith({ + center: [7.02, 52.01], + zoom: 13, + duration: 500, + essential: true + }); + + await act(async () => maplibreState.current.emit("dragstart")); + expect(onFollowPositionChange).toHaveBeenLastCalledWith(false); + }); }); describe("MapView anchor watch", () => {