189 lines
5.9 KiB
TypeScript
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;
|
|
}
|
|
};
|