From 9a4b587853cb4115b4b6442ba5143d490484e82e Mon Sep 17 00:00:00 2001 From: devthejo Date: Sun, 8 Mar 2026 08:47:01 +0100 Subject: [PATCH] feat(dae): itinerary --- src/scenes/DAEItem/Carte.js | 388 ++++++++++++++++++++++-------------- 1 file changed, 237 insertions(+), 151 deletions(-) diff --git a/src/scenes/DAEItem/Carte.js b/src/scenes/DAEItem/Carte.js index b08b351..28788f3 100644 --- a/src/scenes/DAEItem/Carte.js +++ b/src/scenes/DAEItem/Carte.js @@ -9,7 +9,7 @@ import { View, StyleSheet } from "react-native"; import Maplibre from "@maplibre/maplibre-react-native"; import polyline from "@mapbox/polyline"; import { MaterialCommunityIcons } from "@expo/vector-icons"; -import { Button } from "react-native-paper"; +import Drawer from "react-native-drawer"; import MapView from "~/containers/Map/MapView"; import Camera from "~/containers/Map/Camera"; @@ -18,12 +18,26 @@ import { DEFAULT_ZOOM_LEVEL } from "~/containers/Map/constants"; import StepZoomButtonGroup from "~/containers/Map/StepZoomButtonGroup"; import Text from "~/components/Text"; -import Loader from "~/components/Loader"; +import IconTouchTarget from "~/components/IconTouchTarget"; import { useTheme } from "~/theme"; import { useDefibsState, useNetworkState } from "~/stores"; import useLocation from "~/hooks/useLocation"; import { getDefibAvailability } from "~/utils/dae/getDefibAvailability"; -import { osmProfileUrl } from "~/scenes/AlertCurMap/routing"; +import { osmProfileUrl, profileDefaultModes } from "~/scenes/AlertCurMap/routing"; +import { routeToInstructions } from "~/lib/geo/osrmTextInstructions"; +import { + announceForA11yIfScreenReaderEnabled, + setA11yFocusAfterInteractions, +} from "~/lib/a11y"; + +import RoutingSteps from "~/scenes/AlertCurMap/RoutingSteps"; +import MapHeadRouting from "~/scenes/AlertCurMap/MapHeadRouting"; + +import { + STATE_CALCULATING_INIT, + STATE_CALCULATING_LOADED, + STATE_CALCULATING_LOADING, +} from "~/scenes/AlertCurMap/constants"; const STATUS_COLORS = { open: "#4CAF50", @@ -31,21 +45,6 @@ const STATUS_COLORS = { unknown: "#9E9E9E", }; -function formatDuration(seconds) { - if (!seconds || seconds <= 0) return ""; - const mins = Math.round(seconds / 60); - if (mins < 60) return `${mins} min`; - const h = Math.floor(mins / 60); - const m = mins % 60; - return m > 0 ? `${h}h${m}` : `${h}h`; -} - -function formatDistance(meters) { - if (!meters || meters <= 0) return ""; - if (meters < 1000) return `${Math.round(meters)} m`; - return `${(meters / 1000).toFixed(1)} km`; -} - export default React.memo(function DAEItemCarte() { const { colors } = useTheme(); const { selectedDefib: defib } = useDefibsState(["selectedDefib"]); @@ -67,11 +66,13 @@ export default React.memo(function DAEItemCarte() { const hasDefibCoords = defib && defib.latitude && defib.longitude; const [routeCoords, setRouteCoords] = useState(null); - const [routeInfo, setRouteInfo] = useState(null); const [routeError, setRouteError] = useState(null); const [loadingRoute, setLoadingRoute] = useState(false); + const [route, setRoute] = useState(null); + const [calculating, setCalculating] = useState(STATE_CALCULATING_INIT); - const profile = "foot"; // walking itinerary to defib + const defaultProfile = "foot"; + const [profile, setProfile] = useState(defaultProfile); // Compute route useEffect(() => { @@ -89,6 +90,7 @@ export default React.memo(function DAEItemCarte() { const fetchRoute = async () => { setLoadingRoute(true); + setCalculating(STATE_CALCULATING_LOADING); setRouteError(null); try { const origin = `${coords.longitude},${coords.latitude}`; @@ -100,15 +102,13 @@ export default React.memo(function DAEItemCarte() { const result = await res.json(); if (result.routes && result.routes.length > 0) { - const route = result.routes[0]; + const fetchedRoute = result.routes[0]; const decoded = polyline - .decode(route.geometry) + .decode(fetchedRoute.geometry) .map((p) => p.reverse()); setRouteCoords(decoded); - setRouteInfo({ - distance: route.distance, - duration: route.duration, - }); + setRoute(fetchedRoute); + setCalculating(STATE_CALCULATING_LOADED); } } catch (err) { if (err.name !== "AbortError") { @@ -134,6 +134,75 @@ export default React.memo(function DAEItemCarte() { profile, ]); + // Compute instructions from route steps + const allSteps = useMemo(() => { + if (!route) return []; + return route.legs.flatMap((leg) => leg.steps); + }, [route]); + + const instructions = useMemo(() => { + if (allSteps.length === 0) return []; + return routeToInstructions(allSteps); + }, [allSteps]); + + const distance = useMemo( + () => allSteps.reduce((acc, step) => acc + (step?.distance || 0), 0), + [allSteps], + ); + const duration = useMemo( + () => allSteps.reduce((acc, step) => acc + (step?.duration || 0), 0), + [allSteps], + ); + + const destinationName = useMemo(() => { + if (!route) return defib?.nom || ""; + const { legs } = route; + const lastLeg = legs[legs.length - 1]; + if (!lastLeg) return defib?.nom || ""; + const { steps } = lastLeg; + const lastStep = steps[steps.length - 1]; + return lastStep?.name || defib?.nom || ""; + }, [route, defib]); + + // Stepper drawer state + const [stepperIsOpened, setStepperIsOpened] = useState(false); + const routingSheetTitleA11yRef = useRef(null); + const a11yStepsEntryRef = useRef(null); + const mapHeadOpenRef = useRef(null); + const mapHeadSeeAllRef = useRef(null); + const lastStepsTriggerRef = useRef(null); + + const openStepper = useCallback( + (triggerRef) => { + if (triggerRef) { + lastStepsTriggerRef.current = triggerRef; + } + setStepperIsOpened(true); + }, + [], + ); + + const closeStepper = useCallback(() => { + setStepperIsOpened(false); + setA11yFocusAfterInteractions(lastStepsTriggerRef); + }, []); + + const stepperOnOpen = useCallback(() => { + if (!stepperIsOpened) { + setStepperIsOpened(true); + } + setA11yFocusAfterInteractions(routingSheetTitleA11yRef); + announceForA11yIfScreenReaderEnabled("Liste des étapes ouverte"); + }, [stepperIsOpened]); + + const stepperOnClose = useCallback(() => { + if (stepperIsOpened) { + setStepperIsOpened(false); + } + announceForA11yIfScreenReaderEnabled("Liste des étapes fermée"); + setA11yFocusAfterInteractions(lastStepsTriggerRef); + }, [stepperIsOpened]); + // Defib marker GeoJSON const defibGeoJSON = useMemo(() => { if (!hasDefibCoords) return null; @@ -183,6 +252,8 @@ export default React.memo(function DAEItemCarte() { }; }, [hasUserCoords, hasDefibCoords, coords, defib]); + const profileDefaultMode = profileDefaultModes[profile]; + if (!defib) return null; return ( @@ -211,119 +282,150 @@ export default React.memo(function DAEItemCarte() { )} - {/* Route info bar */} - {routeInfo && ( - - ({ + main: { opacity: (2 - ratio) / 2 }, + })} + tweenDuration={250} + openDrawerOffset={40} + open={stepperIsOpened} + onOpen={stepperOnOpen} + onClose={stepperOnClose} + tapToClose + negotiatePan + content={ + - - {formatDistance(routeInfo.distance)} - {routeInfo.duration - ? ` · ${formatDuration(routeInfo.duration)}` - : ""} - - {loadingRoute && ( - - Mise à jour… - + } + > + + {/* A11y entry point for routing steps */} + openStepper(a11yStepsEntryRef)} + style={({ pressed }) => ({ + position: "absolute", + top: 4, + left: 4, + zIndex: 10, + backgroundColor: colors.surface, + borderRadius: 8, + opacity: pressed ? 0.7 : 1, + })} + > + + + + + + + {/* Route line */} + {routeGeoJSON && ( + + + + )} + + {/* Defib marker */} + {defibGeoJSON && ( + + + + + )} + + {/* User location */} + {isLastKnown && hasUserCoords ? ( + + ) : ( + + )} + + + {/* Head routing step overlay */} + {instructions.length > 0 && ( + )} - )} + - - - - {/* Route line */} - {routeGeoJSON && ( - - - - )} - - {/* Defib marker */} - {defibGeoJSON && ( - - - - - )} - - {/* User location */} - {isLastKnown && hasUserCoords ? ( - - ) : ( - - )} - {/* Route error */} @@ -358,22 +460,6 @@ const styles = StyleSheet.create({ fontSize: 13, flex: 1, }, - routeInfoBar: { - flexDirection: "row", - alignItems: "center", - paddingHorizontal: 16, - paddingVertical: 10, - borderBottomWidth: StyleSheet.hairlineWidth, - gap: 8, - }, - routeInfoText: { - fontSize: 15, - fontWeight: "600", - flex: 1, - }, - routeInfoLoading: { - fontSize: 12, - }, routeErrorOverlay: { position: "absolute", bottom: 16,