as-app/src/scenes/AlertCurMap/index.js

666 lines
19 KiB
JavaScript

import React, {
useState,
useRef,
useMemo,
useCallback,
useEffect,
} from "react";
import { StyleSheet, View, AppState } from "react-native";
import cloneDeep from "lodash.clonedeep";
import Maplibre from "@maplibre/maplibre-react-native";
import polyline from "@mapbox/polyline";
import { getDistance } from "geolib";
import { routeToInstructions } from "~/lib/geo/osrmTextInstructions";
import getRouteState from "~/lib/geo/getRouteState";
import shallowCompare from "~/utils/array/shallowCompare";
import { storeLocation } from "~/utils/location/storage";
import useLocation from "~/hooks/useLocation";
import withConnectivity from "~/hoc/withConnectivity";
import useShallowMemo from "~/hooks/useShallowMemo";
import useShallowEffect from "~/hooks/useShallowEffect";
import Drawer from "react-native-drawer";
import { point, lineString } from "@turf/helpers";
import nearestPointOnLine from "@turf/nearest-point-on-line";
import lineSlice from "@turf/line-slice";
import length from "@turf/length";
import { useAlertState } from "~/stores";
import Camera from "~/containers/Map/Camera";
import MapView from "~/containers/Map/MapView";
import FeatureImages from "~/containers/Map/FeatureImages";
import ShapePoints from "~/containers/Map/ShapePoints";
import SelectedFeatureBubble from "~/containers/Map/SelectedFeatureBubble";
import LastKnownLocationMarker from "~/containers/Map/LastKnownLocationMarker";
import MapLinksPopup from "~/containers/MapLinksPopup";
import ControlButtons from "./ControlButtons";
import MapHeadRouting from "./MapHeadRouting.js";
import useFeatures from "./useFeatures";
import useOnRegionDidChange from "./useOnRegionDidChange";
import useOnPress from "./useOnPress";
import getDestinationName from "./getDestinationName.js";
import { osmProfileUrl } from "./routing";
import RoutingSteps from "./RoutingSteps";
import {
STATE_CALCULATING_INIT,
STATE_CALCULATING_LOADED,
STATE_CALCULATING_LOADING,
STATE_CALCULATING_RELOADING,
} from "./constants";
import { BoundType } from "~/containers/Map/constants";
import useMapInit from "~/containers/Map/useMapInit";
import { deepEqual } from "fast-equals";
const compassViewPosition = 2; // 0: TopLeft, 1: TopRight, 2: BottomLeft, 3: BottomRight
const compassViewMargin = { x: 2, y: 100 };
function AlertCurMap() {
const [userCoords, setUserCoords] = useState({
latitude: null,
longitude: null,
});
const [isUsingLastKnown, setIsUsingLastKnown] = useState(false);
// Use location hook for last known state and reload
const { isLastKnown, lastKnownTimestamp, reload, coords } = useLocation();
// Sync with useLocation's isLastKnown
useEffect(() => {
if (isUsingLastKnown && !isLastKnown) {
// If we're using last known location but useLocation indicates current location is available
setIsUsingLastKnown(false);
} else if (!isUsingLastKnown && isLastKnown) {
// If useLocation indicates we should use last known location
setIsUsingLastKnown(true);
setUserCoords(coords);
}
}, [isUsingLastKnown, isLastKnown, coords]);
// Handle app state changes
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextAppState) => {
if (nextAppState === "active") {
reload(); // Use reload from useLocation when app comes to foreground
}
});
return () => {
subscription.remove();
};
}, [reload]);
const userCoordRef = useRef();
const onUserLocationUpdate = useCallback((location) => {
const { coords, timestamp } = location;
if (!(coords.latitude && coords.longitude)) {
return;
}
const newUserCoords = {
latitude: coords.latitude,
longitude: coords.longitude,
};
if (
!userCoordRef.current ||
!deepEqual(userCoordRef.current, newUserCoords)
) {
userCoordRef.current = newUserCoords;
setUserCoords(newUserCoords);
setIsUsingLastKnown(false); // We have current location now
// Store location for last known location feature
storeLocation(coords, timestamp);
}
}, []);
const {
clusterFeature,
setClusterFeature,
mapRef,
setDetached,
cameraRef,
followUserLocation,
followUserMode,
followPitch,
bounds,
zoomLevel,
contentInset,
boundType,
setBoundType,
setZoomLevel,
detached,
cameraKey,
setCameraKey,
refreshCamera,
} = useMapInit({
initialBoundType: BoundType.NAVIGATION,
userCoords,
});
const { navAlertCur } = useAlertState(["navAlertCur"]);
const { coordinates: alertCoords } = navAlertCur.alert.location;
const [calculating, setCalculating] = useState(STATE_CALCULATING_INIT);
const abortControllerRef = useRef(null);
const calculationTimeoutRef = useRef(null);
const { alertingList } = useAlertState(["alertingList"]);
const [driving, setDriving] = useState({});
const defaultProfile = "car";
const [profile, setProfile] = useState(defaultProfile);
const fetchRoute = useCallback(
async ({ origin, target, signal }) => {
console.log("Calculating route ...");
const points = [];
points.push(origin);
points.push(target);
const coordinates = points.map((point) => point.join(",")).join(";");
const osrmUrl = osmProfileUrl[profile];
const url = `${osrmUrl}/route/v1/${profile}/${coordinates}?overview=full&steps=true`;
const res = await fetch(url, { signal });
const result = await res.json();
const { routes } = result;
const [route] = routes;
const { geometry } = route;
const routeCoords = polyline.decode(geometry).map((p) => p.reverse());
return {
route,
routeCoords,
};
},
[profile],
);
const calculateRoute = useCallback(
async (origin, signal) => {
const target = alertCoords;
const result = await fetchRoute({ origin, target, signal });
const { route, routeCoords } = result;
setDriving({
route,
routeCoords,
origin,
target,
profile,
});
},
[fetchRoute, alertCoords, profile],
);
const prevValuesRef = useRef({
userCoordArr: null,
profile: null,
alertCoords: null,
});
const debounceCalculation = useCallback((callback, delay) => {
if (calculationTimeoutRef.current) {
clearTimeout(calculationTimeoutRef.current);
}
calculationTimeoutRef.current = setTimeout(callback, delay);
}, []);
useShallowEffect(() => {
if (
!(
userCoords &&
userCoords.longitude !== null &&
userCoords.latitude !== null
)
) {
return;
}
const userCoordArr = [userCoords.longitude, userCoords.latitude];
if (
shallowCompare(prevValuesRef.current?.userCoordArr, userCoordArr) &&
prevValuesRef.current?.profile === profile &&
shallowCompare(prevValuesRef.current?.alertCoords, alertCoords)
) {
return; // Skip if values haven't changed
}
prevValuesRef.current = {
userCoordArr,
profile,
alertCoords,
};
// Debounce the route calculation
debounceCalculation(() => {
// Abort any ongoing fetch operation
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create a new AbortController for this effect execution
abortControllerRef.current = new AbortController();
const { signal } = abortControllerRef.current;
const calculateRouteIfNeeded = async () => {
if (!driving.origin || driving.profile !== profile) {
setCalculating(STATE_CALCULATING_LOADING);
await calculateRoute(userCoordArr, signal);
setCalculating(STATE_CALCULATING_LOADED);
return;
}
const { routeCoords } = driving;
if (!routeCoords) {
return;
}
const { origin, target } = driving;
const routePoints = [origin, ...routeCoords, target];
// Adjust the route to exclude passed points
// const adjustedRoutePoints = routePoints.slice(currentIndexRef.current);
const { isOffRoute, distanceToLine, nextIndex, snappedPoint } =
getRouteState(userCoordArr, routePoints);
console.log({ isOffRoute, distanceToLine });
const hasNewTarget = !shallowCompare(alertCoords, target);
const needRouteRecalculation = isOffRoute || hasNewTarget;
if (needRouteRecalculation) {
console.log("Recalculating ....");
setCalculating(STATE_CALCULATING_RELOADING);
await calculateRoute(userCoordArr, signal);
setCalculating(STATE_CALCULATING_LOADED);
} else {
const snappedCoords = snappedPoint.geometry.coordinates;
const remainingRoute = routePoints.slice(nextIndex + 1);
// TODO optimize routeCoords by stripping out the points that are off route, keeping one segment previous
setDriving({
route: driving.route,
routeCoords,
remainingRoute,
snappedCoords,
origin: userCoordArr,
target,
profile,
});
}
};
calculateRouteIfNeeded().catch((error) => {
if (error.name === "AbortError") {
console.log("Route calculation aborted");
} else {
console.error("Error calculating route:", error);
setCalculating(STATE_CALCULATING_LOADED); // Set state to loaded even on error
}
});
}, 500); // 500ms debounce
return () => {
if (calculationTimeoutRef.current) {
clearTimeout(calculationTimeoutRef.current);
}
};
}, [
userCoords,
driving,
calculateRoute,
profile,
alertCoords,
debounceCalculation,
]);
// const adaptRouteToCoords = useCallback(
// (route, remainingRouteWithSnapped, userCoords) => {
// console.log("route", route);
// console.log("remainingRouteWithSnapped", remainingRouteWithSnapped);
// console.log("userCoords", userCoords);
// return route.legs.flatMap((leg) => leg.steps);
// },
// [],
// );
const adaptRouteToCoords = useCallback(
(route, remainingRouteWithSnapped, userCoords) => {
// Convert remainingRouteWithSnapped to a Set for efficient lookup
const remainingCoordsSet = new Set(
remainingRouteWithSnapped.map(
([lng, lat]) => `${lat.toFixed(6)},${lng.toFixed(6)}`,
),
);
// Filter the steps based on the remaining coordinates
const filteredSteps = route.legs.flatMap((leg) =>
leg.steps.filter((step) => {
// Decode the step's geometry to get its coordinates
const stepCoords = polyline.decode(step.geometry);
// Check if any of the step's coordinates are in remainingCoordsSet
return stepCoords.some(([lat, lng]) => {
const coordKey = `${lat.toFixed(6)},${lng.toFixed(6)}`;
return remainingCoordsSet.has(coordKey);
});
}),
);
return filteredSteps;
},
[],
);
const { snappedCoords, routeCoords, remainingRoute, route } = driving;
const remainingRouteWithSnapped = useMemo(
() => [
...(snappedCoords ? [snappedCoords] : []),
...(remainingRoute || routeCoords || []),
],
[snappedCoords, remainingRoute, routeCoords],
);
const filteredRoute = useShallowMemo(() => {
if (
!(
route &&
userCoords &&
userCoords.latitude !== null &&
userCoords.longitude !== null
)
) {
return [];
}
return adaptRouteToCoords(route, remainingRouteWithSnapped, userCoords);
// debug byPass
// const allSteps = [];
// for (const leg of route?.legs || []) {
// for (const step of leg.steps) {
// allSteps.push(step);
// }
// }
// return allSteps;
}, [adaptRouteToCoords, route, remainingRouteWithSnapped, userCoords]);
const preparedRoute = useShallowMemo(() => {
const steps = cloneDeep(filteredRoute);
const step = steps[0];
if (step) {
// Check if the step has geometry
if (step.geometry) {
// Decode the geometry to get the step's coordinates
const stepCoords = polyline.decode(step.geometry).map(([lat, lng]) => ({
latitude: lat,
longitude: lng,
}));
// Find the closest point on the step to the user's current location
let closestDistance = Infinity;
let closestIndex = 0;
stepCoords.forEach((coord, index) => {
const distance = getDistance(userCoords, coord);
if (distance < closestDistance) {
closestDistance = distance;
closestIndex = index;
}
});
// Calculate the remaining distance along the step from the closest point
let remainingDistance = 0;
for (let i = closestIndex; i < stepCoords.length - 1; i++) {
remainingDistance += getDistance(stepCoords[i], stepCoords[i + 1]);
}
// Update the step's distance
step.distance = remainingDistance;
} else {
// If no geometry is available, default to the original distance
console.warn("Step geometry is missing.");
}
}
return steps;
}, [filteredRoute, userCoords]);
const distance = preparedRoute.reduce(
(acc, step) => acc + (step?.distance || 0),
0,
);
const duration = preparedRoute.reduce(
(acc, step) => acc + (step?.duration || 0),
0,
);
const instructions = useMemo(() => {
return routeToInstructions(preparedRoute);
}, [preparedRoute]);
const { superCluster, shape } = useFeatures({
clusterFeature,
alertingList,
userCoords,
routeCoords: remainingRouteWithSnapped,
route,
alertCoords,
});
const onRegionDidChange = useOnRegionDidChange({
mapRef,
superCluster,
setClusterFeature,
userCoords,
setDetached,
});
const [selectedFeature, setSelectedFeature] = useState(null);
const closeSelected = useCallback(() => {
setSelectedFeature(null);
}, [setSelectedFeature]);
const onPress = useOnPress({
superCluster,
cameraRef,
setSelectedFeature,
});
const [stepperIsOpened, setStepperIsOpened] = useState(false);
const openStepper = useCallback(() => {
setStepperIsOpened(true);
}, [setStepperIsOpened]);
const closeStepper = useCallback(() => {
setStepperIsOpened(false);
}, [setStepperIsOpened]);
const stepperOnOpen = useCallback(() => {
if (!stepperIsOpened) {
setStepperIsOpened(true);
}
}, [stepperIsOpened, setStepperIsOpened]);
const stepperOnClose = useCallback(() => {
if (stepperIsOpened) {
setStepperIsOpened(false);
}
}, [stepperIsOpened, setStepperIsOpened]);
const [externalGeoIsVisible, setExternalGeoIsVisible] = useState(false);
const destinationName = getDestinationName(driving.route);
return (
<View style={styles.container}>
<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}
/>
}
>
<View
style={{
flex: 1,
alignItems: "stretch",
}}
>
<MapView
mapRef={mapRef}
onRegionDidChange={onRegionDidChange}
contentInset={contentInset}
compassViewPosition={compassViewPosition}
compassViewMargin={compassViewMargin}
// onUserLocationUpdate={onUserLocationUpdate} // didn't work
>
<Camera
cameraKey={cameraKey}
setCameraKey={setCameraKey}
refreshCamera={refreshCamera}
cameraRef={cameraRef}
followUserLocation={followUserLocation}
followUserMode={followUserMode}
followPitch={followPitch}
zoomLevel={zoomLevel}
bounds={bounds}
detached={detached}
compassViewPosition={compassViewPosition}
/>
<FeatureImages />
<ShapePoints shape={shape} onPress={onPress}>
<Maplibre.LineLayer
id="lineLayer"
key="lineLayer"
belowLayerID="points-green"
style={layerStyles.route}
/>
</ShapePoints>
{selectedFeature && (
<SelectedFeatureBubble
feature={selectedFeature}
close={closeSelected}
/>
)}
{isUsingLastKnown && userCoords.latitude && userCoords.longitude ? (
<LastKnownLocationMarker
coordinates={userCoords}
timestamp={lastKnownTimestamp}
id="lastKnownLocation_cur"
/>
) : (
<Maplibre.UserLocation
visible
showsUserHeadingIndicator
onUpdate={onUserLocationUpdate}
/>
)}
</MapView>
<MapHeadRouting
instructions={instructions}
distance={distance}
openStepper={openStepper}
calculatingState={calculating}
/>
</View>
<ControlButtons
mapRef={mapRef}
cameraRef={cameraRef}
refreshCamera={refreshCamera}
boundType={boundType}
setBoundType={setBoundType}
userCoords={userCoords}
setZoomLevel={setZoomLevel}
detached={detached}
setExternalGeoIsVisible={setExternalGeoIsVisible}
/>
</Drawer>
<MapLinksPopup
isVisible={externalGeoIsVisible}
setIsVisible={setExternalGeoIsVisible}
options={{
longitude: alertCoords[0],
latitude: alertCoords[1],
}}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
error: {
marginHorizontal: 10,
},
errorText: {
marginVertical: 10,
fontSize: 16,
},
errorButton: {
marginVertical: 10,
borderRadius: 8,
},
errorButtonText: {
fontSize: 16,
},
errorButtonIcon: {},
});
const layerStyles = {
origin: {
circleRadius: 5,
circleColor: "white",
},
destination: {
circleRadius: 5,
circleColor: "white",
},
route: {
lineColor: "rgba(49, 76, 205, 0.84)",
lineCap: Maplibre.LineJoin.Round,
lineWidth: 3,
lineOpacity: 0.84,
},
progress: {
lineColor: "#314ccd",
lineWidth: 3,
},
};
// AlertCurMap.whyDidYouRender = true;
export default withConnectivity(AlertCurMap, {
keepVisible: true,
});