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 Maplibre from "@maplibre/maplibre-react-native";
import polyline from "@mapbox/polyline"; import polyline from "@mapbox/polyline";
import { MaterialCommunityIcons } from "@expo/vector-icons"; 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 MapView from "~/containers/Map/MapView";
import Camera from "~/containers/Map/Camera"; 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 StepZoomButtonGroup from "~/containers/Map/StepZoomButtonGroup";
import Text from "~/components/Text"; import Text from "~/components/Text";
import Loader from "~/components/Loader"; import IconTouchTarget from "~/components/IconTouchTarget";
import { useTheme } from "~/theme"; import { useTheme } from "~/theme";
import { useDefibsState, useNetworkState } from "~/stores"; import { useDefibsState, useNetworkState } from "~/stores";
import useLocation from "~/hooks/useLocation"; import useLocation from "~/hooks/useLocation";
import { getDefibAvailability } from "~/utils/dae/getDefibAvailability"; 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 = { const STATUS_COLORS = {
open: "#4CAF50", open: "#4CAF50",
@ -31,21 +45,6 @@ const STATUS_COLORS = {
unknown: "#9E9E9E", 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() { export default React.memo(function DAEItemCarte() {
const { colors } = useTheme(); const { colors } = useTheme();
const { selectedDefib: defib } = useDefibsState(["selectedDefib"]); const { selectedDefib: defib } = useDefibsState(["selectedDefib"]);
@ -67,11 +66,13 @@ export default React.memo(function DAEItemCarte() {
const hasDefibCoords = defib && defib.latitude && defib.longitude; const hasDefibCoords = defib && defib.latitude && defib.longitude;
const [routeCoords, setRouteCoords] = useState(null); const [routeCoords, setRouteCoords] = useState(null);
const [routeInfo, setRouteInfo] = useState(null);
const [routeError, setRouteError] = useState(null); const [routeError, setRouteError] = useState(null);
const [loadingRoute, setLoadingRoute] = useState(false); 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 // Compute route
useEffect(() => { useEffect(() => {
@ -89,6 +90,7 @@ export default React.memo(function DAEItemCarte() {
const fetchRoute = async () => { const fetchRoute = async () => {
setLoadingRoute(true); setLoadingRoute(true);
setCalculating(STATE_CALCULATING_LOADING);
setRouteError(null); setRouteError(null);
try { try {
const origin = `${coords.longitude},${coords.latitude}`; const origin = `${coords.longitude},${coords.latitude}`;
@ -100,15 +102,13 @@ export default React.memo(function DAEItemCarte() {
const result = await res.json(); const result = await res.json();
if (result.routes && result.routes.length > 0) { if (result.routes && result.routes.length > 0) {
const route = result.routes[0]; const fetchedRoute = result.routes[0];
const decoded = polyline const decoded = polyline
.decode(route.geometry) .decode(fetchedRoute.geometry)
.map((p) => p.reverse()); .map((p) => p.reverse());
setRouteCoords(decoded); setRouteCoords(decoded);
setRouteInfo({ setRoute(fetchedRoute);
distance: route.distance, setCalculating(STATE_CALCULATING_LOADED);
duration: route.duration,
});
} }
} catch (err) { } catch (err) {
if (err.name !== "AbortError") { if (err.name !== "AbortError") {
@ -134,6 +134,75 @@ export default React.memo(function DAEItemCarte() {
profile, 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 // Defib marker GeoJSON
const defibGeoJSON = useMemo(() => { const defibGeoJSON = useMemo(() => {
if (!hasDefibCoords) return null; if (!hasDefibCoords) return null;
@ -183,6 +252,8 @@ export default React.memo(function DAEItemCarte() {
}; };
}, [hasUserCoords, hasDefibCoords, coords, defib]); }, [hasUserCoords, hasDefibCoords, coords, defib]);
const profileDefaultMode = profileDefaultModes[profile];
if (!defib) return null; if (!defib) return null;
return ( return (
@ -211,119 +282,150 @@ export default React.memo(function DAEItemCarte() {
</View> </View>
)} )}
{/* Route info bar */} <Drawer
{routeInfo && ( type="overlay"
<View tweenHandler={(ratio) => ({
style={[ main: { opacity: (2 - ratio) / 2 },
styles.routeInfoBar, })}
{ tweenDuration={250}
backgroundColor: colors.surface, openDrawerOffset={40}
borderBottomColor: colors.outlineVariant || colors.grey, open={stepperIsOpened}
}, onOpen={stepperOnOpen}
]} onClose={stepperOnClose}
> tapToClose
<MaterialCommunityIcons negotiatePan
name="walk" content={
size={20} <RoutingSteps
color={colors.primary} setProfile={setProfile}
profile={profile}
closeStepper={closeStepper}
destinationName={destinationName}
distance={distance}
duration={duration}
instructions={instructions}
calculatingState={calculating}
titleA11yRef={routingSheetTitleA11yRef}
/> />
<Text style={styles.routeInfoText}> }
{formatDistance(routeInfo.distance)} >
{routeInfo.duration <View style={{ flex: 1 }}>
? ` · ${formatDuration(routeInfo.duration)}` {/* A11y entry point for routing steps */}
: ""} <IconTouchTarget
</Text> ref={a11yStepsEntryRef}
{loadingRoute && ( accessibilityLabel="Ouvrir la liste des étapes de l'itinéraire"
<Text accessibilityHint="Affiche la destination, la distance, la durée et toutes les étapes sans utiliser la carte."
style={[ onPress={() => openStepper(a11yStepsEntryRef)}
styles.routeInfoLoading, style={({ pressed }) => ({
{ color: colors.onSurfaceVariant || colors.grey }, position: "absolute",
]} top: 4,
> left: 4,
Mise à jour zIndex: 10,
</Text> backgroundColor: colors.surface,
borderRadius: 8,
opacity: pressed ? 0.7 : 1,
})}
>
<MaterialCommunityIcons
name="format-list-bulleted"
size={24}
color={colors.onSurface}
/>
</IconTouchTarget>
<MapView
mapRef={mapRef}
compassViewPosition={1}
compassViewMargin={{ x: 10, y: 10 }}
>
<Camera
cameraKey={cameraKey}
setCameraKey={setCameraKey}
refreshCamera={refreshCamera}
cameraRef={cameraRef}
followUserLocation={!bounds}
followUserMode={
bounds
? Maplibre.UserTrackingMode.None
: Maplibre.UserTrackingMode.Follow
}
followPitch={0}
zoomLevel={zoomLevel}
bounds={bounds}
detached={false}
/>
{/* Route line */}
{routeGeoJSON && (
<Maplibre.ShapeSource id="routeSource" shape={routeGeoJSON}>
<Maplibre.LineLayer
id="routeLineLayer"
style={{
lineColor: "rgba(49, 76, 205, 0.84)",
lineWidth: 4,
lineCap: "round",
lineJoin: "round",
lineOpacity: 0.84,
}}
/>
</Maplibre.ShapeSource>
)}
{/* Defib marker */}
{defibGeoJSON && (
<Maplibre.ShapeSource id="defibItemSource" shape={defibGeoJSON}>
<Maplibre.CircleLayer
id="defibItemCircle"
style={{
circleRadius: 10,
circleColor: ["get", "color"],
circleStrokeColor: "#FFFFFF",
circleStrokeWidth: 2.5,
}}
/>
<Maplibre.SymbolLayer
id="defibItemLabel"
aboveLayerID="defibItemCircle"
style={{
textField: ["get", "nom"],
textSize: 12,
textOffset: [0, 1.8],
textAnchor: "top",
textMaxWidth: 14,
textColor: colors.onSurface,
textHaloColor: colors.surface,
textHaloWidth: 1,
}}
/>
</Maplibre.ShapeSource>
)}
{/* User location */}
{isLastKnown && hasUserCoords ? (
<LastKnownLocationMarker
coordinates={coords}
timestamp={lastKnownTimestamp}
id="lastKnownLocation_daeItem"
/>
) : (
<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> </View>
)} </Drawer>
<MapView
mapRef={mapRef}
compassViewPosition={1}
compassViewMargin={{ x: 10, y: 10 }}
>
<Camera
cameraKey={cameraKey}
setCameraKey={setCameraKey}
refreshCamera={refreshCamera}
cameraRef={cameraRef}
followUserLocation={!bounds}
followUserMode={
bounds
? Maplibre.UserTrackingMode.None
: Maplibre.UserTrackingMode.Follow
}
followPitch={0}
zoomLevel={zoomLevel}
bounds={bounds}
detached={false}
/>
{/* Route line */}
{routeGeoJSON && (
<Maplibre.ShapeSource id="routeSource" shape={routeGeoJSON}>
<Maplibre.LineLayer
id="routeLineLayer"
style={{
lineColor: "rgba(49, 76, 205, 0.84)",
lineWidth: 4,
lineCap: "round",
lineJoin: "round",
lineOpacity: 0.84,
}}
/>
</Maplibre.ShapeSource>
)}
{/* Defib marker */}
{defibGeoJSON && (
<Maplibre.ShapeSource id="defibItemSource" shape={defibGeoJSON}>
<Maplibre.CircleLayer
id="defibItemCircle"
style={{
circleRadius: 10,
circleColor: ["get", "color"],
circleStrokeColor: "#FFFFFF",
circleStrokeWidth: 2.5,
}}
/>
<Maplibre.SymbolLayer
id="defibItemLabel"
aboveLayerID="defibItemCircle"
style={{
textField: ["get", "nom"],
textSize: 12,
textOffset: [0, 1.8],
textAnchor: "top",
textMaxWidth: 14,
textColor: colors.onSurface,
textHaloColor: colors.surface,
textHaloWidth: 1,
}}
/>
</Maplibre.ShapeSource>
)}
{/* User location */}
{isLastKnown && hasUserCoords ? (
<LastKnownLocationMarker
coordinates={coords}
timestamp={lastKnownTimestamp}
id="lastKnownLocation_daeItem"
/>
) : (
<Maplibre.UserLocation visible showsUserHeadingIndicator />
)}
</MapView>
<StepZoomButtonGroup mapRef={mapRef} setZoomLevel={setZoomLevel} /> <StepZoomButtonGroup mapRef={mapRef} setZoomLevel={setZoomLevel} />
{/* Route error */} {/* Route error */}
@ -358,22 +460,6 @@ const styles = StyleSheet.create({
fontSize: 13, fontSize: 13,
flex: 1, 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: { routeErrorOverlay: {
position: "absolute", position: "absolute",
bottom: 16, bottom: 16,