diff --git a/app.config.js b/app.config.js
index 72c73e9..63a41ea 100644
--- a/app.config.js
+++ b/app.config.js
@@ -4,7 +4,7 @@ module.exports = ({ config }) => ({
...(config.expo || {}),
name: "OpenBirding",
slug: "OpenBirding",
- version: "1.8.0",
+ version: "2.0.0",
orientation: "portrait",
icon: "./assets/images/logo.png",
scheme: "openbirding",
@@ -58,6 +58,7 @@ module.exports = ({ config }) => ({
"expo-web-browser",
"expo-font",
"expo-image",
+ "expo-localization",
],
experiments: { typedRoutes: true, reactCompiler: true },
extra: {
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 0747e93..ecadf68 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -104,6 +104,15 @@ export default function RootLayout() {
headerShadowVisible: false,
}}
/>
+
(null);
const isMapTouchActiveRef = useRef(false);
const insets = useSafeAreaInsets();
+ const activeFilterCount = useActiveFilterCount();
const { isLoadingLocation, savedLocation, updateLocation, hadSavedLocationOnInit } = useSavedLocation();
const {
@@ -39,7 +46,7 @@ export default function HomeScreen() {
setIsMapAttributionOpen,
} = useMapStore();
const { data: installedPacks, isLoading: isLoadingInstalledPacks } = useInstalledPacks();
- const { hasUpdates } = usePackUpdates();
+ const { updateCount } = usePackUpdates();
const handleMapPress = (_event: any) => {
if (isMenuOpen) handleCloseBottomSheet();
@@ -84,10 +91,26 @@ export default function HomeScreen() {
setIsHotspotListOpen(true);
};
+ const handleOpenFilters = () => {
+ setIsFilterSheetOpen(true);
+ };
+
+ const handleCloseFilters = () => {
+ setIsFilterSheetOpen(false);
+ };
+
const handleCloseHotspotList = () => {
setIsHotspotListOpen(false);
};
+ const handleOpenSearch = () => {
+ setIsSearchOpen(true);
+ };
+
+ const handleCloseSearch = () => {
+ setIsSearchOpen(false);
+ };
+
const handleMapTouchActiveChange = useCallback((isActive: boolean) => {
isMapTouchActiveRef.current = isActive;
}, []);
@@ -121,6 +144,18 @@ export default function HomeScreen() {
[setCustomPinCoordinates, setPlaceId, setHotspotId]
);
+ const handleSelectPlaceFromList = useCallback(
+ (selectedPlaceId: string, lat: number, lng: number) => {
+ setCustomPinCoordinates(null);
+ setHotspotId(null);
+ setPlaceId(selectedPlaceId);
+ setTimeout(() => {
+ mapRef.current?.centerOnCoordinates(lng, lat, 200);
+ }, 500);
+ },
+ [setCustomPinCoordinates, setHotspotId, setPlaceId]
+ );
+
if (isLoadingLocation) return null;
const initialCenter = savedLocation?.center ?? [-98.5, 39.5];
@@ -171,17 +206,34 @@ export default function HomeScreen() {
-
-
+
+
- {hasUpdates && }
+
+
+
+
+
+
diff --git a/app/settings-units.tsx b/app/settings-units.tsx
new file mode 100644
index 0000000..c5e58ee
--- /dev/null
+++ b/app/settings-units.tsx
@@ -0,0 +1,90 @@
+import tw from "@/lib/tw";
+import { DistanceUnits, useSettingsStore } from "@/stores/settingsStore";
+import { Ionicons } from "@expo/vector-icons";
+import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
+import { useRouter } from "expo-router";
+import React from "react";
+import { Platform, ScrollView, Text, TouchableOpacity, View, ViewStyle } from "react-native";
+
+type OptionRowProps = {
+ label: string;
+ selected: boolean;
+ onPress: () => void;
+ isLast?: boolean;
+};
+
+function OptionRow({ label, selected, onPress, isLast }: OptionRowProps) {
+ const borderStyle = isLast ? {} : tw`border-b border-gray-200/50`;
+
+ return (
+
+ {label}
+ {selected && }
+
+ );
+}
+
+type OptionsGroupProps = {
+ children: React.ReactNode;
+ footer?: string;
+};
+
+function OptionsGroup({ children, footer }: OptionsGroupProps) {
+ const useGlass = Platform.OS === "ios" && isLiquidGlassAvailable();
+
+ const cardStyle: ViewStyle = {
+ borderRadius: 12,
+ overflow: "hidden",
+ };
+
+ const content = useGlass ? (
+
+ {children}
+
+ ) : (
+ {children}
+ );
+
+ return (
+
+ {content}
+ {footer && {footer}}
+
+ );
+}
+
+const OPTIONS: { value: DistanceUnits; label: string }[] = [
+ { value: "metric", label: "Kilometers" },
+ { value: "imperial", label: "Miles" },
+];
+
+export default function UnitsPage() {
+ const router = useRouter();
+ const distanceUnits = useSettingsStore((state) => state.distanceUnits);
+ const setDistanceUnits = useSettingsStore((state) => state.setDistanceUnits);
+
+ const handleSelect = (units: DistanceUnits) => {
+ setDistanceUnits(units);
+ router.back();
+ };
+
+ return (
+
+
+ {OPTIONS.map((option, index) => (
+ handleSelect(option.value)}
+ isLast={index === OPTIONS.length - 1}
+ />
+ ))}
+
+
+ );
+}
diff --git a/app/settings.tsx b/app/settings.tsx
index 0ce4496..f59d0c7 100644
--- a/app/settings.tsx
+++ b/app/settings.tsx
@@ -112,6 +112,7 @@ export default function SettingsPage() {
const lifelistExclusions = useSettingsStore((state) => state.lifelistExclusions);
const disableSunTimes = useSettingsStore((state) => state.disableSunTimes);
const setDisableSunTimes = useSettingsStore((state) => state.setDisableSunTimes);
+ const distanceUnits = useSettingsStore((state) => state.distanceUnits);
const providers = getExternalMapProviders();
const getProviderName = (providerId: string | null) => {
@@ -157,6 +158,12 @@ export default function SettingsPage() {
+ router.push("/settings-units" as Href)}
+ icon={{ family: "fontawesome5", name: "ruler", bgColor: "#5856D6" }}
+ />
router.push("/settings-life-list-exclusions" as Href)}
- icon={{ name: "eye-off", bgColor: "#FF9500" }}
+ icon={{ name: "eye-off", bgColor: "#FF3B30" }}
/>
router.push("/settings-import-life-list" as Href)}
- icon={{ name: "cloud-upload", bgColor: "#5856D6" }}
+ icon={{ name: "cloud-upload", bgColor: "#30B0C7" }}
isLast
/>
diff --git a/components/ActionButtonRow.tsx b/components/ActionButtonRow.tsx
index 50e2f9c..da81ee7 100644
--- a/components/ActionButtonRow.tsx
+++ b/components/ActionButtonRow.tsx
@@ -17,7 +17,7 @@ export default function ActionButtonRow({ children, stacked = false }: ActionBut
{child}
));
- const containerStyle = [tw`w-full mt-2`, stacked ? tw`flex-col` : tw`flex-row`, { gap: GAP }] as const;
+ const containerStyle = [tw`w-full mt-2`, stacked ? tw`flex-col` : tw`flex-row`, { gap: GAP }];
return useGlass ? (
diff --git a/components/CountBadge.tsx b/components/CountBadge.tsx
new file mode 100644
index 0000000..a0d8ba0
--- /dev/null
+++ b/components/CountBadge.tsx
@@ -0,0 +1,15 @@
+import tw from "@/lib/tw";
+import React from "react";
+import { Text, View } from "react-native";
+
+export default function CountBadge({ count }: { count: number }) {
+ if (count <= 0) return null;
+
+ return (
+
+ {count}
+
+ );
+}
diff --git a/components/FilterSection.tsx b/components/FilterSection.tsx
deleted file mode 100644
index e0af35b..0000000
--- a/components/FilterSection.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import tw from "@/lib/tw";
-import { useFiltersStore } from "@/stores/filtersStore";
-import React from "react";
-import { Platform, Switch, Text, View } from "react-native";
-import { BorderlessButton } from "react-native-gesture-handler";
-
-export default function FilterSection() {
- const { showSavedOnly, setShowSavedOnly } = useFiltersStore();
-
- const content = (
-
- Show saved only
-
-
- );
-
- if (Platform.OS === "android") {
- return (
- setShowSavedOnly(!showSavedOnly)} style={tw`pl-6 pr-5 py-4`} activeOpacity={1}>
- {content}
-
- );
- }
-
- return {content};
-}
diff --git a/components/FilterSheet.tsx b/components/FilterSheet.tsx
new file mode 100644
index 0000000..acbc9b1
--- /dev/null
+++ b/components/FilterSheet.tsx
@@ -0,0 +1,31 @@
+import tw from "@/lib/tw";
+import { useFiltersStore } from "@/stores/filtersStore";
+import { useSettingsStore } from "@/stores/settingsStore";
+import React from "react";
+import { Switch, Text, View } from "react-native";
+import BaseBottomSheet from "./BaseBottomSheet";
+import TargetRichHotspotControls from "./TargetRichHotspotControls";
+
+type FilterSheetProps = {
+ isOpen: boolean;
+ onClose: () => void;
+};
+
+export default function FilterSheet({ isOpen, onClose }: FilterSheetProps) {
+ const showSavedOnly = useFiltersStore((state) => state.showSavedOnly);
+ const setShowSavedOnly = useFiltersStore((state) => state.setShowSavedOnly);
+ const lifelist = useSettingsStore((state) => state.lifelist);
+ const hasLifeList = (lifelist?.length ?? 0) > 0;
+
+ return (
+
+
+
+ Show saved only
+
+
+
+
+
+ );
+}
diff --git a/components/HotspotDialog.tsx b/components/HotspotDialog.tsx
index 13be887..d71e55a 100644
--- a/components/HotspotDialog.tsx
+++ b/components/HotspotDialog.tsx
@@ -96,7 +96,7 @@ function HotspotDialogContent({ isOpen, hotspotId, onClose }: HotspotDialogProps
const handleViewDetails = () => {
if (!hotspot) return;
- const url = `https://ebird.org/hotspot/${hotspot.id}`;
+ const url = `https://ebird.org/hotspot/${hotspot.id}/about`;
Linking.openURL(url).catch(() => {
Alert.alert("Error", "Could not open eBird hotspot page");
});
diff --git a/components/HotspotItem.tsx b/components/HotspotItem.tsx
index b290cd2..7b0bee7 100644
--- a/components/HotspotItem.tsx
+++ b/components/HotspotItem.tsx
@@ -1,36 +1,21 @@
import tw from "@/lib/tw";
import { Hotspot } from "@/lib/types";
-import { getMarkerColor } from "@/lib/utils";
+import { getSavedHotspotIconImage } from "@/lib/hotspotIconImages";
+import { formatDistance, getMarkerColor } from "@/lib/utils";
+import { useSettingsStore } from "@/stores/settingsStore";
import { Ionicons } from "@expo/vector-icons";
import React, { useCallback } from "react";
-import { Pressable, Text, View } from "react-native";
-
-const MILES_COUNTRIES = ["US", "GB", "MM", "LR", "PR", "VI", "GU", "MP", "AS", "KY", "TC", "VG", "AI", "MS", "FK"];
-
-const formatDistance = (distanceKm: number, country: string | null) => {
- const useMiles = country && MILES_COUNTRIES.includes(country);
- if (useMiles) {
- const distanceMiles = distanceKm * 0.621371;
- const rounded = Math.round(distanceMiles);
- if (rounded >= 10) {
- return `${rounded} mi`;
- }
- return `${distanceMiles.toFixed(1)} mi`;
- }
- const rounded = Math.round(distanceKm);
- if (rounded >= 10) {
- return `${rounded} km`;
- }
- return `${distanceKm.toFixed(1)} km`;
-};
+import { Image, Pressable, Text, View } from "react-native";
type HotspotItemProps = {
item: Hotspot & { distance?: number };
onSelect: (hotspot: Hotspot & { distance?: number }) => void;
+ isSaved?: boolean;
};
const HotspotItem = React.memo(
- ({ item, onSelect }: HotspotItemProps) => {
+ ({ item, onSelect, isSaved = false }: HotspotItemProps) => {
+ const useMiles = useSettingsStore((state) => state.distanceUnits === "imperial");
const handlePress = useCallback(() => {
onSelect(item);
}, [item, onSelect]);
@@ -47,12 +32,20 @@ const HotspotItem = React.memo(
{item.name}
-
+ {isSaved ? (
+
+ ) : (
+
+ )}
{item.species} species
{item.distance !== undefined && (
- {formatDistance(item.distance, item.country)}
+ {formatDistance(item.distance, useMiles)}
)}
@@ -67,6 +60,7 @@ const HotspotItem = React.memo(
prevProps.item.species === nextProps.item.species &&
prevProps.item.distance === nextProps.item.distance &&
prevProps.item.country === nextProps.item.country &&
+ prevProps.isSaved === nextProps.isSaved &&
prevProps.onSelect === nextProps.onSelect
);
}
diff --git a/components/HotspotList.tsx b/components/HotspotList.tsx
index bc8ad2f..2dc73ab 100644
--- a/components/HotspotList.tsx
+++ b/components/HotspotList.tsx
@@ -1,112 +1,170 @@
+import { useActiveFilterCount } from "@/hooks/useActiveFilterCount";
import { useLocation } from "@/hooks/useLocation";
-import { useScrollRestore } from "@/hooks/useScrollRestore";
-import { getAllHotspots, getNearbyHotspots, searchHotspots } from "@/lib/database";
+import { useTargetRichHotspots } from "@/hooks/useTargetRichHotspots";
+import { getHotspotsWithinBounds, getSavedHotspots, getSavedPlaces } from "@/lib/database";
import tw from "@/lib/tw";
-import { Hotspot } from "@/lib/types";
-import { calculateDistance, getBoundingBoxFromLocation } from "@/lib/utils";
+import { Bounds, Hotspot, SavedPlace } from "@/lib/types";
+import { calculateDistance, isWithinBounds, padBoundsBySize } from "@/lib/utils";
import { useFiltersStore } from "@/stores/filtersStore";
import { useLocationPermissionStore } from "@/stores/locationPermissionStore";
+import { useMapStore } from "@/stores/mapStore";
import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
-import debounce from "lodash/debounce";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { ActivityIndicator, Switch, Text, View } from "react-native";
+import { ActivityIndicator, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
+import FilterSlidersIcon from "./icons/FilterSlidersIcon";
import BaseBottomSheet from "./BaseBottomSheet";
+import FilterSheet from "./FilterSheet";
import HotspotItem from "./HotspotItem";
import IconButton from "./IconButton";
import IconButtonGroup from "./IconButtonGroup";
-import SearchInput from "./SearchInput";
+import PlaceItem from "./PlaceItem";
type HotspotListProps = {
isOpen: boolean;
onClose: () => void;
onSelectHotspot: (hotspotId: string, lat: number, lng: number) => void;
+ onSelectPlace: (placeId: string, lat: number, lng: number) => void;
};
-const NEARBY_LIMIT = 200;
-const SEARCH_LIMIT = 100;
-const ALL_HOTSPOTS_LIMIT = 1000;
-const NEARBY_BUCKETS_KM = [50, 100, 200, 500];
+type HotspotRow = Hotspot & { kind: "hotspot"; distance?: number };
+type PlaceRow = SavedPlace & { kind: "place"; distance?: number };
+type ListRow = HotspotRow | PlaceRow;
-export default function HotspotList({ isOpen, onClose, onSelectHotspot }: HotspotListProps) {
+// Captured when the list opens so it reflects the map's viewport at that moment,
+// rather than shifting if the map keeps reporting bounds during the animation.
+type ListSnapshot = {
+ bounds: Bounds | null;
+ center: { lat: number; lng: number } | null;
+ zoomedTooFarOut: boolean;
+};
+
+export default function HotspotList({ isOpen, onClose, onSelectHotspot, onSelectPlace }: HotspotListProps) {
const insets = useSafeAreaInsets();
- const { status: permissionStatus, isLoading: isLoadingPermission } = useLocationPermissionStore();
+ const { status: permissionStatus } = useLocationPermissionStore();
const { location, isLoading: isLoadingUserLocation } = useLocation(isOpen);
- const isLoadingLocation = isLoadingPermission || isLoadingUserLocation;
- const { showSavedOnly, setShowSavedOnly } = useFiltersStore();
- const activeFilterCount = [showSavedOnly].filter(Boolean).length;
+ const showSavedOnly = useFiltersStore((state) => state.showSavedOnly);
+ const activeFilterCount = useActiveFilterCount();
const dismissRef = useRef<(() => Promise) | null>(null);
- const [searchQuery, setSearchQuery] = useState("");
- const [isFilterPanelOpen, setIsFilterPanelOpen] = useState(false);
- const [debouncedQuery, setDebouncedQuery] = useState("");
+ const storeBounds = useMapStore((state) => state.bounds);
+ const storeMapCenter = useMapStore((state) => state.mapCenter);
+ const storeZoomedTooFarOut = useMapStore((state) => state.isZoomedTooFarOut);
- const debouncedSetQuery = useMemo(() => debounce(setDebouncedQuery, 150), []);
+ const [snapshot, setSnapshot] = useState(null);
+ const [isFilterSheetOpen, setIsFilterSheetOpen] = useState(false);
+ const wasOpenRef = useRef(false);
+ // Snapshot the current viewport on the closed -> open transition only.
useEffect(() => {
- debouncedSetQuery(searchQuery);
- return () => debouncedSetQuery.cancel();
- }, [searchQuery, debouncedSetQuery]);
+ if (isOpen && !wasOpenRef.current) {
+ setSnapshot({ bounds: storeBounds, center: storeMapCenter, zoomedTooFarOut: storeZoomedTooFarOut });
+ }
+ wasOpenRef.current = isOpen;
+ }, [isOpen, storeBounds, storeMapCenter, storeZoomedTooFarOut]);
useEffect(() => {
- if (!isOpen) {
- setIsFilterPanelOpen(false);
- }
+ if (!isOpen) setIsFilterSheetOpen(false);
}, [isOpen]);
- const hasLocationAccess = permissionStatus === "granted" && location !== null;
-
- const { data: searchResults = [], dataUpdatedAt: searchUpdatedAt } = useQuery({
- queryKey: ["hotspotSearch", debouncedQuery, showSavedOnly],
- queryFn: () => searchHotspots(debouncedQuery, SEARCH_LIMIT, showSavedOnly),
- enabled: isOpen && debouncedQuery.length >= 2 && !isLoadingLocation,
- staleTime: 60 * 1000,
- placeholderData: (prev) => prev,
- });
+ const snapshotBounds = snapshot?.bounds ?? null;
+ const isZoomedOut = snapshot?.zoomedTooFarOut ?? false;
- const { data: allHotspots = [] } = useQuery({
- queryKey:
- hasLocationAccess && location
- ? ["nearbyHotspots", location.lat, location.lng, showSavedOnly]
- : ["allHotspots", showSavedOnly],
+ const { data: hotspots = [], isFetching: isFetchingHotspots } = useQuery({
+ // Same key + padding as Mapbox so we hit the same cached viewport query.
+ queryKey: ["hotspots", snapshotBounds],
queryFn: async () => {
- if (hasLocationAccess && location) {
- // Try the smallest buckets first to get a result quickly
- let hotspots: Hotspot[] = [];
- for (const radiusKm of NEARBY_BUCKETS_KM) {
- const bbox = getBoundingBoxFromLocation(location.lat, location.lng, radiusKm);
- hotspots = await getNearbyHotspots(bbox, showSavedOnly);
- if (hotspots.length >= NEARBY_LIMIT) {
- return hotspots;
- }
- }
- return hotspots;
- }
- return getAllHotspots(ALL_HOTSPOTS_LIMIT, showSavedOnly);
+ if (!snapshotBounds) return [];
+ const padded = padBoundsBySize(snapshotBounds);
+ return getHotspotsWithinBounds(padded.west, padded.south, padded.east, padded.north);
},
- enabled: isOpen && debouncedQuery.length < 2 && !isLoadingLocation,
+ enabled: isOpen && snapshotBounds !== null,
staleTime: 5 * 60 * 1000,
+ gcTime: 10 * 60 * 1000,
placeholderData: (prev) => prev,
});
- const displayedHotspots = useMemo(() => {
- const hotspots = debouncedQuery.length >= 2 ? searchResults : allHotspots;
+ const { data: savedHotspots = [] } = useQuery({
+ queryKey: ["savedHotspots"],
+ queryFn: getSavedHotspots,
+ staleTime: 5 * 60 * 1000,
+ gcTime: 10 * 60 * 1000,
+ });
+
+ const { data: savedPlaces = [] } = useQuery({
+ queryKey: ["savedPlaces"],
+ queryFn: getSavedPlaces,
+ staleTime: 5 * 60 * 1000,
+ gcTime: 10 * 60 * 1000,
+ });
+
+ const savedHotspotsSet = useMemo(() => new Set(savedHotspots.map((s) => s.hotspot_id)), [savedHotspots]);
+ // Fetch uses padded bounds (shared cache with the map), but clip the list back to the
+ // unpadded viewport so it shows only what's actually visible on the map — not the
+ // off-screen margin hotspots the padding loads ahead of a pan.
+ const candidateHotspots = useMemo(
+ () =>
+ hotspots.filter(
+ (hotspot) =>
+ (!showSavedOnly || savedHotspotsSet.has(hotspot.id)) &&
+ (!snapshotBounds || isWithinBounds(hotspot.lat, hotspot.lng, snapshotBounds))
+ ),
+ [hotspots, savedHotspotsSet, showSavedOnly, snapshotBounds]
+ );
+
+ const targetRichFilter = useTargetRichHotspots(candidateHotspots.map((hotspot) => hotspot.id), {
+ enabled: isOpen && snapshotBounds !== null && !isFetchingHotspots,
+ blockWhileDisabled: true,
+ });
+ const targetRichIds = useMemo(() => new Set(targetRichFilter.filteredIds), [targetRichFilter.filteredIds]);
+ const isTargetRichLoading = targetRichFilter.isActive && (isFetchingHotspots || targetRichFilter.isLoading);
+
+ const filteredHotspots = useMemo(() => {
+ if (!targetRichFilter.isActive) return candidateHotspots;
+ return candidateHotspots.filter((hotspot) => targetRichIds.has(hotspot.id));
+ }, [candidateHotspots, targetRichFilter.isActive, targetRichIds]);
+
+ // Saved places within the same viewport. Excluded from the list when the
+ // target-rich filter is active, since they have no species data to qualify.
+ const placesInView = useMemo(() => {
+ if (!snapshotBounds) return [];
+ return savedPlaces.filter((place) => isWithinBounds(place.lat, place.lng, snapshotBounds));
+ }, [savedPlaces, snapshotBounds]);
- if (hasLocationAccess && location) {
- const hotspotsWithDistance = hotspots.map((h) => ({
- ...h,
- distance: calculateDistance(location.lat, location.lng, h.lat, h.lng),
- }));
- hotspotsWithDistance.sort((a, b) => a.distance - b.distance);
- return hotspotsWithDistance.slice(0, debouncedQuery.length >= 2 ? hotspots.length : NEARBY_LIMIT);
+ const hasUserLocation = location !== null;
+ const originPoint = location ?? snapshot?.center ?? null;
+
+ const rows = useMemo(() => {
+ const placesForList = targetRichFilter.isActive ? [] : placesInView;
+ const base: ListRow[] = [
+ ...filteredHotspots.map((hotspot) => ({ ...hotspot, kind: "hotspot" as const })),
+ ...placesForList.map((place) => ({ ...place, kind: "place" as const })),
+ ];
+
+ const withDistance = base.map((row) => ({
+ row,
+ sortDistance: originPoint ? calculateDistance(originPoint.lat, originPoint.lng, row.lat, row.lng) : 0,
+ }));
+
+ if (originPoint) {
+ withDistance.sort((a, b) => a.sortDistance - b.sortDistance);
} else {
- const sorted = [...hotspots].sort((a, b) => a.name.localeCompare(b.name));
- return sorted.slice(0, debouncedQuery.length >= 2 ? hotspots.length : ALL_HOTSPOTS_LIMIT);
+ withDistance.sort((a, b) => a.row.name.localeCompare(b.row.name));
}
- }, [debouncedQuery, searchResults, allHotspots, hasLocationAccess, location]);
- const { listRef, onScroll } = useScrollRestore(isOpen, searchUpdatedAt);
+ return withDistance.map(({ row, sortDistance }) => {
+ const distance = hasUserLocation && originPoint ? sortDistance : undefined;
+ if (row.kind === "hotspot") {
+ return { ...row, distance };
+ }
+ return { ...row, distance };
+ });
+ }, [filteredHotspots, placesInView, targetRichFilter.isActive, originPoint, hasUserLocation]);
+
+ const matchingCount = rows.length;
+
+ const isLocationLoading = isLoadingUserLocation && permissionStatus === "granted" && location === null;
const handleSelectHotspot = useCallback(
async (hotspot: Hotspot & { distance?: number }) => {
@@ -116,40 +174,72 @@ export default function HotspotList({ isOpen, onClose, onSelectHotspot }: Hotspo
[onSelectHotspot]
);
- const renderHotspotItem = useCallback(
- ({ item }: { item: Hotspot & { distance?: number } }) => ,
- [handleSelectHotspot]
+ const handleSelectPlace = useCallback(
+ async (place: SavedPlace & { distance?: number }) => {
+ await dismissRef.current?.();
+ onSelectPlace(place.id, place.lat, place.lng);
+ },
+ [onSelectPlace]
+ );
+
+ const renderItem = useCallback(
+ ({ item }: { item: ListRow }) =>
+ item.kind === "hotspot" ? (
+
+ ) : (
+
+ ),
+ [handleSelectHotspot, handleSelectPlace, savedHotspotsSet]
);
+ const keyExtractor = useCallback((item: ListRow) => `${item.kind}:${item.id}`, []);
+
+ const showEmptyState = isZoomedOut || isTargetRichLoading || isLocationLoading || rows.length === 0;
+
const listEmptyComponent = (
- {isLoadingLocation && permissionStatus === "granted" ? (
+ {isZoomedOut ? (
+ Zoom in to list hotspots
+ ) : isTargetRichLoading ? (
+ <>
+
+ Filtering hotspots...
+ >
+ ) : isLocationLoading ? (
<>
Getting current location...
>
+ ) : activeFilterCount > 0 ? (
+ No hotspots match your filters
) : (
- No hotspots found
+ No hotspots in view
)}
);
- const headerText = hasLocationAccess ? "Nearby Hotspots" : "Hotspots";
-
- const keyExtractor = useCallback((item: Hotspot & { distance?: number }) => item.id, []);
+ let headerTitle: string;
+ if (isZoomedOut || snapshotBounds === null || isTargetRichLoading || isLocationLoading) {
+ headerTitle = "Locations";
+ } else {
+ headerTitle = `${matchingCount} ${matchingCount === 1 ? "location" : "locations"}`;
+ }
const headerContent = (dismiss: () => Promise) => {
dismissRef.current = dismiss;
return (
-
+
- {headerText}
+ {headerTitle}
- setIsFilterPanelOpen((open) => !open)} />
+ }
+ onPress={() => setIsFilterSheetOpen(true)}
+ />
{activeFilterCount > 0 && (
-
-
- {isFilterPanelOpen && (
-
- Show saved only
-
-
- )}
-
-
);
};
@@ -180,27 +260,26 @@ export default function HotspotList({ isOpen, onClose, onSelectHotspot }: Hotspo
+ setIsFilterSheetOpen(false)} />
>
);
}
diff --git a/components/MapViewControls.tsx b/components/MapViewControls.tsx
new file mode 100644
index 0000000..8c0478b
--- /dev/null
+++ b/components/MapViewControls.tsx
@@ -0,0 +1,44 @@
+import FilterSlidersIcon from "@/components/icons/FilterSlidersIcon";
+import tw from "@/lib/tw";
+import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
+import React from "react";
+import { Platform, Text, TouchableOpacity, View } from "react-native";
+
+type MapViewControlsProps = {
+ onOpenFilters: () => void;
+ onOpenList: () => void;
+ filterCount: number;
+};
+
+export default function MapViewControls({ onOpenFilters, onOpenList, filterCount }: MapViewControlsProps) {
+ const useGlass = Platform.OS === "ios" && isLiquidGlassAvailable();
+
+ const inner = (
+
+
+
+ {filterCount > 0 && (
+
+ {filterCount}
+
+ )}
+
+
+
+ List
+
+
+ );
+
+ if (useGlass) {
+ return (
+
+
+ {inner}
+
+
+ );
+ }
+
+ return {inner};
+}
diff --git a/components/Mapbox.tsx b/components/Mapbox.tsx
index 9459cd3..26c2382 100644
--- a/components/Mapbox.tsx
+++ b/components/Mapbox.tsx
@@ -1,3 +1,5 @@
+import { useDelayedFlag } from "@/hooks/useDelayedFlag";
+import { useTargetRichHotspots } from "@/hooks/useTargetRichHotspots";
import { getHotspotsWithinBounds, getSavedHotspots, getSavedPlaces } from "@/lib/database";
import {
haloInnerStyle,
@@ -6,19 +8,21 @@ import {
savedHotspotSymbolStyle,
savedPlaceSymbolStyle,
} from "@/lib/layers";
+import { targetRichHotspotCache } from "@/lib/targetRichHotspots";
import tw from "@/lib/tw";
import { OnPressEvent } from "@/lib/types";
import { findClosestFeature, getMarkerColorIndex, padBoundsBySize } from "@/lib/utils";
import { useFiltersStore } from "@/stores/filtersStore";
import { useLocationPermissionStore } from "@/stores/locationPermissionStore";
import { useMapStore } from "@/stores/mapStore";
+import { useSettingsStore } from "@/stores/settingsStore";
import Mapbox from "@rnmapbox/maps";
import { useQuery } from "@tanstack/react-query";
import Constants from "expo-constants";
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
import debounce from "lodash/debounce";
import throttle from "lodash/throttle";
-import React, { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from "react";
+import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import { Linking, Platform, Text, TouchableOpacity, View, ViewStyle } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import BaseBottomSheet from "./BaseBottomSheet";
@@ -102,9 +106,31 @@ export type MapboxMapRef = {
const THROTTLE_DELAY = 750;
const THROTTLE_DELAY_WITH_OPEN_HOTSPOT = 250; // Load hotspots faster when jumping to a hotspot from list modal
+const SETTLED_BOUNDS_DELAY = 300;
+// Only surface the "Filtering hotspots..." badge if filtering outlasts this; incremental pans resolve faster and shouldn't flicker it.
+const TARGET_RICH_BADGE_DELAY = 350;
const MIN_ZOOM = 8;
const DEFAULT_USER_ZOOM = 14;
const DEFAULT_HOTSPOT_ZOOM = 13;
+const BOUNDS_EPSILON = 0.0001;
+
+function areBoundsEquivalent(left: Bounds | null, right: Bounds | null): boolean {
+ if (left === right) {
+ return true;
+ }
+
+ if (!left || !right) {
+ return false;
+ }
+
+ return (
+ Math.abs(left.west - right.west) < BOUNDS_EPSILON &&
+ Math.abs(left.south - right.south) < BOUNDS_EPSILON &&
+ Math.abs(left.east - right.east) < BOUNDS_EPSILON &&
+ Math.abs(left.north - right.north) < BOUNDS_EPSILON
+ );
+}
+
const isValidUserCoord = (coord: [number, number] | null) => {
if (!coord) return false;
const [lng, lat] = coord;
@@ -134,12 +160,16 @@ const MapboxMap = forwardRef(
const currentLayer = useMapStore((state) => state.currentLayer);
const placeId = useMapStore((state) => state.placeId);
const setMapCenter = useMapStore((state) => state.setMapCenter);
+ const setStoreBounds = useMapStore((state) => state.setBounds);
+ const setInViewCount = useMapStore((state) => state.setInViewCount);
+ const setDisplayedCount = useMapStore((state) => state.setDisplayedCount);
const isZoomedTooFarOut = useMapStore((state) => state.isZoomedTooFarOut);
const setIsZoomedTooFarOut = useMapStore((state) => state.setIsZoomedTooFarOut);
const showAttribution = useMapStore((state) => state.isMapAttributionOpen);
const setShowAttribution = useMapStore((state) => state.setIsMapAttributionOpen);
const { status: permissionStatus } = useLocationPermissionStore();
- const { showSavedOnly } = useFiltersStore();
+ const { showSavedOnly, targetRichEnabled } = useFiltersStore();
+ const lifelist = useSettingsStore((state) => state.lifelist);
const mapRef = useRef(null);
const cameraRef = useRef(null);
@@ -147,9 +177,12 @@ const MapboxMap = forwardRef(
const centeredToUserRef = useRef(false);
const userCoordRef = useRef<[number, number] | null>(null);
const isTouchActiveRef = useRef(false);
+ const lastResolvedHotspotsRef = useRef<{ id: string; lat: number; lng: number; species: number }[]>([]);
const [isMapReady, setIsMapReady] = useState(false);
const [bounds, setBounds] = useState(null);
+ const hasLifeList = (lifelist?.length ?? 0) > 0;
+ const isTargetRichMapFiltering = targetRichEnabled && hasLifeList;
const mapStyle = useMemo(() => {
return currentLayer === "satellite"
@@ -157,7 +190,7 @@ const MapboxMap = forwardRef(
: "mapbox://styles/mapbox/outdoors-v12";
}, [currentLayer]);
- const { data: hotspots = [] } = useQuery({
+ const { data: hotspots = [], isFetching: isFetchingHotspots } = useQuery({
queryKey: ["hotspots", bounds],
queryFn: async () => {
if (!bounds) return [];
@@ -192,9 +225,19 @@ const MapboxMap = forwardRef(
}
const throttledSetBounds = useMemo(
- () => throttle((b: Bounds | null) => setBounds(b), throttleDelay),
+ () =>
+ throttle((nextBounds: Bounds | null) => {
+ setBounds((currentBounds) => (areBoundsEquivalent(currentBounds, nextBounds) ? currentBounds : nextBounds));
+ }, throttleDelay),
[throttleDelay]
);
+ const debouncedSetSettledBounds = useMemo(
+ () =>
+ debounce((nextBounds: Bounds | null) => {
+ setBounds((currentBounds) => (areBoundsEquivalent(currentBounds, nextBounds) ? currentBounds : nextBounds));
+ }, SETTLED_BOUNDS_DELAY),
+ []
+ );
const debouncedSaveLocation = useMemo(
() =>
debounce(async () => {
@@ -228,13 +271,41 @@ const MapboxMap = forwardRef(
return null;
}, [isZoomedTooFarOut, setIsZoomedTooFarOut]);
- const syncViewport = useCallback(async () => {
+ const syncViewport = useCallback(async (immediate = false) => {
if (!mapRef.current) return;
- const b = await readBoundsIfZoomed();
- throttledSetBounds(b);
+ const nextBounds = await readBoundsIfZoomed();
+ if (immediate) {
+ throttledSetBounds.cancel();
+ debouncedSetSettledBounds.cancel();
+ setBounds((currentBounds) => (areBoundsEquivalent(currentBounds, nextBounds) ? currentBounds : nextBounds));
+ } else if (isTargetRichMapFiltering) {
+ debouncedSetSettledBounds(nextBounds);
+ } else {
+ throttledSetBounds(nextBounds);
+ }
debouncedSaveLocation();
throttledSetMapCenter();
- }, [readBoundsIfZoomed, throttledSetBounds, debouncedSaveLocation, throttledSetMapCenter]);
+ }, [
+ debouncedSaveLocation,
+ debouncedSetSettledBounds,
+ isTargetRichMapFiltering,
+ readBoundsIfZoomed,
+ throttledSetBounds,
+ throttledSetMapCenter,
+ ]);
+
+ useEffect(() => {
+ return () => {
+ throttledSetBounds.cancel();
+ debouncedSetSettledBounds.cancel();
+ };
+ }, [debouncedSetSettledBounds, throttledSetBounds]);
+
+ useEffect(() => {
+ throttledSetBounds.cancel();
+ debouncedSetSettledBounds.cancel();
+ void syncViewport(true);
+ }, [debouncedSetSettledBounds, isTargetRichMapFiltering, syncViewport, throttledSetBounds]);
const setTouchActive = useCallback(
(isActive: boolean) => {
@@ -285,6 +356,72 @@ const MapboxMap = forwardRef(
);
const savedHotspotsSet = useMemo(() => new Set(savedHotspots.map((s) => s.hotspot_id)), [savedHotspots]);
+ const mapCandidateHotspots = useMemo(
+ () => hotspots.filter((hotspot) => !showSavedOnly || savedHotspotsSet.has(hotspot.id)),
+ [hotspots, savedHotspotsSet, showSavedOnly]
+ );
+ const targetRichFilter = useTargetRichHotspots(mapCandidateHotspots.map((hotspot) => hotspot.id), {
+ enabled: bounds !== null && !isFetchingHotspots,
+ blockWhileDisabled: true,
+ });
+ const targetRichHotspotIds = useMemo(() => new Set(targetRichFilter.filteredIds), [targetRichFilter.filteredIds]);
+ const unresolvedCandidateCount = targetRichFilter.isActive
+ ? mapCandidateHotspots.reduce((count, hotspot) => count + (targetRichHotspotCache.has(hotspot.id) ? 0 : 1), 0)
+ : 0;
+ const isInitialTargetRichFetch =
+ targetRichFilter.isActive &&
+ bounds !== null &&
+ isFetchingHotspots &&
+ lastResolvedHotspotsRef.current.length === 0;
+ const isTargetRichLoading =
+ targetRichFilter.isActive &&
+ (isInitialTargetRichFetch || unresolvedCandidateCount > 0 || targetRichFilter.isLoading);
+ // Delay the badge so brief filtering during incremental pans doesn't flash it; markers still use isTargetRichLoading directly.
+ const isTargetRichBadgeVisible = useDelayedFlag(isTargetRichLoading, TARGET_RICH_BADGE_DELAY);
+ const displayedHotspots = useMemo(() => {
+ if (showSavedOnly || targetRichFilter.isActive) {
+ const resolvedHotspots = mapCandidateHotspots.filter((hotspot) =>
+ targetRichFilter.isActive ? targetRichHotspotIds.has(hotspot.id) : true
+ );
+
+ if (isTargetRichLoading) {
+ return lastResolvedHotspotsRef.current;
+ }
+
+ return resolvedHotspots;
+ }
+
+ return hotspots;
+ }, [
+ hotspots,
+ isTargetRichLoading,
+ mapCandidateHotspots,
+ targetRichFilter.isActive,
+ targetRichHotspotIds,
+ showSavedOnly,
+ ]);
+
+ useEffect(() => {
+ if (!isTargetRichLoading) {
+ lastResolvedHotspotsRef.current = displayedHotspots;
+ }
+ }, [displayedHotspots, isTargetRichLoading]);
+
+ // Mirror the viewport + result counts into the store so the hotspot list can
+ // snapshot the same bounds and the toggle pill can show how many are in view.
+ useEffect(() => {
+ setStoreBounds(bounds);
+ }, [bounds, setStoreBounds]);
+
+ useEffect(() => {
+ if (bounds === null) {
+ setInViewCount(null);
+ setDisplayedCount(null);
+ return;
+ }
+ setInViewCount(mapCandidateHotspots.length);
+ setDisplayedCount(displayedHotspots.length);
+ }, [bounds, mapCandidateHotspots.length, displayedHotspots.length, setInViewCount, setDisplayedCount]);
const handleFeaturePress = useCallback(
(event: any) => {
@@ -346,10 +483,14 @@ const MapboxMap = forwardRef(
style={tw`flex-1`}
styleURL={mapStyle}
onDidFinishLoadingMap={() => setIsMapReady(true)}
- onDidFinishLoadingStyle={syncViewport}
- onCameraChanged={syncViewport}
- onMapIdle={() => {
+ onDidFinishLoadingStyle={() => {
+ syncViewport(true);
+ }}
+ onCameraChanged={() => {
syncViewport();
+ }}
+ onMapIdle={() => {
+ syncViewport(true);
centerMapOnUserInitial();
}}
onPress={handleFeaturePress}
@@ -393,14 +534,14 @@ const MapboxMap = forwardRef(
>
)}
- {isMapReady && (hotspots.length > 0 || savedPlaces.length > 0) && (
+ {isMapReady && (displayedHotspots.length > 0 || savedPlaces.length > 0) && (
{
+ ...displayedHotspots.map((h: any) => {
const isSaved = savedHotspotsSet.has(h.id);
return {
type: "Feature" as const,
@@ -427,15 +568,10 @@ const MapboxMap = forwardRef(
],
}}
>
- {/* Hotspot - hidden when showSavedOnly filter is active */}
+ {/* Hotspot */}
@@ -446,7 +582,7 @@ const MapboxMap = forwardRef(
style={savedHotspotSymbolStyle() as any}
/>
- {/* Selected hotspot - hidden when showSavedOnly filter is active */}
+ {/* Selected hotspot */}
(
["==", ["get", "featureType"], "hotspot"],
["==", ["get", "isSaved"], false],
["==", ["get", "isSelected"], true],
- ["literal", !showSavedOnly],
]}
style={haloInnerStyle() as any}
/>
@@ -465,7 +600,6 @@ const MapboxMap = forwardRef(
["==", ["get", "featureType"], "hotspot"],
["==", ["get", "isSaved"], false],
["==", ["get", "isSelected"], true],
- ["literal", !showSavedOnly],
]}
style={haloOuterStyle() as any}
/>
@@ -476,7 +610,6 @@ const MapboxMap = forwardRef(
["==", ["get", "featureType"], "hotspot"],
["==", ["get", "isSaved"], false],
["==", ["get", "isSelected"], true],
- ["literal", !showSavedOnly],
]}
style={hotspotSymbolStyle() as any}
/>
@@ -591,6 +724,22 @@ const MapboxMap = forwardRef(
)}
+ {isTargetRichBadgeVisible && !isZoomedTooFarOut && (
+
+ {Platform.OS === "ios" && isLiquidGlassAvailable() ? (
+
+
+ Filtering hotspots...
+
+
+ ) : (
+
+ Filtering hotspots...
+
+ )}
+
+ )}
+
(
-
{
await dismiss();
diff --git a/components/MenuList.tsx b/components/MenuList.tsx
index 1b31d36..7b8f2b0 100644
--- a/components/MenuList.tsx
+++ b/components/MenuList.tsx
@@ -54,7 +54,7 @@ export default function MenuList({ onNavigateToPacks, onNavigateToSettings, pack
);
return (
-
+
{menuOptions.map((item) => (
{renderMenuItem({ item })}
))}
diff --git a/components/PlaceEditSheet.tsx b/components/PlaceEditSheet.tsx
index 80b197a..0fd353e 100644
--- a/components/PlaceEditSheet.tsx
+++ b/components/PlaceEditSheet.tsx
@@ -1,6 +1,7 @@
import IconButton from "@/components/IconButton";
import Input from "@/components/Input";
import { deletePlace, getSavedPlaceById, savePlace } from "@/lib/database";
+import { placeIconImages } from "@/lib/placeIconImages";
import { placeIcons, type PlaceIconT } from "@/lib/placeIcons";
import tw from "@/lib/tw";
import { generateId } from "@/lib/utils";
@@ -11,30 +12,6 @@ import { FlatList, Image, ScrollView, Text, TextInput, TouchableOpacity, View }
import Toast from "react-native-toast-message";
import BaseBottomSheet from "./BaseBottomSheet";
-const placeIconImages: Record = {
- hike: require("@/assets/markers/place-hike.png"),
- mountain: require("@/assets/markers/place-mountain.png"),
- tent: require("@/assets/markers/place-tent.png"),
- house: require("@/assets/markers/place-house.png"),
- airbnb: require("@/assets/markers/place-airbnb.png"),
- bed: require("@/assets/markers/place-bed.png"),
- bins: require("@/assets/markers/place-bins.png"),
- camera: require("@/assets/markers/place-camera.png"),
- airport: require("@/assets/markers/place-airport.png"),
- boat: require("@/assets/markers/place-boat.png"),
- car: require("@/assets/markers/place-car.png"),
- bus: require("@/assets/markers/place-bus.png"),
- utensils: require("@/assets/markers/place-utensils.png"),
- mug: require("@/assets/markers/place-mug.png"),
- trolley: require("@/assets/markers/place-trolley.png"),
- bike: require("@/assets/markers/place-bike.png"),
- dog: require("@/assets/markers/place-dog.png"),
- fuel: require("@/assets/markers/place-fuel.png"),
- parking: require("@/assets/markers/place-parking.png"),
- building: require("@/assets/markers/place-building.png"),
- hotspot: require("@/assets/markers/place-hotspot.png"),
-};
-
const placeIconKeys = Object.keys(placeIcons) as PlaceIconT[];
type PlaceEditSheetProps = {
diff --git a/components/PlaceItem.tsx b/components/PlaceItem.tsx
new file mode 100644
index 0000000..192e5e7
--- /dev/null
+++ b/components/PlaceItem.tsx
@@ -0,0 +1,61 @@
+import { getPlaceIconImage } from "@/lib/placeIconImages";
+import tw from "@/lib/tw";
+import { SavedPlace } from "@/lib/types";
+import { formatDistance } from "@/lib/utils";
+import { useSettingsStore } from "@/stores/settingsStore";
+import { Ionicons } from "@expo/vector-icons";
+import React, { useCallback } from "react";
+import { Image, Pressable, Text, View } from "react-native";
+
+type PlaceItemProps = {
+ item: SavedPlace & { distance?: number };
+ onSelect: (place: SavedPlace & { distance?: number }) => void;
+};
+
+const PlaceItem = React.memo(
+ ({ item, onSelect }: PlaceItemProps) => {
+ const useMiles = useSettingsStore((state) => state.distanceUnits === "imperial");
+ const handlePress = useCallback(() => {
+ onSelect(item);
+ }, [item, onSelect]);
+
+ return (
+ // Prevent the parent list from canceling row presses after the list is reopened.
+ [tw`flex-row items-center px-4 py-3 border-b border-gray-200/50`, pressed && tw`opacity-70`]}
+ >
+
+
+ {item.name}
+
+
+
+ {item.notes ? item.notes : "Saved Pin"}
+
+
+ {item.distance !== undefined && (
+ {formatDistance(item.distance, useMiles)}
+ )}
+
+
+ );
+ },
+ (prevProps, nextProps) => {
+ return (
+ prevProps.item.id === nextProps.item.id &&
+ prevProps.item.lat === nextProps.item.lat &&
+ prevProps.item.lng === nextProps.item.lng &&
+ prevProps.item.name === nextProps.item.name &&
+ prevProps.item.icon === nextProps.item.icon &&
+ prevProps.item.notes === nextProps.item.notes &&
+ prevProps.item.distance === nextProps.item.distance &&
+ prevProps.onSelect === nextProps.onSelect
+ );
+ }
+);
+
+PlaceItem.displayName = "PlaceItem";
+
+export default PlaceItem;
diff --git a/components/SearchInput.tsx b/components/SearchInput.tsx
index 43bf1d9..851a814 100644
--- a/components/SearchInput.tsx
+++ b/components/SearchInput.tsx
@@ -9,7 +9,10 @@ type SearchInputProps = {
value: string;
onChangeText: (text: string) => void;
placeholder?: string;
-} & Pick;
+ // When false, the inline clear (✕) button is never shown — useful when the
+ // surrounding UI already provides a close affordance and a second ✕ would clash.
+ clearable?: boolean;
+} & Pick;
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
@@ -28,12 +31,14 @@ export default function SearchInput({
autoCapitalize = "none",
autoComplete = "off",
returnKeyType = "search",
+ autoFocus = false,
+ clearable = true,
}: SearchInputProps) {
const inputRef = useRef(null);
const [isFocused, setIsFocused] = useState(false);
const useGlass = Platform.OS === "ios" && isLiquidGlassAvailable();
- const showClear = value.length > 0 || isFocused;
+ const showClear = clearable && (value.length > 0 || isFocused);
const handleClear = () => {
onChangeText("");
@@ -59,6 +64,7 @@ export default function SearchInput({
autoCapitalize={autoCapitalize}
autoComplete={autoComplete}
returnKeyType={returnKeyType}
+ autoFocus={autoFocus}
/>
);
diff --git a/components/SearchSheet.tsx b/components/SearchSheet.tsx
new file mode 100644
index 0000000..763ae78
--- /dev/null
+++ b/components/SearchSheet.tsx
@@ -0,0 +1,228 @@
+import { useLocation } from "@/hooks/useLocation";
+import { getSavedHotspots, getSavedPlaces, searchHotspots } from "@/lib/database";
+import tw from "@/lib/tw";
+import { Hotspot, SavedPlace } from "@/lib/types";
+import { calculateDistance } from "@/lib/utils";
+import { useMapStore } from "@/stores/mapStore";
+import { useQuery } from "@tanstack/react-query";
+import { FlashList } from "@shopify/flash-list";
+import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { Text, View } from "react-native";
+import BaseBottomSheet from "./BaseBottomSheet";
+import HotspotItem from "./HotspotItem";
+import IconButton from "./IconButton";
+import PlaceItem from "./PlaceItem";
+import SearchInput from "./SearchInput";
+
+type SearchSheetProps = {
+ isOpen: boolean;
+ onClose: () => void;
+ onSelectHotspot: (hotspotId: string, lat: number, lng: number) => void;
+ onSelectPlace: (placeId: string, lat: number, lng: number) => void;
+};
+
+// Nothing is searched until the query is meaningful — a 1-char `LIKE %x%`
+// against the full table matches almost everything.
+const MIN_QUERY = 2;
+const HOTSPOT_LIMIT = 30;
+
+type PlaceWithDistance = SavedPlace & { distance?: number };
+type HotspotWithDistance = Hotspot & { distance?: number };
+
+type SearchRow =
+ | { type: "section"; key: string; title: string }
+ | { type: "place"; key: string; place: PlaceWithDistance }
+ | { type: "hotspot"; key: string; hotspot: HotspotWithDistance };
+
+export default function SearchSheet({ isOpen, onClose, onSelectHotspot, onSelectPlace }: SearchSheetProps) {
+ const [query, setQuery] = useState("");
+ const [debouncedQuery, setDebouncedQuery] = useState("");
+ const dismissRef = useRef<(() => Promise) | null>(null);
+ const { location } = useLocation(isOpen);
+ const mapCenter = useMapStore((state) => state.mapCenter);
+
+ // Sort origin falls back to map center so duplicate-named saved places (e.g.
+ // two "Airport"s) still order nearest-first without GPS. The displayed
+ // distance label stays gated on real location only — see `withDistance`.
+ const originPoint = location ?? mapCenter ?? null;
+
+ useEffect(() => {
+ if (!isOpen) {
+ setQuery("");
+ setDebouncedQuery("");
+ }
+ }, [isOpen]);
+
+ // Debounce the hotspot SQL search; place filtering stays live off `query`.
+ useEffect(() => {
+ const handle = setTimeout(() => setDebouncedQuery(query.trim()), 250);
+ return () => clearTimeout(handle);
+ }, [query]);
+
+ const { data: savedPlaces = [] } = useQuery({
+ queryKey: ["savedPlaces"],
+ queryFn: getSavedPlaces,
+ staleTime: 5 * 60 * 1000,
+ gcTime: 10 * 60 * 1000,
+ });
+
+ const { data: savedHotspots = [] } = useQuery({
+ queryKey: ["savedHotspots"],
+ queryFn: getSavedHotspots,
+ staleTime: 5 * 60 * 1000,
+ gcTime: 10 * 60 * 1000,
+ });
+
+ const savedHotspotsSet = useMemo(() => new Set(savedHotspots.map((s) => s.hotspot_id)), [savedHotspots]);
+
+ const hotspotQueryEnabled = isOpen && debouncedQuery.length >= MIN_QUERY;
+ const { data: hotspotResults = [] } = useQuery({
+ queryKey: ["searchHotspots", debouncedQuery],
+ queryFn: () => searchHotspots(debouncedQuery, HOTSPOT_LIMIT),
+ enabled: hotspotQueryEnabled,
+ staleTime: 5 * 60 * 1000,
+ gcTime: 10 * 60 * 1000,
+ placeholderData: (prev) => prev,
+ });
+
+ const withDistance = useCallback(
+ (item: T): T & { distance?: number } => {
+ if (!location) return item;
+ return { ...item, distance: calculateDistance(location.lat, location.lng, item.lat, item.lng) };
+ },
+ [location]
+ );
+
+ const matchingPlaces = useMemo(() => {
+ const trimmed = query.trim().toLowerCase();
+ const matched = trimmed.length >= MIN_QUERY
+ ? savedPlaces.filter((place) => place.name.toLowerCase().includes(trimmed))
+ : [];
+ const decorated = matched.map((place) => ({
+ place: withDistance(place),
+ sortDistance: originPoint ? calculateDistance(originPoint.lat, originPoint.lng, place.lat, place.lng) : null,
+ }));
+ decorated.sort((a, b) => {
+ if (a.sortDistance !== null && b.sortDistance !== null) return a.sortDistance - b.sortDistance;
+ return a.place.name.localeCompare(b.place.name);
+ });
+ return decorated.map((d) => d.place);
+ }, [savedPlaces, query, withDistance, originPoint]);
+
+ // Hotspots come back alphabetical from SQL; surface prefix matches first so
+ // the most likely target sits at the top.
+ const rankedHotspots = useMemo(() => {
+ if (!hotspotQueryEnabled) return [];
+ const trimmed = debouncedQuery.toLowerCase();
+ const decorated = hotspotResults.map(withDistance);
+ decorated.sort((a, b) => {
+ const aPrefix = a.name.toLowerCase().startsWith(trimmed) ? 0 : 1;
+ const bPrefix = b.name.toLowerCase().startsWith(trimmed) ? 0 : 1;
+ if (aPrefix !== bPrefix) return aPrefix - bPrefix;
+ return a.name.localeCompare(b.name);
+ });
+ return decorated;
+ }, [hotspotResults, debouncedQuery, hotspotQueryEnabled, withDistance]);
+
+ const rows = useMemo(() => {
+ const result: SearchRow[] = [];
+ if (matchingPlaces.length > 0) {
+ result.push({ type: "section", key: "section:saved", title: "Saved locations" });
+ for (const place of matchingPlaces) {
+ result.push({ type: "place", key: `place:${place.id}`, place });
+ }
+ }
+ if (rankedHotspots.length > 0) {
+ result.push({ type: "section", key: "section:hotspots", title: "Hotspots" });
+ for (const hotspot of rankedHotspots) {
+ result.push({ type: "hotspot", key: `hotspot:${hotspot.id}`, hotspot });
+ }
+ }
+ return result;
+ }, [matchingPlaces, rankedHotspots]);
+
+ const handleSelectHotspot = useCallback(
+ async (hotspot: HotspotWithDistance) => {
+ await dismissRef.current?.();
+ onSelectHotspot(hotspot.id, hotspot.lat, hotspot.lng);
+ },
+ [onSelectHotspot]
+ );
+
+ const handleSelectPlace = useCallback(
+ async (place: PlaceWithDistance) => {
+ await dismissRef.current?.();
+ onSelectPlace(place.id, place.lat, place.lng);
+ },
+ [onSelectPlace]
+ );
+
+ const renderItem = useCallback(
+ ({ item }: { item: SearchRow }) => {
+ if (item.type === "section") {
+ return (
+
+ {item.title}
+
+ );
+ }
+ if (item.type === "place") {
+ return ;
+ }
+ return ;
+ },
+ [handleSelectHotspot, handleSelectPlace, savedHotspotsSet]
+ );
+
+ const keyExtractor = useCallback((item: SearchRow) => item.key, []);
+
+ // Distinguish empty-because-nothing-typed from empty-because-no-matches.
+ const isSearching = query.trim().length >= MIN_QUERY;
+ const emptyMessage = isSearching ? "No matches" : "Search for hotspots or saved locations";
+
+ const headerContent = (dismiss: () => Promise) => {
+ dismissRef.current = dismiss;
+ return (
+
+
+
+
+
+
+ );
+ };
+
+ return (
+
+
+ {emptyMessage}
+
+ }
+ />
+
+ );
+}
diff --git a/components/TargetRichHotspotControls.tsx b/components/TargetRichHotspotControls.tsx
new file mode 100644
index 0000000..f1550f7
--- /dev/null
+++ b/components/TargetRichHotspotControls.tsx
@@ -0,0 +1,90 @@
+import tw from "@/lib/tw";
+import { useFiltersStore } from "@/stores/filtersStore";
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import { Pressable, Switch, Text, View } from "react-native";
+
+type TargetRichHotspotControlsProps = {
+ hasLifeList: boolean;
+};
+
+const PERCENT_STEP = 10;
+const MIN_PERCENT = 10;
+
+function StepperButton({
+ icon,
+ onPress,
+ disabled,
+}: {
+ icon: keyof typeof Ionicons.glyphMap;
+ onPress: () => void;
+ disabled?: boolean;
+}) {
+ return (
+ [
+ tw`w-9 h-9 rounded-full items-center justify-center border border-gray-300`,
+ disabled && tw`opacity-40`,
+ pressed && !disabled && tw`bg-gray-100`,
+ ]}
+ >
+
+
+ );
+}
+
+export default function TargetRichHotspotControls({
+ hasLifeList,
+}: TargetRichHotspotControlsProps) {
+ const enabled = useFiltersStore((state) => state.targetRichEnabled);
+ const setEnabled = useFiltersStore((state) => state.setTargetRichEnabled);
+ const minCount = useFiltersStore((state) => state.minTargets);
+ const setMinCount = useFiltersStore((state) => state.setMinTargets);
+ const minPercent = useFiltersStore((state) => state.minTargetFrequency);
+ const setMinPercent = useFiltersStore((state) => state.setMinTargetFrequency);
+
+ // Snap to clean tens as the user steps, so values stay 0/10/20/… even if a
+ // legacy stored value (e.g. 12) was off the grid.
+ const stepPercent = (delta: number) => {
+ const base = Math.round(minPercent / PERCENT_STEP) * PERCENT_STEP;
+ setMinPercent(Math.min(100, Math.max(MIN_PERCENT, base + delta)));
+ };
+
+ return (
+
+
+
+ Target-Rich Hotspots
+
+
+
+
+ {!hasLifeList ? (
+ Import a life list to enable this filter.
+ ) : enabled ? (
+
+
+ Minimum targets
+
+ setMinCount(minCount - 1)} disabled={minCount <= 1} />
+ {minCount}
+ setMinCount(minCount + 1)} />
+
+
+
+
+ Minimum frequency
+
+ stepPercent(-PERCENT_STEP)} disabled={minPercent <= MIN_PERCENT} />
+ {minPercent}%
+ stepPercent(PERCENT_STEP)} disabled={minPercent >= 100} />
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/components/icons/FilterSlidersIcon.tsx b/components/icons/FilterSlidersIcon.tsx
new file mode 100644
index 0000000..2c187d3
--- /dev/null
+++ b/components/icons/FilterSlidersIcon.tsx
@@ -0,0 +1,19 @@
+import React from "react";
+import Svg, { Path } from "react-native-svg";
+
+type Props = {
+ color?: string;
+ size?: number;
+};
+
+// Font Awesome Pro "sliders" (filter) icon.
+export default function FilterSlidersIcon({ color = "#374151", size = 20 }: Props) {
+ return (
+
+ );
+}
diff --git a/components/ui/IconSymbol.tsx b/components/ui/IconSymbol.tsx
index f3687fa..e22a33a 100644
--- a/components/ui/IconSymbol.tsx
+++ b/components/ui/IconSymbol.tsx
@@ -5,7 +5,7 @@ import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
import { ComponentProps } from 'react';
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
-type IconMapping = Record['name']>;
+type IconMapping = Record, ComponentProps['name']>;
type IconSymbolName = keyof typeof MAPPING;
/**
diff --git a/hooks/useActiveFilterCount.ts b/hooks/useActiveFilterCount.ts
new file mode 100644
index 0000000..4d210b6
--- /dev/null
+++ b/hooks/useActiveFilterCount.ts
@@ -0,0 +1,12 @@
+import { useFiltersStore } from "@/stores/filtersStore";
+import { useSettingsStore } from "@/stores/settingsStore";
+
+// The target-rich filter only counts as active once a life list is imported.
+export function useActiveFilterCount() {
+ const showSavedOnly = useFiltersStore((state) => state.showSavedOnly);
+ const targetRichEnabled = useFiltersStore((state) => state.targetRichEnabled);
+ const lifelist = useSettingsStore((state) => state.lifelist);
+ const hasLifeList = (lifelist?.length ?? 0) > 0;
+
+ return [showSavedOnly, targetRichEnabled && hasLifeList].filter(Boolean).length;
+}
diff --git a/hooks/useDelayedFlag.ts b/hooks/useDelayedFlag.ts
new file mode 100644
index 0000000..97fe06c
--- /dev/null
+++ b/hooks/useDelayedFlag.ts
@@ -0,0 +1,40 @@
+import { useEffect, useRef, useState } from "react";
+
+/**
+ * Delays surfacing a transient `true` flag so brief flickers never render.
+ *
+ * The returned value only becomes `true` after `source` has stayed `true`
+ * continuously for `delayMs`. It clears immediately when `source` goes `false`,
+ * so quick on/off cycles (e.g. a loading badge during incremental map pans)
+ * never appear. Use to suppress loading indicators for work that usually
+ * resolves faster than the delay.
+ */
+export function useDelayedFlag(source: boolean, delayMs: number): boolean {
+ const [delayed, setDelayed] = useState(false);
+ const timeoutRef = useRef | null>(null);
+
+ useEffect(() => {
+ if (!source) {
+ if (timeoutRef.current !== null) {
+ clearTimeout(timeoutRef.current);
+ timeoutRef.current = null;
+ }
+ setDelayed(false);
+ return;
+ }
+
+ timeoutRef.current = setTimeout(() => {
+ timeoutRef.current = null;
+ setDelayed(true);
+ }, delayMs);
+
+ return () => {
+ if (timeoutRef.current !== null) {
+ clearTimeout(timeoutRef.current);
+ timeoutRef.current = null;
+ }
+ };
+ }, [source, delayMs]);
+
+ return delayed;
+}
diff --git a/hooks/useManagePack.ts b/hooks/useManagePack.ts
index 8b8f08b..d446b8d 100644
--- a/hooks/useManagePack.ts
+++ b/hooks/useManagePack.ts
@@ -1,5 +1,6 @@
import { cleanupPartialInstall, installPackWithTargets, uninstallPack } from "@/lib/database";
import { downloadWithProgress } from "@/lib/download";
+import { resetTargetRichHotspotCache } from "@/lib/targetRichHotspots";
import { StaticPack, StaticPackResponse } from "@/lib/types";
import { refreshTaxonomy } from "@/lib/taxonomy";
import { API_URL } from "@/lib/utils";
@@ -69,6 +70,8 @@ export function useManagePack(packId: number) {
downloadStore.setProgress(100);
+ resetTargetRichHotspotCache();
+
await queryClient.invalidateQueries({ queryKey: ["installed-packs"] });
queryClient.invalidateQueries({ queryKey: ["hotspots"], refetchType: "active" });
queryClient.invalidateQueries({ queryKey: ["hotspotSearch"] });
@@ -111,6 +114,8 @@ export function useManagePack(packId: number) {
await uninstallPack(packId);
+ resetTargetRichHotspotCache();
+
queryClient.invalidateQueries({ queryKey: ["installed-packs"] });
queryClient.invalidateQueries({ queryKey: ["hotspots"], refetchType: "active" });
queryClient.invalidateQueries({ queryKey: ["hotspotSearch"] });
diff --git a/hooks/useScrollRestore.ts b/hooks/useScrollRestore.ts
deleted file mode 100644
index 0cc5f15..0000000
--- a/hooks/useScrollRestore.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { useCallback, useEffect, useRef } from "react";
-import type { NativeScrollEvent, NativeSyntheticEvent } from "react-native";
-
-export function useScrollRestore(isOpen: boolean, resetKey: number) {
- const listRef = useRef(null);
- const scrollOffsetRef = useRef(0);
- const prevResetKeyRef = useRef(resetKey);
-
- // Restore scroll position when modal opens
- useEffect(() => {
- if (isOpen && scrollOffsetRef.current > 0) {
- const timeoutId = setTimeout(() => {
- listRef.current?.scrollToOffset({ offset: scrollOffsetRef.current, animated: false });
- }, 100);
- return () => clearTimeout(timeoutId);
- }
- }, [isOpen]);
-
- // Reset scroll when resetKey changes
- useEffect(() => {
- if (resetKey !== prevResetKeyRef.current) {
- prevResetKeyRef.current = resetKey;
- scrollOffsetRef.current = 0;
- listRef.current?.scrollToOffset({ offset: 0, animated: false });
- }
- }, [resetKey]);
-
- const onScroll = useCallback((e: NativeSyntheticEvent) => {
- scrollOffsetRef.current = e.nativeEvent.contentOffset.y;
- }, []);
-
- return { listRef, onScroll };
-}
diff --git a/hooks/useTargetRichHotspots.ts b/hooks/useTargetRichHotspots.ts
new file mode 100644
index 0000000..90d6e07
--- /dev/null
+++ b/hooks/useTargetRichHotspots.ts
@@ -0,0 +1,221 @@
+import {
+ createTargetRichHotspotBasis,
+ getTargetRichHotspotCacheGeneration,
+ subscribeToTargetRichHotspotCacheReset,
+ targetRichHotspotCache,
+ syncTargetRichHotspotCacheBasis,
+} from "@/lib/targetRichHotspots";
+import { useFiltersStore } from "@/stores/filtersStore";
+import { useSettingsStore } from "@/stores/settingsStore";
+import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
+
+type UseTargetRichHotspotsOptions = {
+ enabled?: boolean;
+ blockWhileDisabled?: boolean;
+};
+
+type TargetRichHotspotState = {
+ filteredIds: string[];
+ isActive: boolean;
+ isLoading: boolean;
+ hasLifeList: boolean;
+};
+
+type AsyncTargetRichHotspotState = {
+ filteredIds: string[];
+ isLoading: boolean;
+};
+
+function filterResolvedHotspotIds(hotspotIds: string[]): string[] {
+ return hotspotIds.filter((hotspotId) => targetRichHotspotCache.get(hotspotId) === true);
+}
+
+function areStringArraysEqual(left: string[], right: string[]): boolean {
+ if (left.length !== right.length) {
+ return false;
+ }
+
+ return left.every((value, index) => value === right[index]);
+}
+
+export function useTargetRichHotspots(
+ hotspotIds: string[],
+ options: UseTargetRichHotspotsOptions = {}
+): TargetRichHotspotState {
+ const targetRichEnabled = useFiltersStore((state) => state.targetRichEnabled);
+ const minTargets = useFiltersStore((state) => state.minTargets);
+ const minTargetFrequency = useFiltersStore((state) => state.minTargetFrequency);
+ const lifelist = useSettingsStore((state) => state.lifelist);
+ const lifelistExclusions = useSettingsStore((state) => state.lifelistExclusions);
+ const targetMonths = useSettingsStore((state) => state.targetMonths);
+
+ const basis = useMemo(
+ () =>
+ createTargetRichHotspotBasis({
+ lifelist,
+ lifelistExclusions,
+ targetMonths,
+ minTargets,
+ minTargetFrequency,
+ }),
+ [lifelist, lifelistExclusions, targetMonths, minTargets, minTargetFrequency]
+ );
+
+ const cacheGeneration = useSyncExternalStore(
+ subscribeToTargetRichHotspotCacheReset,
+ getTargetRichHotspotCacheGeneration
+ );
+
+ const hasLifeList = basis !== null;
+ const isActive = targetRichEnabled && hasLifeList;
+ const isEnabled = options.enabled ?? true;
+ const candidateKey = useMemo(() => hotspotIds.join("|"), [hotspotIds]);
+ const stableHotspotIdsRef = useRef(hotspotIds);
+ const basisRef = useRef(basis);
+
+ if (stableHotspotIdsRef.current !== hotspotIds && stableHotspotIdsRef.current.join("|") !== candidateKey) {
+ stableHotspotIdsRef.current = hotspotIds;
+ }
+
+ const stableHotspotIds = stableHotspotIdsRef.current;
+
+ const [asyncState, setAsyncState] = useState({
+ filteredIds: [],
+ isLoading: false,
+ });
+
+ useEffect(() => {
+ basisRef.current = basis;
+ }, [basis]);
+
+ useEffect(() => {
+ syncTargetRichHotspotCacheBasis(basis?.cacheKey ?? null);
+ }, [basis?.cacheKey]);
+
+ useEffect(() => {
+ if (!isActive || !isEnabled || stableHotspotIds.length === 0 || !basisRef.current) {
+ return;
+ }
+
+ const basisForRun = basisRef.current;
+ const unresolvedHotspotIds = stableHotspotIds.filter((hotspotId) => !targetRichHotspotCache.has(hotspotId));
+ if (unresolvedHotspotIds.length === 0) {
+ const filteredIds = filterResolvedHotspotIds(stableHotspotIds);
+ setAsyncState((currentState) => {
+ if (!currentState.isLoading && areStringArraysEqual(currentState.filteredIds, filteredIds)) {
+ return currentState;
+ }
+
+ return {
+ filteredIds,
+ isLoading: false,
+ };
+ });
+ return;
+ }
+
+ const abortController = new AbortController();
+
+ setAsyncState((currentState) => ({
+ filteredIds: currentState.isLoading ? currentState.filteredIds : [],
+ isLoading: true,
+ }));
+
+ void targetRichHotspotCache
+ .evaluateMany(stableHotspotIds, basisForRun, abortController.signal)
+ .then(() => {
+ if (abortController.signal.aborted) {
+ return;
+ }
+
+ const stillUnresolved = stableHotspotIds.some((hotspotId) => !targetRichHotspotCache.has(hotspotId));
+ if (stillUnresolved) {
+ setAsyncState((currentState) => ({
+ filteredIds: currentState.filteredIds,
+ isLoading: true,
+ }));
+ return;
+ }
+
+ const filteredIds = filterResolvedHotspotIds(stableHotspotIds);
+ setAsyncState((currentState) => {
+ if (!currentState.isLoading && areStringArraysEqual(currentState.filteredIds, filteredIds)) {
+ return currentState;
+ }
+
+ return {
+ filteredIds,
+ isLoading: false,
+ };
+ });
+ })
+ .catch((error) => {
+ if (abortController.signal.aborted || error?.name === "AbortError") {
+ return;
+ }
+
+ console.error("Failed to evaluate target-rich hotspot filter", error);
+ setAsyncState((currentState) => ({
+ filteredIds: currentState.filteredIds,
+ isLoading: false,
+ }));
+ });
+
+ return () => {
+ abortController.abort();
+ };
+ }, [basis?.cacheKey, cacheGeneration, candidateKey, hasLifeList, isActive, isEnabled, stableHotspotIds]);
+
+ return useMemo(() => {
+ if (!isActive) {
+ return {
+ filteredIds: stableHotspotIds,
+ isActive: false,
+ isLoading: false,
+ hasLifeList,
+ };
+ }
+
+ if (!isEnabled && !options.blockWhileDisabled) {
+ return {
+ filteredIds: stableHotspotIds,
+ isActive: true,
+ isLoading: false,
+ hasLifeList,
+ };
+ }
+
+ if (!isEnabled && options.blockWhileDisabled) {
+ return {
+ filteredIds: [],
+ isActive: true,
+ isLoading: stableHotspotIds.length > 0,
+ hasLifeList,
+ };
+ }
+
+ if (stableHotspotIds.length === 0) {
+ return {
+ filteredIds: [],
+ isActive: true,
+ isLoading: false,
+ hasLifeList,
+ };
+ }
+
+ return {
+ filteredIds: asyncState.filteredIds,
+ isActive: true,
+ isLoading: asyncState.isLoading,
+ hasLifeList,
+ };
+ }, [
+ asyncState.filteredIds,
+ asyncState.isLoading,
+ hasLifeList,
+ isActive,
+ isEnabled,
+ options.blockWhileDisabled,
+ stableHotspotIds,
+ ]);
+}
diff --git a/lib/database.ts b/lib/database.ts
index f7b8ecc..b992003 100644
--- a/lib/database.ts
+++ b/lib/database.ts
@@ -1,5 +1,6 @@
import * as SQLite from "expo-sqlite";
import { BirdPlanTripData, SavedPlace, StaticPackHotspot, StaticPackTarget, Trip } from "./types";
+import { aggregateHotspotTargets, getMonthIndices, getTotalSamplesForMonths, parseHotspotTargetData } from "./hotspotTargets";
let db: SQLite.SQLiteDatabase | null = null;
let isInstallingPack = false;
@@ -207,7 +208,7 @@ export async function getHotspotsWithinBounds(
south: number,
east: number,
north: number
-): Promise<{ id: string; lat: number; lng: number; species: number }[]> {
+): Promise<{ id: string; name: string; lat: number; lng: number; species: number; country: string | null }[]> {
if (!db) throw new Error("Database not initialized");
// When west > east, the bounding box crosses the international date line
@@ -215,16 +216,18 @@ export async function getHotspotsWithinBounds(
const lngCondition = crossesDateLine ? `(lng >= ? OR lng <= ?)` : `(lng >= ? AND lng <= ?)`;
const result = await db.getAllAsync(
- `SELECT id, lat, lng, species FROM hotspots
+ `SELECT id, name, lat, lng, species, country FROM hotspots
WHERE lat >= ? AND lat <= ? AND ${lngCondition}`,
[south, north, west, east]
);
return result.map((row: any) => ({
id: row.id,
+ name: row.name,
lat: row.lat,
lng: row.lng,
species: row.species,
+ country: row.country ?? null,
}));
}
@@ -637,6 +640,34 @@ export type HotspotTargetsResult = {
version: string | null;
};
+const TARGET_QUERY_BATCH_SIZE = 400;
+
+export async function getTargetDataForHotspots(hotspotIds: string[]): Promise