Files
waseel/components/map.tsx
T

195 lines
6.0 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { Platform, StyleSheet } from "react-native";
import MapView, { Marker, PROVIDER_DEFAULT } from "react-native-maps";
import MapViewDirections from "react-native-maps-directions";
import { icons } from "@/constants";
import { useFetch } from "@/lib/fetch";
import { tr } from "@/lib/i18n";
import {
calculateDriverTimes,
calculateRegion,
generateMarkersFromData,
} from "@/lib/map";
import { useTheme } from "@/lib/theme";
import { useDriverStore, useLocationStore, useServiceStore } from "@/store";
import type { Driver, MarkerData } from "@/types/type";
// react-native-maps sizes itself from a real style object, so give it explicit
// dimensions rather than relying on percentage classNames resolving to 0.
const styles = StyleSheet.create({
map: { ...StyleSheet.absoluteFillObject, borderRadius: 16 },
});
// "mutedStandard" is an Apple Maps type. Android's MapManager looks the value
// up in a fixed table and unboxes the result into an int, so an unrecognised
// name is a null Integer -> NullPointerException, and the map never draws.
const MAP_TYPE = Platform.OS === "ios" ? "mutedStandard" : "standard";
// showsPointsOfInterest is iOS-only; on Android the same muting is done with a
// style array, so both platforms get the same clean base map.
const MUTED_POI_STYLE = [
{
featureType: "poi",
elementType: "labels",
stylers: [{ visibility: "off" }],
},
{
featureType: "transit",
elementType: "labels.icon",
stylers: [{ visibility: "off" }],
},
];
export const Map = () => {
const {
userLatitude,
userLongitude,
destinationLatitude,
destinationLongitude,
} = useLocationStore();
const { service } = useServiceStore();
const { selectedDriver, setDrivers } = useDriverStore();
const { isDark } = useTheme();
// Online drivers of the selected service near the rider. Falls back to a
// Beirut center when the rider's position isn't resolved yet so the map
// still populates instead of sitting empty.
const lat = userLatitude ?? 33.8938;
const lng = userLongitude ?? 35.5018;
const { data: drivers, error } = useFetch<Driver[]>(
`/(api)/driver/nearby?service=${service}&lat=${lat}&lng=${lng}`,
);
const [markers, setMarkers] = useState<MarkerData[]>([]);
const mapRef = useRef<MapView>(null);
const region = calculateRegion({
userLatitude,
userLongitude,
destinationLatitude,
destinationLongitude,
});
// `initialRegion` is read once, at mount. The map mounts before the location
// fix arrives, so it would sit on the Beirut fallback forever and never zoom
// out to fit a destination the rider picks later. Animate on every real
// change instead. Keyed on the coordinates so the repeated setUserLocation
// from reverse geocoding (same coords, new address) doesn't yank the camera
// back while the rider is panning.
const regionKey = `${region.latitude},${region.longitude},${region.latitudeDelta},${region.longitudeDelta}`;
const lastRegionKey = useRef(regionKey);
useEffect(() => {
if (lastRegionKey.current === regionKey) return;
lastRegionKey.current = regionKey;
mapRef.current?.animateToRegion(region, 500);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [regionKey]);
useEffect(() => {
if (Array.isArray(drivers)) {
if (!userLatitude || !userLongitude) return;
const newMarkers = generateMarkersFromData({
data: drivers,
userLatitude,
userLongitude,
});
setMarkers(newMarkers);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [drivers, userLatitude, userLongitude]);
useEffect(() => {
if (markers.length > 0 && destinationLatitude && destinationLongitude) {
calculateDriverTimes({
markers,
userLatitude,
userLongitude,
destinationLatitude,
destinationLongitude,
service,
}).then((driversWithTimes) => {
setDrivers((driversWithTimes as MarkerData[]) ?? []);
});
}
}, [
markers,
destinationLatitude,
destinationLongitude,
userLatitude,
userLongitude,
setDrivers,
service,
]);
// The map itself never waits on the driver list or the location fix: drivers
// are an overlay, and calculateRegion falls back to Beirut without coords.
// Previously either one failing replaced the whole map with a spinner or an
// error line, which read as "the map didn't load".
if (error) console.log("[MAP_DRIVERS]: ", error);
return (
<MapView
ref={mapRef}
provider={PROVIDER_DEFAULT}
style={styles.map}
tintColor={isDark ? "white" : "black"}
mapType={MAP_TYPE}
customMapStyle={MUTED_POI_STYLE}
showsPointsOfInterest={false}
initialRegion={region}
showsUserLocation
userInterfaceStyle={isDark ? "dark" : "light"}
>
{markers.map((marker) => (
<Marker
key={marker.id}
coordinate={{
latitude: marker.latitude,
longitude: marker.longitude,
}}
title={marker.title}
image={
selectedDriver === marker.id ? icons.selectedMarker : icons.marker
}
/>
))}
{userLatitude &&
userLongitude &&
destinationLatitude &&
destinationLongitude && (
<>
<Marker
key="destination"
coordinate={{
latitude: destinationLatitude,
longitude: destinationLongitude,
}}
title={tr("components.map.destination")}
image={icons.pin}
/>
<MapViewDirections
origin={{
latitude: userLatitude,
longitude: userLongitude,
}}
destination={{
latitude: destinationLatitude,
longitude: destinationLongitude,
}}
apikey={process.env.EXPO_PUBLIC_GOOGLE_API_KEY!}
strokeColor="#0286FF"
strokeWidth={3}
/>
</>
)}
</MapView>
);
};