feat(dae): itinerary
This commit is contained in:
parent
150f23d7a9
commit
9a4b587853
1 changed files with 237 additions and 151 deletions
|
|
@ -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() {
|
|||
</View>
|
||||
)}
|
||||
|
||||
{/* Route info bar */}
|
||||
{routeInfo && (
|
||||
<View
|
||||
style={[
|
||||
styles.routeInfoBar,
|
||||
{
|
||||
backgroundColor: colors.surface,
|
||||
borderBottomColor: colors.outlineVariant || colors.grey,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="walk"
|
||||
size={20}
|
||||
color={colors.primary}
|
||||
<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}
|
||||
/>
|
||||
<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 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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</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} />
|
||||
|
||||
{/* 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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue