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 ( ({ main: { opacity: (2 - ratio) / 2 }, })} tweenDuration={250} openDrawerOffset={40} open={stepperIsOpened} onOpen={stepperOnOpen} onClose={stepperOnClose} tapToClose negotiatePan content={ } > {selectedFeature && ( )} {isUsingLastKnown && userCoords.latitude && userCoords.longitude ? ( ) : ( )} ); } 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, });