Waseel: driver app, dispatch, POI suggestions, map fixes
This commit is contained in:
+125
-23
@@ -1,7 +1,10 @@
|
||||
import type { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
|
||||
// Google Places (New) Nearby Search — powers the "nearby mall / hospital /
|
||||
// pharmacy / restaurant" destination chips on the home screen. Reuses the same
|
||||
// API key and header pattern as the autocomplete in components/google-text-input.
|
||||
|
||||
import { tr } from "@/lib/i18n";
|
||||
import { haversine } from "@/lib/utils";
|
||||
import type { NearbyPlace } from "@/types/type";
|
||||
|
||||
@@ -11,25 +14,83 @@ const googleApiKey = process.env.EXPO_PUBLIC_GOOGLE_API_KEY!;
|
||||
// Google Places (New) `includedTypes` value.
|
||||
export type PoiCategory = {
|
||||
id: "mall" | "hospital" | "pharmacy" | "restaurant";
|
||||
label: string;
|
||||
/** MaterialCommunityIcons glyph name. */
|
||||
icon: string;
|
||||
/** i18n key for the chip label. */
|
||||
labelKey: string;
|
||||
/** MaterialCommunityIcons glyph name. Typed so a bad name fails the build
|
||||
* rather than warning at runtime and rendering nothing. */
|
||||
icon: React.ComponentProps<typeof MaterialCommunityIcons>["name"];
|
||||
googleType: string;
|
||||
};
|
||||
|
||||
export const POI_CATEGORIES: PoiCategory[] = [
|
||||
{ id: "mall", label: "Mall", icon: "shopping-mall", googleType: "shopping_mall" },
|
||||
{ id: "hospital", label: "Hospital", icon: "hospital", googleType: "hospital" },
|
||||
{ id: "pharmacy", label: "Pharmacy", icon: "pill", googleType: "pharmacy" },
|
||||
{ id: "restaurant", label: "Restaurant", icon: "silverware-fork-knife", googleType: "restaurant" },
|
||||
{
|
||||
id: "mall",
|
||||
labelKey: "pois.mall",
|
||||
icon: "storefront",
|
||||
googleType: "shopping_mall",
|
||||
},
|
||||
{
|
||||
id: "hospital",
|
||||
labelKey: "pois.hospital",
|
||||
icon: "hospital",
|
||||
googleType: "hospital",
|
||||
},
|
||||
{
|
||||
id: "pharmacy",
|
||||
labelKey: "pois.pharmacy",
|
||||
icon: "pill",
|
||||
googleType: "pharmacy",
|
||||
},
|
||||
{
|
||||
id: "restaurant",
|
||||
labelKey: "pois.restaurant",
|
||||
icon: "silverware-fork-knife",
|
||||
googleType: "restaurant",
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_RADIUS_M = 4000;
|
||||
|
||||
// Nearby Search ranks by popularity unless told otherwise, which put a mall a
|
||||
// 29-minute drive away ahead of one 6 minutes away. Rank by distance instead,
|
||||
// then re-rank the closest few by their actual driving route: straight-line
|
||||
// distance is a poor proxy in Lebanon's mountain terrain, where a place 2.9 km
|
||||
// away as the crow flies can be a 17.9 km drive around a valley. Each candidate
|
||||
// costs one Directions call, so keep the set small.
|
||||
const ROUTE_CANDIDATES = 3;
|
||||
|
||||
type DrivingRoute = { distanceMeters: number; durationSeconds: number };
|
||||
|
||||
// Road distance/time between two points. Returns null when Google finds no
|
||||
// route (ZERO_RESULTS) or the request fails, so callers can fall back.
|
||||
const fetchDrivingRoute = async (
|
||||
from: { latitude: number; longitude: number },
|
||||
to: { latitude: number; longitude: number },
|
||||
): Promise<DrivingRoute | null> => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://maps.googleapis.com/maps/api/directions/json?origin=${from.latitude},${from.longitude}&destination=${to.latitude},${to.longitude}&mode=driving&key=${googleApiKey}`,
|
||||
);
|
||||
const data = await res.json();
|
||||
const leg = data.routes?.[0]?.legs?.[0];
|
||||
|
||||
if (!leg) return null;
|
||||
|
||||
return {
|
||||
distanceMeters: leg.distance.value,
|
||||
durationSeconds: leg.duration.value,
|
||||
};
|
||||
} catch (error) {
|
||||
console.log("[PLACES_ROUTE]: ", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Searches for the nearest place of `googleType` around (latitude, longitude)
|
||||
// and returns it as a NearbyPlace with its distance from the rider. Returns
|
||||
// null when no place of that type is found nearby — the chip then shows an
|
||||
// empty state rather than a broken one.
|
||||
// and returns it as a NearbyPlace carrying its real driving distance and time.
|
||||
// "Nearest" means nearest by road, not by straight line. Returns null when no
|
||||
// place of that type is found nearby — the chip then shows an empty state
|
||||
// rather than a broken one.
|
||||
export const searchNearby = async (
|
||||
googleType: string,
|
||||
{
|
||||
@@ -53,6 +114,8 @@ export const searchNearby = async (
|
||||
includedTypes: [googleType],
|
||||
languageCode: "en",
|
||||
regionCode: "lb",
|
||||
rankPreference: "DISTANCE",
|
||||
maxResultCount: ROUTE_CANDIDATES,
|
||||
locationRestriction: {
|
||||
circle: {
|
||||
center: { latitude, longitude },
|
||||
@@ -63,24 +126,63 @@ export const searchNearby = async (
|
||||
},
|
||||
);
|
||||
const data = await res.json();
|
||||
const place = data.places?.[0];
|
||||
if (!place) return null;
|
||||
|
||||
const lat = place.location?.latitude as number;
|
||||
const lng = place.location?.longitude as number;
|
||||
const candidates = ((data.places ?? []) as Record<string, any>[])
|
||||
.map((place) => {
|
||||
const lat = place.location?.latitude as number;
|
||||
const lng = place.location?.longitude as number;
|
||||
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
|
||||
|
||||
return {
|
||||
name: (place.displayName?.text as string) ?? tr("pois.nearbyPlace"),
|
||||
address: (place.formattedAddress as string) ?? "",
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
distanceMeters: haversine(latitude, longitude, lat, lng),
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(candidate): candidate is NonNullable<typeof candidate> =>
|
||||
candidate !== null,
|
||||
);
|
||||
|
||||
if (!candidates.length) return null;
|
||||
|
||||
const routes = await Promise.all(
|
||||
candidates.map((candidate) =>
|
||||
fetchDrivingRoute({ latitude, longitude }, candidate),
|
||||
),
|
||||
);
|
||||
|
||||
const routed = candidates
|
||||
.map((candidate, index) => ({ candidate, route: routes[index] }))
|
||||
.filter(
|
||||
(
|
||||
entry,
|
||||
): entry is {
|
||||
candidate: (typeof candidates)[number];
|
||||
route: DrivingRoute;
|
||||
} => entry.route !== null,
|
||||
);
|
||||
|
||||
// Every candidate unreachable, or Directions failed for all of them: show
|
||||
// the straight-line nearest rather than dropping the chip entirely.
|
||||
if (!routed.length) return candidates[0];
|
||||
|
||||
const best = routed.reduce((shortest, entry) =>
|
||||
entry.route.distanceMeters < shortest.route.distanceMeters
|
||||
? entry
|
||||
: shortest,
|
||||
);
|
||||
|
||||
return {
|
||||
name: (place.displayName?.text as string) ?? "Nearby place",
|
||||
address: (place.formattedAddress as string) ?? "",
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
distanceMeters:
|
||||
Number.isFinite(lat) && Number.isFinite(lng)
|
||||
? haversine(latitude, longitude, lat, lng)
|
||||
: undefined,
|
||||
...best.candidate,
|
||||
routeDistanceMeters: best.route.distanceMeters,
|
||||
routeDurationSeconds: best.route.durationSeconds,
|
||||
};
|
||||
} catch (error) {
|
||||
console.log("[PLACES_NEARBY]: ", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user