Files
waseel/lib/places.ts

189 lines
5.9 KiB
TypeScript

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";
const googleApiKey = process.env.EXPO_PUBLIC_GOOGLE_API_KEY!;
// The four POI categories surfaced as quick destination chips. Each maps to a
// Google Places (New) `includedTypes` value.
export type PoiCategory = {
id: "mall" | "hospital" | "pharmacy" | "restaurant";
/** 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",
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 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,
{
latitude,
longitude,
radiusM = DEFAULT_RADIUS_M,
}: { latitude: number; longitude: number; radiusM?: number },
): Promise<NearbyPlace | null> => {
try {
const res = await fetch(
"https://places.googleapis.com/v1/places:searchNearby",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Goog-Api-Key": googleApiKey,
"X-Goog-FieldMask":
"places.displayName,places.formattedAddress,places.location,places.id",
},
body: JSON.stringify({
includedTypes: [googleType],
languageCode: "en",
regionCode: "lb",
rankPreference: "DISTANCE",
maxResultCount: ROUTE_CANDIDATES,
locationRestriction: {
circle: {
center: { latitude, longitude },
radius: radiusM,
},
},
}),
},
);
const data = await res.json();
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 {
...best.candidate,
routeDistanceMeters: best.route.distanceMeters,
routeDurationSeconds: best.route.durationSeconds,
};
} catch (error) {
console.log("[PLACES_NEARBY]: ", error);
return null;
}
};