as-app/src/scenes/DAEItem/Carte.js

459 lines
14 KiB
JavaScript

import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
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 Drawer from "react-native-drawer";
import MapView from "~/containers/Map/MapView";
import Camera from "~/containers/Map/Camera";
import LastKnownLocationMarker from "~/containers/Map/LastKnownLocationMarker";
import { DEFAULT_ZOOM_LEVEL } from "~/containers/Map/constants";
import StepZoomButtonGroup from "~/containers/Map/StepZoomButtonGroup";
import Text from "~/components/Text";
import IconTouchTarget from "~/components/IconTouchTarget";
import { useTheme } from "~/theme";
import { useDefibsState, useNetworkState } from "~/stores";
import useLocation from "~/hooks/useLocation";
import {
osmProfileUrl,
profileDefaultModes,
} from "~/scenes/AlertCurMap/routing";
import { routeToInstructions } from "~/lib/geo/osrmTextInstructions";
import {
announceForA11yIfScreenReaderEnabled,
setA11yFocusAfterInteractions,
} from "~/lib/a11y";
import markerDae from "~/assets/img/marker-dae.png";
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";
export default React.memo(function DAEItemCarte() {
const { colors } = useTheme();
const { selectedDefib: defib } = useDefibsState(["selectedDefib"]);
const { hasInternetConnection } = useNetworkState(["hasInternetConnection"]);
const { coords, isLastKnown, lastKnownTimestamp } = useLocation();
const mapRef = useRef();
const cameraRef = useRef();
const [cameraKey, setCameraKey] = useState(1);
const [zoomLevel, setZoomLevel] = useState(DEFAULT_ZOOM_LEVEL);
const abortControllerRef = useRef(null);
const refreshCamera = useCallback(() => {
setCameraKey(`${Date.now()}`);
}, []);
const hasUserCoords =
coords && coords.latitude !== null && coords.longitude !== null;
const hasDefibCoords = defib && defib.latitude && defib.longitude;
const [routeCoords, setRouteCoords] = 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 defaultProfile = "foot";
const [profile, setProfile] = useState(defaultProfile);
// Compute route
useEffect(() => {
if (!hasUserCoords || !hasDefibCoords || !hasInternetConnection) {
return;
}
// Abort any previous request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const controller = new AbortController();
abortControllerRef.current = controller;
const fetchRoute = async () => {
setLoadingRoute(true);
setCalculating(STATE_CALCULATING_LOADING);
setRouteError(null);
try {
const origin = `${coords.longitude},${coords.latitude}`;
const target = `${defib.longitude},${defib.latitude}`;
const osrmUrl = osmProfileUrl[profile] || osmProfileUrl.foot;
const url = `${osrmUrl}/route/v1/${profile}/${origin};${target}?overview=full&steps=true`;
const res = await fetch(url, { signal: controller.signal });
const result = await res.json();
if (result.routes && result.routes.length > 0) {
const fetchedRoute = result.routes[0];
const decoded = polyline
.decode(fetchedRoute.geometry)
.map((p) => p.reverse());
setRouteCoords(decoded);
setRoute(fetchedRoute);
setCalculating(STATE_CALCULATING_LOADED);
}
} catch (err) {
if (err.name !== "AbortError") {
console.warn("Route calculation failed:", err.message);
setRouteError(err);
}
} finally {
setLoadingRoute(false);
}
};
fetchRoute();
return () => {
controller.abort();
};
}, [
hasUserCoords,
hasDefibCoords,
hasInternetConnection,
coords,
defib,
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;
return {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: {
type: "Point",
coordinates: [defib.longitude, defib.latitude],
},
properties: {
id: defib.id,
nom: defib.nom || "Défibrillateur",
},
},
],
};
}, [defib, hasDefibCoords]);
// Route line GeoJSON
const routeGeoJSON = useMemo(() => {
if (!routeCoords || routeCoords.length < 2) return null;
return {
type: "Feature",
geometry: {
type: "LineString",
coordinates: routeCoords,
},
};
}, [routeCoords]);
// Camera bounds to show both user + defib
const bounds = useMemo(() => {
if (!hasUserCoords || !hasDefibCoords) return null;
const lats = [coords.latitude, defib.latitude];
const lons = [coords.longitude, defib.longitude];
return {
ne: [Math.max(...lons), Math.max(...lats)],
sw: [Math.min(...lons), Math.min(...lats)],
};
}, [hasUserCoords, hasDefibCoords, coords, defib]);
const profileDefaultMode = profileDefaultModes[profile];
if (!defib) return null;
return (
<View style={styles.container}>
{/* Offline banner */}
{!hasInternetConnection && (
<View
style={[
styles.offlineBanner,
{ backgroundColor: (colors.error || "#F44336") + "15" },
]}
>
<MaterialCommunityIcons
name="wifi-off"
size={18}
color={colors.error || "#F44336"}
/>
<Text
style={[
styles.offlineBannerText,
{ color: colors.error || "#F44336" },
]}
>
Hors ligne l'itinéraire n'est pas disponible
</Text>
</View>
)}
<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,
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>
)}
<Maplibre.Images images={{ dae: markerDae }} />
{/* Defib marker */}
{defibGeoJSON && (
<Maplibre.ShapeSource id="defibItemSource" shape={defibGeoJSON}>
<Maplibre.SymbolLayer
id="defibItemSymbol"
style={{
iconImage: "dae",
iconSize: 0.5,
iconAllowOverlap: true,
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>
</Drawer>
<StepZoomButtonGroup mapRef={mapRef} setZoomLevel={setZoomLevel} />
{/* Route error */}
{routeError && !loadingRoute && (
<View style={styles.routeErrorOverlay}>
<Text
style={[
styles.routeErrorText,
{ color: colors.onSurfaceVariant || colors.grey },
]}
>
Impossible de calculer l'itinéraire
</Text>
</View>
)}
</View>
);
});
const styles = StyleSheet.create({
container: {
flex: 1,
},
offlineBanner: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 10,
gap: 8,
},
offlineBannerText: {
fontSize: 13,
flex: 1,
},
routeErrorOverlay: {
position: "absolute",
bottom: 16,
left: 16,
right: 16,
alignItems: "center",
},
routeErrorText: {
fontSize: 13,
textAlign: "center",
},
});