feat(dae): itinerary

This commit is contained in:
devthejo 2026-03-08 08:47:01 +01:00
parent 150f23d7a9
commit 9a4b587853
No known key found for this signature in database
GPG key ID: 00CCA7A92B1D5351

View file

@ -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,40 +282,55 @@ export default React.memo(function DAEItemCarte() {
</View>
)}
{/* Route info bar */}
{routeInfo && (
<View
style={[
styles.routeInfoBar,
{
<Drawer
type="overlay"
tweenHandler={(ratio) => ({
main: { opacity: (2 - ratio) / 2 },
})}
tweenDuration={250}
openDrawerOffset={40}
open={stepperIsOpened}
onOpen={stepperOnOpen}
onClose={stepperOnClose}
tapToClose
negotiatePan
content={
<RoutingSteps
setProfile={setProfile}
profile={profile}
closeStepper={closeStepper}
destinationName={destinationName}
distance={distance}
duration={duration}
instructions={instructions}
calculatingState={calculating}
titleA11yRef={routingSheetTitleA11yRef}
/>
}
>
<View style={{ flex: 1 }}>
{/* A11y entry point for routing steps */}
<IconTouchTarget
ref={a11yStepsEntryRef}
accessibilityLabel="Ouvrir la liste des étapes de l'itinéraire"
accessibilityHint="Affiche la destination, la distance, la durée et toutes les étapes sans utiliser la carte."
onPress={() => openStepper(a11yStepsEntryRef)}
style={({ pressed }) => ({
position: "absolute",
top: 4,
left: 4,
zIndex: 10,
backgroundColor: colors.surface,
borderBottomColor: colors.outlineVariant || colors.grey,
},
]}
borderRadius: 8,
opacity: pressed ? 0.7 : 1,
})}
>
<MaterialCommunityIcons
name="walk"
size={20}
color={colors.primary}
name="format-list-bulleted"
size={24}
color={colors.onSurface}
/>
<Text style={styles.routeInfoText}>
{formatDistance(routeInfo.distance)}
{routeInfo.duration
? ` · ${formatDuration(routeInfo.duration)}`
: ""}
</Text>
{loadingRoute && (
<Text
style={[
styles.routeInfoLoading,
{ color: colors.onSurfaceVariant || colors.grey },
]}
>
Mise à jour
</Text>
)}
</View>
)}
</IconTouchTarget>
<MapView
mapRef={mapRef}
@ -324,6 +410,22 @@ export default React.memo(function DAEItemCarte() {
<Maplibre.UserLocation visible showsUserHeadingIndicator />
)}
</MapView>
{/* Head routing step overlay */}
{instructions.length > 0 && (
<MapHeadRouting
instructions={instructions}
distance={distance}
profileDefaultMode={profileDefaultMode}
openStepper={openStepper}
openStepperTriggerRef={mapHeadOpenRef}
seeAllStepsTriggerRef={mapHeadSeeAllRef}
calculatingState={calculating}
/>
)}
</View>
</Drawer>
<StepZoomButtonGroup mapRef={mapRef} setZoomLevel={setZoomLevel} />
{/* 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,