Keep route drawings in sync
Test and publish container images / test (push) Successful in 2m26s
Test and publish container images / publish (push) Successful in 1m16s

This commit is contained in:
BuTzZ
2026-07-31 07:45:06 +02:00
parent 6cd697abfa
commit 6801fa9089
6 changed files with 258 additions and 31 deletions
+21 -3
View File
@@ -570,6 +570,7 @@ export function App() {
); );
const clearRouteOutcome = useCallback(() => { const clearRouteOutcome = useCallback(() => {
courseAssistant.stop();
routeRequestId.current += 1; routeRequestId.current += 1;
routeWeatherRequestId.current += 1; routeWeatherRequestId.current += 1;
setRoute(null); setRoute(null);
@@ -580,7 +581,20 @@ export function App() {
setRouteWeatherError(null); setRouteWeatherError(null);
setRouteWeatherLoading(false); setRouteWeatherLoading(false);
setRouteLoading(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 saveCurrentBoat = useCallback((profile: BoatProfile) => {
const stored = persistBoatProfile(profile); const stored = persistBoatProfile(profile);
@@ -675,11 +689,12 @@ export function App() {
return; return;
} }
courseAssistant.stop();
setRoute(selected); setRoute(selected);
if (activeVesselProfile) { if (activeVesselProfile) {
loadRouteWeather(selected, activeVesselProfile); loadRouteWeather(selected, activeVesselProfile);
} }
}, [activeVesselProfile, loadRouteWeather, routeOptions]); }, [activeVesselProfile, courseAssistant.stop, loadRouteWeather, routeOptions]);
const clearStart = useCallback(() => { const clearStart = useCallback(() => {
setRouteStart(null); setRouteStart(null);
@@ -716,6 +731,7 @@ export function App() {
}, [clearRouteOutcome]); }, [clearRouteOutcome]);
const loadOfflineVoyage = useCallback((voyage: OfflineVoyage) => { const loadOfflineVoyage = useCallback((voyage: OfflineVoyage) => {
courseAssistant.stop();
routeRequestId.current += 1; routeRequestId.current += 1;
routeWeatherRequestId.current += 1; routeWeatherRequestId.current += 1;
setRouteStart(voyage.plan.start); setRouteStart(voyage.plan.start);
@@ -737,7 +753,7 @@ export function App() {
if (voyage.plan.vesselProfile && navigator.onLine) { if (voyage.plan.vesselProfile && navigator.onLine) {
loadRouteWeather(offlineRoute, voyage.plan.vesselProfile); loadRouteWeather(offlineRoute, voyage.plan.vesselProfile);
} }
}, [loadRouteWeather]); }, [courseAssistant.stop, loadRouteWeather]);
const uiMode = anchorWatch.phase !== "idle" const uiMode = anchorWatch.phase !== "idle"
? "anchor" ? "anchor"
@@ -1022,6 +1038,8 @@ export function App() {
onClearDestination={clearDestination} onClearDestination={clearDestination}
onUseGpsAsStart={useGpsAsStart} onUseGpsAsStart={useGpsAsStart}
onSelectRoute={selectRouteOption} onSelectRoute={selectRouteOption}
onEditRoute={editRoute}
onCancelRoute={cancelRoute}
guidanceActive={courseAssistant.active} guidanceActive={courseAssistant.active}
onStartGuidance={startCourseAssistant} onStartGuidance={startCourseAssistant}
onEditBoat={() => { onEditBoat={() => {
+26 -18
View File
@@ -417,8 +417,8 @@ export function MapView({
map.addSource("route-guidance", { map.addSource("route-guidance", {
type: "geojson", type: "geojson",
data: guidanceFeatures( data: guidanceFeatures(
guidanceActiveRef.current ? positionRef.current : null, routeRef.current && guidanceActiveRef.current ? positionRef.current : null,
guidanceActiveRef.current ? guidanceTargetRef.current : null routeRef.current && guidanceActiveRef.current ? guidanceTargetRef.current : null
) )
}); });
map.addSource("anchor-watch", { map.addSource("anchor-watch", {
@@ -950,13 +950,18 @@ export function MapView({
useEffect(() => { useEffect(() => {
const map = mapRef.current; const map = mapRef.current;
if (!map?.isStyleLoaded()) { const source = map?.getSource("route-guidance") as GeoJSONSource | undefined;
if (!source) {
return; return;
} }
const source = map.getSource("route-guidance") as GeoJSONSource | undefined; source.setData(
source?.setData(guidanceFeatures(guidanceActive ? position : null, guidanceActive ? guidanceTarget : null)); guidanceFeatures(
}, [guidanceActive, guidanceTarget, position]); route && guidanceActive ? position : null,
route && guidanceActive ? guidanceTarget : null
)
);
}, [guidanceActive, guidanceTarget, position, route]);
useEffect(() => { useEffect(() => {
const map = mapRef.current; const map = mapRef.current;
@@ -987,42 +992,45 @@ export function MapView({
useEffect(() => { useEffect(() => {
const map = mapRef.current; const map = mapRef.current;
if (!map?.isStyleLoaded()) { const source = map?.getSource("start-point") as GeoJSONSource | undefined;
if (!source) {
return; return;
} }
const source = map.getSource("start-point") as GeoJSONSource | undefined; source.setData(startPoint ? pointFeature(startPoint) : emptyPoint());
source?.setData(startPoint ? pointFeature(startPoint) : emptyPoint());
}, [startPoint]); }, [startPoint]);
useEffect(() => { useEffect(() => {
const map = mapRef.current; const map = mapRef.current;
if (!map?.isStyleLoaded()) { const source = map?.getSource("destination") as GeoJSONSource | undefined;
if (!source) {
return; return;
} }
const source = map.getSource("destination") as GeoJSONSource | undefined; source.setData(destination ? pointFeature(destination) : emptyPoint());
source?.setData(destination ? pointFeature(destination) : emptyPoint());
}, [destination]); }, [destination]);
useEffect(() => { useEffect(() => {
const map = mapRef.current; const map = mapRef.current;
if (!map?.isStyleLoaded()) { const source = map?.getSource("waypoints") as GeoJSONSource | undefined;
if (!source) {
return; return;
} }
const source = map.getSource("waypoints") as GeoJSONSource | undefined; source.setData(waypointFeatures(waypoints));
source?.setData(waypointFeatures(waypoints));
}, [waypoints]); }, [waypoints]);
useEffect(() => { useEffect(() => {
const map = mapRef.current; const map = mapRef.current;
if (!map?.isStyleLoaded()) { if (!map) {
return;
}
const source = map.getSource("route") as GeoJSONSource | undefined;
if (!source) {
return; return;
} }
const source = map.getSource("route") as GeoJSONSource | undefined; source.setData(route ? routeFeature(route) : emptyLine());
source?.setData(route ? routeFeature(route) : emptyLine());
fitRouteOnMap(map, route); fitRouteOnMap(map, route);
}, [route]); }, [route]);
+21 -1
View File
@@ -93,6 +93,8 @@ type RoutePlannerProps = {
onClearDestination: () => void; onClearDestination: () => void;
onUseGpsAsStart: () => void; onUseGpsAsStart: () => void;
onSelectRoute: (routeId: string) => void; onSelectRoute: (routeId: string) => void;
onEditRoute?: () => void;
onCancelRoute?: () => void;
guidanceActive?: boolean; guidanceActive?: boolean;
onStartGuidance?: () => void; onStartGuidance?: () => void;
onEditBoat?: () => void; onEditBoat?: () => void;
@@ -148,6 +150,8 @@ export function RoutePlanner({
onClearDestination, onClearDestination,
onUseGpsAsStart, onUseGpsAsStart,
onSelectRoute, onSelectRoute,
onEditRoute = () => undefined,
onCancelRoute = () => undefined,
guidanceActive = false, guidanceActive = false,
onStartGuidance = () => undefined, onStartGuidance = () => undefined,
onEditBoat = () => undefined, onEditBoat = () => undefined,
@@ -612,13 +616,29 @@ export function RoutePlanner({
</section> </section>
)} )}
<div className="route-plan-actions">
<button <button
className="secondary-action" className="secondary-action"
type="button" type="button"
onClick={() => setPlannerView("input")} onClick={() => {
onEditRoute();
setPlannerView("input");
}}
> >
Plan ändern Plan ändern
</button> </button>
<button
className="secondary-action route-cancel-action"
type="button"
onClick={() => {
onCancelRoute();
setPlannerView("input");
}}
>
<X size={15} aria-hidden="true" />
Route verwerfen
</button>
</div>
{result && ( {result && (
<section className="course-assistant-launch" aria-label="Navigation starten"> <section className="course-assistant-launch" aria-label="Navigation starten">
+14
View File
@@ -1029,6 +1029,20 @@ button:disabled {
font-weight: 800; 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"] { .route-result[data-severity="ok"] {
background: #dceee6; background: #dceee6;
color: #196f5c; color: #196f5c;
+132 -2
View File
@@ -28,6 +28,7 @@ vi.mock("maplibre-gl", () => {
layerDefinitions = new globalThis.Map<string, any>(); layerDefinitions = new globalThis.Map<string, any>();
renderedFeatures: any[] = []; renderedFeatures: any[] = [];
zoom = 13; zoom = 13;
styleLoaded = true;
container: HTMLElement; container: HTMLElement;
fitBounds = vi.fn(() => this); fitBounds = vi.fn(() => this);
easeTo = vi.fn(() => this); easeTo = vi.fn(() => this);
@@ -110,7 +111,7 @@ vi.mock("maplibre-gl", () => {
} }
isStyleLoaded() { isStyleLoaded() {
return true; return this.styleLoaded;
} }
getZoom() { 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(
<MapView
config={config}
position={{ lat: 53.1, lon: 7.1 }}
accuracyM={5}
startPoint={{ lat: 53.1, lon: 7.1 }}
destination={{ lat: 53.3, lon: 7.3 }}
waypoints={[{ lat: 53.2, lon: 7.2 }]}
pickMode={null}
route={firstRoute}
guidanceActive
guidanceTarget={{ lat: 53.2, lon: 7.2 }}
onPickCoordinate={stableOnPickCoordinate}
onMapReady={stableOnMapReady}
/>
);
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(
<MapView
config={config}
position={{ lat: 54.1, lon: 8.1 }}
accuracyM={5}
startPoint={{ lat: 54.1, lon: 8.1 }}
destination={{ lat: 54.4, lon: 8.4 }}
waypoints={[{ lat: 54.3, lon: 8.3 }]}
pickMode={null}
route={secondRoute}
guidanceActive
guidanceTarget={{ lat: 54.2, lon: 8.2 }}
onPickCoordinate={stableOnPickCoordinate}
onMapReady={stableOnMapReady}
/>
);
expect(routeSource.setData).toHaveBeenLastCalledWith({
type: "Feature",
properties: {},
geometry: secondRoute.geometry
});
view.rerender(
<MapView
config={config}
position={{ lat: 54.1, lon: 8.1 }}
accuracyM={5}
startPoint={null}
destination={null}
waypoints={[]}
pickMode={null}
route={null}
guidanceActive={false}
guidanceTarget={null}
onPickCoordinate={stableOnPickCoordinate}
onMapReady={stableOnMapReady}
/>
);
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 () => { it("focuses a route event requested by the shared navigation workspace", async () => {
const stableOnMapReady = vi.fn(); const stableOnMapReady = vi.fn();
const view = render( const view = render(
@@ -636,7 +759,14 @@ describe("MapView marine feature information", () => {
startPoint={{ lat: 52, lon: 7 }} startPoint={{ lat: 52, lon: 7 }}
destination={{ lat: 52, lon: 7.1 }} destination={{ lat: 52, lon: 7.1 }}
pickMode={null} pickMode={null}
route={null} route={{
geometry: { type: "LineString", coordinates: [[7, 52], [7.1, 52]] },
distanceNm: 4,
eta: null,
warnings: [],
dataSources: [],
routingMode: "fairway"
}}
guidanceActive guidanceActive
guidanceTarget={{ lat: 52.001, lon: 7.01 }} guidanceTarget={{ lat: 52.001, lon: 7.01 }}
onPickCoordinate={vi.fn()} onPickCoordinate={vi.fn()}
+38 -1
View File
@@ -383,6 +383,7 @@ describe("RoutePlanner", () => {
}); });
it("opens the compact result view and keeps critical warnings above secondary route details", () => { it("opens the compact result view and keeps critical warnings above secondary route details", () => {
const onEditRoute = vi.fn();
const result = { const result = {
...routeOption("primary", "Hauptroute", 4), ...routeOption("primary", "Hauptroute", 4),
warnings: [ warnings: [
@@ -411,6 +412,7 @@ describe("RoutePlanner", () => {
onClearDestination={vi.fn()} onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()} onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()} onSelectRoute={vi.fn()}
onEditRoute={onEditRoute}
onCollapse={vi.fn()} onCollapse={vi.fn()}
/> />
); );
@@ -422,9 +424,44 @@ describe("RoutePlanner", () => {
expect(screen.queryByRole("button", { name: "Start auf Karte setzen" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Start auf Karte setzen" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Plan ändern" })); fireEvent.click(screen.getByRole("button", { name: "Plan ändern" }));
expect(onEditRoute).toHaveBeenCalledTimes(1);
expect(screen.getByText("Route planen")).toBeVisible(); expect(screen.getByText("Route planen")).toBeVisible();
expect(screen.getByRole("button", { name: "Start auf Karte setzen" })).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(
<RoutePlanner
startPoint={{ lat: 52, lon: 7 }}
gpsPosition={null}
destination={{ lat: 52, lon: 7.1 }}
result={result}
routeOptions={[result]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onCancelRoute={onCancelRoute}
onCollapse={vi.fn()}
/>
);
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", () => { it("filters retired fairway and depth warnings from route results", () => {