From f629603ffc714a43e590cfb545768a24af7b5651 Mon Sep 17 00:00:00 2001 From: Adam Jackson Date: Thu, 28 May 2026 18:07:34 -0700 Subject: [PATCH 1/2] Hotspot filtering prototype --- components/FilterSection.tsx | 20 +- components/HotspotList.tsx | 53 +++- components/Mapbox.tsx | 172 +++++++++-- .../PersonalizedHotspotFilterControls.tsx | 111 +++++++ hooks/usePersonalizedHotspotFilter.ts | 289 ++++++++++++++++++ lib/database.ts | 65 ++-- lib/hotspotTargets.ts | 54 ++++ lib/personalizedHotspotFilter.ts | 287 +++++++++++++++++ stores/filtersStore.ts | 25 +- 9 files changed, 1006 insertions(+), 70 deletions(-) create mode 100644 components/PersonalizedHotspotFilterControls.tsx create mode 100644 hooks/usePersonalizedHotspotFilter.ts create mode 100644 lib/hotspotTargets.ts create mode 100644 lib/personalizedHotspotFilter.ts diff --git a/components/FilterSection.tsx b/components/FilterSection.tsx index e0af35b..23fbbc5 100644 --- a/components/FilterSection.tsx +++ b/components/FilterSection.tsx @@ -1,11 +1,15 @@ +import PersonalizedHotspotFilterControls from "@/components/PersonalizedHotspotFilterControls"; import tw from "@/lib/tw"; import { useFiltersStore } from "@/stores/filtersStore"; +import { useSettingsStore } from "@/stores/settingsStore"; 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 lifelist = useSettingsStore((state) => state.lifelist); + const hasLifeList = (lifelist?.length ?? 0) > 0; const content = ( @@ -16,11 +20,19 @@ export default function FilterSection() { if (Platform.OS === "android") { return ( - setShowSavedOnly(!showSavedOnly)} style={tw`pl-6 pr-5 py-4`} activeOpacity={1}> - {content} - + + setShowSavedOnly(!showSavedOnly)} activeOpacity={1}> + {content} + + + ); } - return {content}; + return ( + + {content} + + + ); } diff --git a/components/HotspotList.tsx b/components/HotspotList.tsx index bc8ad2f..feb440f 100644 --- a/components/HotspotList.tsx +++ b/components/HotspotList.tsx @@ -1,3 +1,4 @@ +import { usePersonalizedHotspotFilter } from "@/hooks/usePersonalizedHotspotFilter"; import { useLocation } from "@/hooks/useLocation"; import { useScrollRestore } from "@/hooks/useScrollRestore"; import { getAllHotspots, getNearbyHotspots, searchHotspots } from "@/lib/database"; @@ -6,6 +7,7 @@ import { Hotspot } from "@/lib/types"; import { calculateDistance, getBoundingBoxFromLocation } from "@/lib/utils"; import { useFiltersStore } from "@/stores/filtersStore"; import { useLocationPermissionStore } from "@/stores/locationPermissionStore"; +import { useSettingsStore } from "@/stores/settingsStore"; import { FlashList } from "@shopify/flash-list"; import { useQuery } from "@tanstack/react-query"; import debounce from "lodash/debounce"; @@ -16,6 +18,7 @@ import BaseBottomSheet from "./BaseBottomSheet"; import HotspotItem from "./HotspotItem"; import IconButton from "./IconButton"; import IconButtonGroup from "./IconButtonGroup"; +import PersonalizedHotspotFilterControls from "./PersonalizedHotspotFilterControls"; import SearchInput from "./SearchInput"; type HotspotListProps = { @@ -35,7 +38,10 @@ export default function HotspotList({ isOpen, onClose, onSelectHotspot }: Hotspo const { location, isLoading: isLoadingUserLocation } = useLocation(isOpen); const isLoadingLocation = isLoadingPermission || isLoadingUserLocation; const { showSavedOnly, setShowSavedOnly } = useFiltersStore(); - const activeFilterCount = [showSavedOnly].filter(Boolean).length; + const personalizedFilterEnabled = useFiltersStore((state) => state.personalizedFilterEnabled); + const lifelist = useSettingsStore((state) => state.lifelist); + const hasLifeList = (lifelist?.length ?? 0) > 0; + const activeFilterCount = [showSavedOnly, personalizedFilterEnabled && hasLifeList].filter(Boolean).length; const dismissRef = useRef<(() => Promise) | null>(null); const [searchQuery, setSearchQuery] = useState(""); @@ -57,7 +63,11 @@ export default function HotspotList({ isOpen, onClose, onSelectHotspot }: Hotspo const hasLocationAccess = permissionStatus === "granted" && location !== null; - const { data: searchResults = [], dataUpdatedAt: searchUpdatedAt } = useQuery({ + const { + data: searchResults = [], + dataUpdatedAt: searchUpdatedAt, + isFetching: isFetchingSearchResults, + } = useQuery({ queryKey: ["hotspotSearch", debouncedQuery, showSavedOnly], queryFn: () => searchHotspots(debouncedQuery, SEARCH_LIMIT, showSavedOnly), enabled: isOpen && debouncedQuery.length >= 2 && !isLoadingLocation, @@ -65,7 +75,7 @@ export default function HotspotList({ isOpen, onClose, onSelectHotspot }: Hotspo placeholderData: (prev) => prev, }); - const { data: allHotspots = [] } = useQuery({ + const { data: allHotspots = [], isFetching: isFetchingAllHotspots } = useQuery({ queryKey: hasLocationAccess && location ? ["nearbyHotspots", location.lat, location.lng, showSavedOnly] @@ -106,6 +116,21 @@ export default function HotspotList({ isOpen, onClose, onSelectHotspot }: Hotspo } }, [debouncedQuery, searchResults, allHotspots, hasLocationAccess, location]); + const isBaseResultsFetching = debouncedQuery.length >= 2 ? isFetchingSearchResults : isFetchingAllHotspots; + const personalizedFilter = usePersonalizedHotspotFilter(displayedHotspots.map((hotspot) => hotspot.id), { + enabled: !isBaseResultsFetching, + blockWhileDisabled: true, + }); + const isPersonalizedLoading = personalizedFilter.isActive && (isBaseResultsFetching || personalizedFilter.isLoading); + const displayedHotspotsSet = useMemo(() => new Set(personalizedFilter.filteredIds), [personalizedFilter.filteredIds]); + const filteredDisplayedHotspots = useMemo(() => { + if (!personalizedFilter.isActive) { + return displayedHotspots; + } + + return displayedHotspots.filter((hotspot) => displayedHotspotsSet.has(hotspot.id)); + }, [displayedHotspots, displayedHotspotsSet, personalizedFilter.isActive]); + const { listRef, onScroll } = useScrollRestore(isOpen, searchUpdatedAt); const handleSelectHotspot = useCallback( @@ -123,7 +148,12 @@ export default function HotspotList({ isOpen, onClose, onSelectHotspot }: Hotspo const listEmptyComponent = ( - {isLoadingLocation && permissionStatus === "granted" ? ( + {isPersonalizedLoading ? ( + <> + + Filtering hotspots... + + ) : isLoadingLocation && permissionStatus === "granted" ? ( <> Getting current location... @@ -164,9 +194,12 @@ export default function HotspotList({ isOpen, onClose, onSelectHotspot }: Hotspo {isFilterPanelOpen && ( - - Show saved only - + + + Show saved only + + + )} @@ -188,12 +221,14 @@ export default function HotspotList({ isOpen, onClose, onSelectHotspot }: Hotspo > void; }; -const THROTTLE_DELAY = 750; -const THROTTLE_DELAY_WITH_OPEN_HOTSPOT = 250; // Load hotspots faster when jumping to a hotspot from list modal +const THROTTLE_DELAY = 300; +const THROTTLE_DELAY_WITH_OPEN_HOTSPOT = 150; // Load hotspots faster when jumping to a hotspot from list modal 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; @@ -147,6 +168,8 @@ const MapboxMap = forwardRef( const centeredToUserRef = useRef(false); const userCoordRef = useRef<[number, number] | null>(null); const isTouchActiveRef = useRef(false); + const lastPersonalizedMapDebugRef = useRef(null); + const lastResolvedHotspotsRef = useRef<{ id: string; lat: number; lng: number; species: number }[]>([]); const [isMapReady, setIsMapReady] = useState(false); const [bounds, setBounds] = useState(null); @@ -157,7 +180,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,7 +215,10 @@ const MapboxMap = forwardRef( } const throttledSetBounds = useMemo( - () => throttle((b: Bounds | null) => setBounds(b), throttleDelay), + () => + debounce((nextBounds: Bounds | null) => { + setBounds((currentBounds) => (areBoundsEquivalent(currentBounds, nextBounds) ? currentBounds : nextBounds)); + }, throttleDelay), [throttleDelay] ); const debouncedSaveLocation = useMemo( @@ -228,14 +254,25 @@ 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(); + setBounds((currentBounds) => (areBoundsEquivalent(currentBounds, nextBounds) ? currentBounds : nextBounds)); + } else { + throttledSetBounds(nextBounds); + } debouncedSaveLocation(); throttledSetMapCenter(); }, [readBoundsIfZoomed, throttledSetBounds, debouncedSaveLocation, throttledSetMapCenter]); + useEffect(() => { + return () => { + throttledSetBounds.cancel(); + }; + }, [throttledSetBounds]); + const setTouchActive = useCallback( (isActive: boolean) => { if (isTouchActiveRef.current === isActive) return; @@ -285,6 +322,81 @@ 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 personalizedFilter = usePersonalizedHotspotFilter(mapCandidateHotspots.map((hotspot) => hotspot.id), { + enabled: bounds !== null && !isFetchingHotspots, + blockWhileDisabled: true, + }); + const personalizedHotspotIds = useMemo(() => new Set(personalizedFilter.filteredIds), [personalizedFilter.filteredIds]); + const isPersonalizedLoading = personalizedFilter.isActive && ((bounds !== null && isFetchingHotspots) || personalizedFilter.isLoading); + const displayedHotspots = useMemo(() => { + if (showSavedOnly || personalizedFilter.isActive) { + const resolvedHotspots = mapCandidateHotspots.filter((hotspot) => + personalizedFilter.isActive ? personalizedHotspotIds.has(hotspot.id) : true + ); + + if (isPersonalizedLoading) { + return lastResolvedHotspotsRef.current; + } + + return resolvedHotspots; + } + + return hotspots; + }, [ + hotspots, + isPersonalizedLoading, + mapCandidateHotspots, + personalizedFilter.isActive, + personalizedHotspotIds, + showSavedOnly, + ]); + + useEffect(() => { + if (!isPersonalizedLoading) { + lastResolvedHotspotsRef.current = displayedHotspots; + } + }, [displayedHotspots, isPersonalizedLoading]); + + useEffect(() => { + if (!personalizedFilter.isActive) { + return; + } + + const debugState = JSON.stringify({ + hasBounds: bounds !== null, + isFetchingHotspots, + hotspotCount: hotspots.length, + candidateCount: mapCandidateHotspots.length, + displayedCount: displayedHotspots.length, + isPersonalizedLoading, + }); + + if (lastPersonalizedMapDebugRef.current === debugState) { + return; + } + + lastPersonalizedMapDebugRef.current = debugState; + logPersonalizedHotspotFilterDebug("map personalized filter state", { + hasBounds: bounds !== null, + isFetchingHotspots, + hotspotCount: hotspots.length, + candidateCount: mapCandidateHotspots.length, + displayedCount: displayedHotspots.length, + isPersonalizedLoading, + }); + }, [ + displayedHotspots.length, + bounds, + hotspots.length, + isFetchingHotspots, + isPersonalizedLoading, + mapCandidateHotspots.length, + personalizedFilter.isActive, + ]); const handleFeaturePress = useCallback( (event: any) => { @@ -346,10 +458,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 +509,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 +543,10 @@ const MapboxMap = forwardRef( ], }} > - {/* Hotspot - hidden when showSavedOnly filter is active */} + {/* Hotspot */} @@ -446,7 +557,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 +575,6 @@ const MapboxMap = forwardRef( ["==", ["get", "featureType"], "hotspot"], ["==", ["get", "isSaved"], false], ["==", ["get", "isSelected"], true], - ["literal", !showSavedOnly], ]} style={haloOuterStyle() as any} /> @@ -476,7 +585,6 @@ const MapboxMap = forwardRef( ["==", ["get", "featureType"], "hotspot"], ["==", ["get", "isSaved"], false], ["==", ["get", "isSelected"], true], - ["literal", !showSavedOnly], ]} style={hotspotSymbolStyle() as any} /> @@ -591,6 +699,22 @@ const MapboxMap = forwardRef( )} + {isPersonalizedLoading && !isZoomedTooFarOut && ( + + {Platform.OS === "ios" && isLiquidGlassAvailable() ? ( + + + Filtering hotspots... + + + ) : ( + + Filtering hotspots... + + )} + + )} + void; + keyboardType: "decimal-pad" | "number-pad"; + onChangeText: (text: string) => void; +}) { + return ( + + {label} + + + ); +} + +export default function PersonalizedHotspotFilterControls({ + hasLifeList, +}: PersonalizedHotspotFilterControlsProps) { + const personalizedFilterEnabled = useFiltersStore((state) => state.personalizedFilterEnabled); + const setPersonalizedFilterEnabled = useFiltersStore((state) => state.setPersonalizedFilterEnabled); + const neededSpeciesMinCount = useFiltersStore((state) => state.neededSpeciesMinCount); + const setNeededSpeciesMinCount = useFiltersStore((state) => state.setNeededSpeciesMinCount); + const neededSpeciesMinPercent = useFiltersStore((state) => state.neededSpeciesMinPercent); + const setNeededSpeciesMinPercent = useFiltersStore((state) => state.setNeededSpeciesMinPercent); + + const [countText, setCountText] = useState(String(neededSpeciesMinCount)); + const [percentText, setPercentText] = useState(String(neededSpeciesMinPercent)); + + useEffect(() => { + setCountText(String(neededSpeciesMinCount)); + }, [neededSpeciesMinCount]); + + useEffect(() => { + setPercentText(String(neededSpeciesMinPercent)); + }, [neededSpeciesMinPercent]); + + const commitCount = () => { + const parsedValue = Number.parseInt(countText.replace(/[^\d]/g, ""), 10); + const nextValue = Number.isFinite(parsedValue) ? parsedValue : neededSpeciesMinCount; + setNeededSpeciesMinCount(nextValue); + setCountText(String(nextValue)); + }; + + const commitPercent = () => { + const parsedValue = Number.parseFloat(percentText.replace(/[^0-9.]/g, "")); + const nextValue = Number.isFinite(parsedValue) ? parsedValue : neededSpeciesMinPercent; + setNeededSpeciesMinPercent(nextValue); + setPercentText(String(nextValue)); + }; + + return ( + + + + Personalized hotspot filter + + Show only hotspots with at least X needed species above Y%. + + + + + + {!hasLifeList ? ( + Import a life list to enable this filter. + ) : personalizedFilterEnabled ? ( + + setCountText(text.replace(/[^\d]/g, ""))} + onChangeValue={commitCount} + /> + setPercentText(text.replace(/[^0-9.]/g, ""))} + onChangeValue={commitPercent} + /> + + ) : null} + + ); +} diff --git a/hooks/usePersonalizedHotspotFilter.ts b/hooks/usePersonalizedHotspotFilter.ts new file mode 100644 index 0000000..eb841ed --- /dev/null +++ b/hooks/usePersonalizedHotspotFilter.ts @@ -0,0 +1,289 @@ +import { + createPersonalizedHotspotFilterBasis, + logPersonalizedHotspotFilterDebug, + personalizedHotspotCache, + syncPersonalizedHotspotCacheBasis, +} from "@/lib/personalizedHotspotFilter"; +import { useFiltersStore } from "@/stores/filtersStore"; +import { useSettingsStore } from "@/stores/settingsStore"; +import { useEffect, useMemo, useRef, useState } from "react"; + +type UsePersonalizedHotspotFilterOptions = { + enabled?: boolean; + blockWhileDisabled?: boolean; +}; + +type PersonalizedHotspotFilterState = { + filteredIds: string[]; + isActive: boolean; + isLoading: boolean; + hasLifeList: boolean; +}; + +type AsyncPersonalizedHotspotFilterState = { + filteredIds: string[]; + isLoading: boolean; +}; + +function filterResolvedHotspotIds(hotspotIds: string[]): string[] { + return hotspotIds.filter((hotspotId) => personalizedHotspotCache.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 usePersonalizedHotspotFilter( + hotspotIds: string[], + options: UsePersonalizedHotspotFilterOptions = {} +): PersonalizedHotspotFilterState { + const personalizedFilterEnabled = useFiltersStore((state) => state.personalizedFilterEnabled); + const neededSpeciesMinCount = useFiltersStore((state) => state.neededSpeciesMinCount); + const neededSpeciesMinPercent = useFiltersStore((state) => state.neededSpeciesMinPercent); + const lifelist = useSettingsStore((state) => state.lifelist); + const lifelistExclusions = useSettingsStore((state) => state.lifelistExclusions); + const targetMonths = useSettingsStore((state) => state.targetMonths); + + const basis = useMemo( + () => + createPersonalizedHotspotFilterBasis({ + lifelist, + lifelistExclusions, + targetMonths, + neededSpeciesMinCount, + neededSpeciesMinPercent, + }), + [lifelist, lifelistExclusions, targetMonths, neededSpeciesMinCount, neededSpeciesMinPercent] + ); + + const hasLifeList = basis !== null; + const isActive = personalizedFilterEnabled && hasLifeList; + const isEnabled = options.enabled ?? true; + const candidateKey = useMemo(() => hotspotIds.join("|"), [hotspotIds]); + const stableHotspotIdsRef = useRef(hotspotIds); + const basisRef = useRef(basis); + const asyncStateRef = useRef({ filteredIds: [], isLoading: false }); + const lastDebugStatusRef = useRef(null); + const logDebugStatusRef = useRef<(status: string, details?: Record) => void>(() => {}); + + 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(() => { + asyncStateRef.current = asyncState; + }, [asyncState]); + + logDebugStatusRef.current = (status: string, details?: Record) => { + const statusKey = JSON.stringify({ + status, + candidateCount: stableHotspotIds.length, + filteredCount: asyncStateRef.current.filteredIds.length, + isLoading: asyncStateRef.current.isLoading, + isEnabled, + isActive, + hasLifeList, + ...details, + }); + + if (lastDebugStatusRef.current === statusKey) { + return; + } + + lastDebugStatusRef.current = statusKey; + logPersonalizedHotspotFilterDebug(status, { + candidateCount: stableHotspotIds.length, + filteredCount: asyncStateRef.current.filteredIds.length, + isLoading: asyncStateRef.current.isLoading, + isEnabled, + isActive, + hasLifeList, + ...details, + }); + }; + + useEffect(() => { + syncPersonalizedHotspotCacheBasis(basis?.cacheKey ?? null); + }, [basis?.cacheKey]); + + useEffect(() => { + if (!isActive) { + logDebugStatusRef.current("hook inactive"); + return; + } + + if (!isEnabled) { + logDebugStatusRef.current("waiting for prerequisites", { + blockWhileDisabled: options.blockWhileDisabled ?? false, + }); + return; + } + + if (stableHotspotIds.length === 0) { + logDebugStatusRef.current("no candidate hotspots"); + return; + } + + if (!basisRef.current) { + logDebugStatusRef.current("missing filter basis"); + return; + } + + const basisForRun = basisRef.current; + const unresolvedHotspotIds = stableHotspotIds.filter((hotspotId) => !personalizedHotspotCache.has(hotspotId)); + if (unresolvedHotspotIds.length === 0) { + const filteredIds = filterResolvedHotspotIds(stableHotspotIds); + logDebugStatusRef.current("all candidates resolved from cache", { + matchedCount: filteredIds.length, + }); + setAsyncState((currentState) => { + if (!currentState.isLoading && areStringArraysEqual(currentState.filteredIds, filteredIds)) { + return currentState; + } + + return { + filteredIds, + isLoading: false, + }; + }); + return; + } + + const abortController = new AbortController(); + logDebugStatusRef.current("evaluating unresolved hotspots", { + unresolvedCount: unresolvedHotspotIds.length, + cachedCount: stableHotspotIds.length - unresolvedHotspotIds.length, + }); + + setAsyncState((currentState) => ({ + filteredIds: currentState.isLoading ? currentState.filteredIds : [], + isLoading: true, + })); + + void personalizedHotspotCache + .evaluateMany(unresolvedHotspotIds, basisForRun, abortController.signal) + .then(() => { + if (abortController.signal.aborted) { + return; + } + + const stillUnresolved = stableHotspotIds.some((hotspotId) => !personalizedHotspotCache.has(hotspotId)); + if (stillUnresolved) { + logDebugStatusRef.current("evaluation completed but candidates still unresolved"); + setAsyncState((currentState) => ({ + filteredIds: currentState.filteredIds, + isLoading: true, + })); + return; + } + + const filteredIds = filterResolvedHotspotIds(stableHotspotIds); + logDebugStatusRef.current("evaluation resolved", { + matchedCount: filteredIds.length, + unresolvedCount: 0, + }); + setAsyncState((currentState) => { + if (!currentState.isLoading && areStringArraysEqual(currentState.filteredIds, filteredIds)) { + return currentState; + } + + return { + filteredIds, + isLoading: false, + }; + }); + }) + .catch((error) => { + if (abortController.signal.aborted || error?.name === "AbortError") { + logDebugStatusRef.current("evaluation aborted"); + return; + } + + logDebugStatusRef.current("evaluation failed"); + console.error("Failed to evaluate personalized hotspot filter", error); + setAsyncState((currentState) => ({ + filteredIds: currentState.filteredIds, + isLoading: false, + })); + }); + + return () => { + abortController.abort(); + }; + }, [ + candidateKey, + hasLifeList, + isActive, + isEnabled, + options.blockWhileDisabled, + 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..2ba4d96 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; @@ -637,6 +638,34 @@ export type HotspotTargetsResult = { version: string | null; }; +const TARGET_QUERY_BATCH_SIZE = 400; + +export async function getTargetDataForHotspots(hotspotIds: string[]): Promise> { + if (!db) throw new Error("Database not initialized"); + + const uniqueHotspotIds = [...new Set(hotspotIds)]; + const targetData = new Map(); + + for (let index = 0; index < uniqueHotspotIds.length; index += TARGET_QUERY_BATCH_SIZE) { + const batch = uniqueHotspotIds.slice(index, index + TARGET_QUERY_BATCH_SIZE); + if (batch.length === 0) { + continue; + } + + const placeholders = batch.map(() => "?").join(", "); + const rows = await db.getAllAsync<{ id: string; data: string }>( + `SELECT id, data FROM targets WHERE id IN (${placeholders})`, + batch + ); + + for (const row of rows) { + targetData.set(row.id, row.data); + } + } + + return targetData; +} + export async function getTargetsForHotspot(hotspotId: string, months?: number[]): Promise { if (!db) throw new Error("Database not initialized"); @@ -648,40 +677,12 @@ export async function getTargetsForHotspot(hotspotId: string, months?: number[]) if (!result) return null; const row = result as { data: string; version: string | null }; - const data = JSON.parse(row.data) as { - samples: (number | null)[]; - species: (string | number)[][]; - }; - - // Determine which month indices to aggregate (0-11) - const monthIndices = months && months.length > 0 ? months : data.samples.map((_, i) => i); - - const totalSamples = monthIndices.reduce((sum, i) => sum + (data.samples[i] ?? 0), 0); + const data = parseHotspotTargetData(row.data); + const monthIndices = getMonthIndices(data, months); + const totalSamples = getTotalSamplesForMonths(data, monthIndices); if (totalSamples === 0) return { samples: 0, targets: [], version: row.version }; - - // Aggregate observations per species for selected months - const speciesMap = new Map(); - for (const speciesEntry of data.species) { - const speciesCode = String(speciesEntry[0]); - // Species entry layout: [code, janObs, febObs, ..., decObs] — index i+1 for month i - const totalObs = monthIndices.reduce((sum, i) => { - const val = speciesEntry[i + 1]; - return sum + (typeof val === "number" ? val : 0); - }, 0); - if (totalObs > 0) { - speciesMap.set(speciesCode, (speciesMap.get(speciesCode) ?? 0) + totalObs); - } - } - - // Convert to array, calculate percentages, and sort by percentage descending - const targets: HotspotTarget[] = Array.from(speciesMap.entries()) - .map(([speciesCode, observations]) => ({ - speciesCode, - observations, - percentage: (observations / totalSamples) * 100, - })) - .sort((a, b) => b.percentage - a.percentage); + const targets = aggregateHotspotTargets(data, monthIndices, totalSamples); return { samples: totalSamples, targets, version: row.version }; } diff --git a/lib/hotspotTargets.ts b/lib/hotspotTargets.ts new file mode 100644 index 0000000..4e3f9ad --- /dev/null +++ b/lib/hotspotTargets.ts @@ -0,0 +1,54 @@ +export type RawHotspotTargetData = { + samples: (number | null)[]; + species: (string | number)[][]; +}; + +export type AggregatedHotspotTarget = { + speciesCode: string; + observations: number; + percentage: number; +}; + +export function parseHotspotTargetData(rawData: string): RawHotspotTargetData { + return JSON.parse(rawData) as RawHotspotTargetData; +} + +export function getMonthIndices(data: RawHotspotTargetData, months?: number[]): number[] { + return months && months.length > 0 ? months : data.samples.map((_, index) => index); +} + +export function getTotalSamplesForMonths(data: RawHotspotTargetData, monthIndices: number[]): number { + return monthIndices.reduce((sum, monthIndex) => sum + (data.samples[monthIndex] ?? 0), 0); +} + +export function aggregateHotspotTargets( + data: RawHotspotTargetData, + monthIndices: number[], + totalSamples: number +): AggregatedHotspotTarget[] { + if (totalSamples === 0) { + return []; + } + + const speciesMap = new Map(); + + for (const speciesEntry of data.species) { + const speciesCode = String(speciesEntry[0]); + const totalObservations = monthIndices.reduce((sum, monthIndex) => { + const value = speciesEntry[monthIndex + 1]; + return sum + (typeof value === "number" ? value : 0); + }, 0); + + if (totalObservations > 0) { + speciesMap.set(speciesCode, (speciesMap.get(speciesCode) ?? 0) + totalObservations); + } + } + + return Array.from(speciesMap.entries()) + .map(([speciesCode, observations]) => ({ + speciesCode, + observations, + percentage: (observations / totalSamples) * 100, + })) + .sort((a, b) => b.percentage - a.percentage); +} diff --git a/lib/personalizedHotspotFilter.ts b/lib/personalizedHotspotFilter.ts new file mode 100644 index 0000000..d397b7f --- /dev/null +++ b/lib/personalizedHotspotFilter.ts @@ -0,0 +1,287 @@ +import { LifeListEntry } from "@/stores/settingsStore"; +import { getTargetDataForHotspots } from "./database"; +import { getMonthIndices, getTotalSamplesForMonths, parseHotspotTargetData } from "./hotspotTargets"; + +const CACHE_CAPACITY = 5_000; +const COMPUTE_BATCH_SIZE = 50; +const DEBUG_PERSONALIZED_FILTER = __DEV__; + +let evaluationRunCounter = 0; + +export type PersonalizedHotspotFilterBasis = { + cacheKey: string; + lifeListCodes: ReadonlySet; + excludedCodes: ReadonlySet; + selectedMonths: number[]; + neededSpeciesMinCount: number; + neededSpeciesMinPercent: number; +}; + +export function logPersonalizedHotspotFilterDebug(message: string, details?: Record) { + if (!DEBUG_PERSONALIZED_FILTER) { + return; + } + + if (details) { + console.log(`[personalized-hotspot-filter] ${message}`, details); + return; + } + + console.log(`[personalized-hotspot-filter] ${message}`); +} + +export function normalizeNeededSpeciesMinCount(value: number): number { + if (!Number.isFinite(value)) return 1; + return Math.max(1, Math.floor(value)); +} + +export function normalizeNeededSpeciesMinPercent(value: number): number { + if (!Number.isFinite(value)) return 1; + return Math.min(100, Math.max(0, value)); +} + +export function createPersonalizedHotspotFilterBasis(params: { + lifelist: LifeListEntry[] | null; + lifelistExclusions: string[] | null; + targetMonths: number[]; + neededSpeciesMinCount: number; + neededSpeciesMinPercent: number; +}): PersonalizedHotspotFilterBasis | null { + const lifeListCodes = [...new Set((params.lifelist ?? []).map((entry) => entry.code))].sort(); + + if (lifeListCodes.length === 0) { + return null; + } + + const excludedCodes = [...new Set(params.lifelistExclusions ?? [])].sort(); + const selectedMonths = [...new Set(params.targetMonths)].sort((a, b) => a - b); + const neededSpeciesMinCount = normalizeNeededSpeciesMinCount(params.neededSpeciesMinCount); + const neededSpeciesMinPercent = normalizeNeededSpeciesMinPercent(params.neededSpeciesMinPercent); + + return { + cacheKey: JSON.stringify({ + lifeListCodes, + excludedCodes, + selectedMonths, + neededSpeciesMinCount, + neededSpeciesMinPercent, + }), + lifeListCodes: new Set(lifeListCodes), + excludedCodes: new Set(excludedCodes), + selectedMonths, + neededSpeciesMinCount, + neededSpeciesMinPercent, + }; +} + +function createAbortError(): Error { + const error = new Error("Personalized hotspot evaluation aborted"); + error.name = "AbortError"; + return error; +} + +function throwIfAborted(signal?: AbortSignal) { + if (signal?.aborted) { + throw createAbortError(); + } +} + +function matchesPersonalizedHotspotFilter(rawData: string, basis: PersonalizedHotspotFilterBasis): boolean { + const parsed = parseHotspotTargetData(rawData); + const monthIndices = getMonthIndices(parsed, basis.selectedMonths); + const totalSamples = getTotalSamplesForMonths(parsed, monthIndices); + + if (totalSamples === 0) { + return false; + } + + let qualifyingSpeciesCount = 0; + + for (const speciesEntry of parsed.species) { + const speciesCode = String(speciesEntry[0]); + + if (basis.lifeListCodes.has(speciesCode) || basis.excludedCodes.has(speciesCode)) { + continue; + } + + const observations = monthIndices.reduce((sum, monthIndex) => { + const value = speciesEntry[monthIndex + 1]; + return sum + (typeof value === "number" ? value : 0); + }, 0); + + if (observations === 0) { + continue; + } + + const percentage = (observations / totalSamples) * 100; + if (percentage >= basis.neededSpeciesMinPercent) { + qualifyingSpeciesCount += 1; + if (qualifyingSpeciesCount >= basis.neededSpeciesMinCount) { + return true; + } + } + } + + return false; +} + +class PersonalizedHotspotCache { + private cache = new Map(); + private activeSignals = new Set(); + + clear() { + logPersonalizedHotspotFilterDebug("cache clear", { previousSize: this.cache.size }); + this.cache.clear(); + } + + has(hotspotId: string): boolean { + return this.cache.has(hotspotId); + } + + get(hotspotId: string): boolean | undefined { + const value = this.cache.get(hotspotId); + if (value === undefined) { + return undefined; + } + + this.cache.delete(hotspotId); + this.cache.set(hotspotId, value); + return value; + } + + cancelActiveRun() { + if (this.activeSignals.size > 0) { + logPersonalizedHotspotFilterDebug("cancel active runs", { activeRunCount: this.activeSignals.size }); + } + + for (const controller of this.activeSignals) { + controller.abort(); + } + this.activeSignals.clear(); + } + + async evaluateMany( + hotspotIds: string[], + basis: PersonalizedHotspotFilterBasis, + signal?: AbortSignal + ): Promise { + const missingHotspotIds = [...new Set(hotspotIds)].filter((hotspotId) => !this.cache.has(hotspotId)); + if (missingHotspotIds.length === 0) { + return; + } + + const runId = ++evaluationRunCounter; + const controller = new AbortController(); + this.activeSignals.add(controller); + + const combinedSignal = controller.signal; + const isAborted = () => combinedSignal.aborted || signal?.aborted; + + try { + logPersonalizedHotspotFilterDebug("evaluate start", { + runId, + requestedCount: hotspotIds.length, + missingCount: missingHotspotIds.length, + cachedCount: hotspotIds.length - missingHotspotIds.length, + cacheSize: this.cache.size, + monthCount: basis.selectedMonths.length === 0 ? 12 : basis.selectedMonths.length, + neededSpeciesMinCount: basis.neededSpeciesMinCount, + neededSpeciesMinPercent: basis.neededSpeciesMinPercent, + }); + + throwIfAborted(signal); + const targetData = await getTargetDataForHotspots(missingHotspotIds); + throwIfAborted(signal); + + logPersonalizedHotspotFilterDebug("target data fetched", { + runId, + requestedCount: missingHotspotIds.length, + foundCount: targetData.size, + missingDataCount: missingHotspotIds.length - targetData.size, + }); + + for (let index = 0; index < missingHotspotIds.length; index += COMPUTE_BATCH_SIZE) { + if (isAborted()) { + throw createAbortError(); + } + + const batch = missingHotspotIds.slice(index, index + COMPUTE_BATCH_SIZE); + for (const hotspotId of batch) { + if (isAborted()) { + throw createAbortError(); + } + + const rawData = targetData.get(hotspotId); + this.set(hotspotId, rawData ? matchesPersonalizedHotspotFilter(rawData, basis) : false); + } + + const processedCount = Math.min(index + batch.length, missingHotspotIds.length); + if ( + processedCount === missingHotspotIds.length || + (missingHotspotIds.length > 250 && processedCount % 250 === 0) + ) { + logPersonalizedHotspotFilterDebug("evaluate progress", { + runId, + processedCount, + totalCount: missingHotspotIds.length, + cacheSize: this.cache.size, + }); + } + + if (index + COMPUTE_BATCH_SIZE < missingHotspotIds.length) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + logPersonalizedHotspotFilterDebug("evaluate complete", { + runId, + computedCount: missingHotspotIds.length, + cacheSize: this.cache.size, + }); + } catch (error) { + if ((error as Error | undefined)?.name === "AbortError") { + logPersonalizedHotspotFilterDebug("evaluate aborted", { + runId, + processedCandidateCount: missingHotspotIds.length, + cacheSize: this.cache.size, + }); + } + throw error; + } finally { + this.activeSignals.delete(controller); + } + } + + private set(hotspotId: string, value: boolean) { + if (this.cache.has(hotspotId)) { + this.cache.delete(hotspotId); + } + + this.cache.set(hotspotId, value); + + while (this.cache.size > CACHE_CAPACITY) { + const oldestHotspotId = this.cache.keys().next().value; + if (!oldestHotspotId) { + break; + } + this.cache.delete(oldestHotspotId); + } + } +} + +export const personalizedHotspotCache = new PersonalizedHotspotCache(); + +let activeBasisKey: string | null = null; + +export function syncPersonalizedHotspotCacheBasis(nextBasisKey: string | null) { + if (activeBasisKey === nextBasisKey) { + return; + } + + logPersonalizedHotspotFilterDebug("basis changed", { + hadBasis: activeBasisKey !== null, + hasBasis: nextBasisKey !== null, + }); + activeBasisKey = nextBasisKey; + personalizedHotspotCache.cancelActiveRun(); + personalizedHotspotCache.clear(); +} diff --git a/stores/filtersStore.ts b/stores/filtersStore.ts index 3458e52..ac1913c 100644 --- a/stores/filtersStore.ts +++ b/stores/filtersStore.ts @@ -1,13 +1,23 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; +import { + normalizeNeededSpeciesMinCount, + normalizeNeededSpeciesMinPercent, +} from "@/lib/personalizedHotspotFilter"; type FiltersState = { showSavedOnly: boolean; + personalizedFilterEnabled: boolean; + neededSpeciesMinCount: number; + neededSpeciesMinPercent: number; }; type FiltersActions = { setShowSavedOnly: (value: boolean) => void; + setPersonalizedFilterEnabled: (value: boolean) => void; + setNeededSpeciesMinCount: (value: number) => void; + setNeededSpeciesMinPercent: (value: number) => void; resetFilters: () => void; }; @@ -15,8 +25,21 @@ export const useFiltersStore = create()( persist( (set) => ({ showSavedOnly: false, + personalizedFilterEnabled: false, + neededSpeciesMinCount: 1, + neededSpeciesMinPercent: 1, setShowSavedOnly: (value) => set({ showSavedOnly: value }), - resetFilters: () => set({ showSavedOnly: false }), + setPersonalizedFilterEnabled: (value) => set({ personalizedFilterEnabled: value }), + setNeededSpeciesMinCount: (value) => set({ neededSpeciesMinCount: normalizeNeededSpeciesMinCount(value) }), + setNeededSpeciesMinPercent: (value) => + set({ neededSpeciesMinPercent: normalizeNeededSpeciesMinPercent(value) }), + resetFilters: () => + set({ + showSavedOnly: false, + personalizedFilterEnabled: false, + neededSpeciesMinCount: 1, + neededSpeciesMinPercent: 1, + }), }), { name: "filters-storage", From a6c25a7090e24df29ef664ad9f9da5e6dc6359ab Mon Sep 17 00:00:00 2001 From: Adam Jackson Date: Thu, 28 May 2026 18:13:28 -0700 Subject: [PATCH 2/2] Keep existing throttled loading when not filtering --- components/Mapbox.tsx | 58 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/components/Mapbox.tsx b/components/Mapbox.tsx index ab8cec6..9f1e92a 100644 --- a/components/Mapbox.tsx +++ b/components/Mapbox.tsx @@ -7,13 +7,14 @@ import { savedHotspotSymbolStyle, savedPlaceSymbolStyle, } from "@/lib/layers"; -import { logPersonalizedHotspotFilterDebug } from "@/lib/personalizedHotspotFilter"; +import { logPersonalizedHotspotFilterDebug, personalizedHotspotCache } from "@/lib/personalizedHotspotFilter"; 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"; @@ -102,8 +103,9 @@ export type MapboxMapRef = { centerOnCoordinates: (lng: number, lat: number, offsetY?: number) => void; }; -const THROTTLE_DELAY = 300; -const THROTTLE_DELAY_WITH_OPEN_HOTSPOT = 150; // Load hotspots faster when jumping to a hotspot from list modal +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; const MIN_ZOOM = 8; const DEFAULT_USER_ZOOM = 14; const DEFAULT_HOTSPOT_ZOOM = 13; @@ -160,7 +162,8 @@ const MapboxMap = forwardRef( const showAttribution = useMapStore((state) => state.isMapAttributionOpen); const setShowAttribution = useMapStore((state) => state.setIsMapAttributionOpen); const { status: permissionStatus } = useLocationPermissionStore(); - const { showSavedOnly } = useFiltersStore(); + const { showSavedOnly, personalizedFilterEnabled } = useFiltersStore(); + const lifelist = useSettingsStore((state) => state.lifelist); const mapRef = useRef(null); const cameraRef = useRef(null); @@ -173,6 +176,8 @@ const MapboxMap = forwardRef( const [isMapReady, setIsMapReady] = useState(false); const [bounds, setBounds] = useState(null); + const hasLifeList = (lifelist?.length ?? 0) > 0; + const isPersonalizedMapFiltering = personalizedFilterEnabled && hasLifeList; const mapStyle = useMemo(() => { return currentLayer === "satellite" @@ -216,11 +221,18 @@ const MapboxMap = forwardRef( const throttledSetBounds = useMemo( () => - debounce((nextBounds: Bounds | null) => { + 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 () => { @@ -259,19 +271,36 @@ const MapboxMap = forwardRef( const nextBounds = await readBoundsIfZoomed(); if (immediate) { throttledSetBounds.cancel(); + debouncedSetSettledBounds.cancel(); setBounds((currentBounds) => (areBoundsEquivalent(currentBounds, nextBounds) ? currentBounds : nextBounds)); + } else if (isPersonalizedMapFiltering) { + debouncedSetSettledBounds(nextBounds); } else { throttledSetBounds(nextBounds); } debouncedSaveLocation(); throttledSetMapCenter(); - }, [readBoundsIfZoomed, throttledSetBounds, debouncedSaveLocation, throttledSetMapCenter]); + }, [ + debouncedSaveLocation, + debouncedSetSettledBounds, + isPersonalizedMapFiltering, + readBoundsIfZoomed, + throttledSetBounds, + throttledSetMapCenter, + ]); useEffect(() => { return () => { throttledSetBounds.cancel(); + debouncedSetSettledBounds.cancel(); }; - }, [throttledSetBounds]); + }, [debouncedSetSettledBounds, throttledSetBounds]); + + useEffect(() => { + throttledSetBounds.cancel(); + debouncedSetSettledBounds.cancel(); + void syncViewport(true); + }, [debouncedSetSettledBounds, isPersonalizedMapFiltering, syncViewport, throttledSetBounds]); const setTouchActive = useCallback( (isActive: boolean) => { @@ -331,7 +360,17 @@ const MapboxMap = forwardRef( blockWhileDisabled: true, }); const personalizedHotspotIds = useMemo(() => new Set(personalizedFilter.filteredIds), [personalizedFilter.filteredIds]); - const isPersonalizedLoading = personalizedFilter.isActive && ((bounds !== null && isFetchingHotspots) || personalizedFilter.isLoading); + const unresolvedCandidateCount = personalizedFilter.isActive + ? mapCandidateHotspots.reduce((count, hotspot) => count + (personalizedHotspotCache.has(hotspot.id) ? 0 : 1), 0) + : 0; + const isInitialPersonalizedFetch = + personalizedFilter.isActive && + bounds !== null && + isFetchingHotspots && + lastResolvedHotspotsRef.current.length === 0; + const isPersonalizedLoading = + personalizedFilter.isActive && + (isInitialPersonalizedFetch || unresolvedCandidateCount > 0 || personalizedFilter.isLoading); const displayedHotspots = useMemo(() => { if (showSavedOnly || personalizedFilter.isActive) { const resolvedHotspots = mapCandidateHotspots.filter((hotspot) => @@ -371,6 +410,7 @@ const MapboxMap = forwardRef( isFetchingHotspots, hotspotCount: hotspots.length, candidateCount: mapCandidateHotspots.length, + unresolvedCandidateCount, displayedCount: displayedHotspots.length, isPersonalizedLoading, }); @@ -385,6 +425,7 @@ const MapboxMap = forwardRef( isFetchingHotspots, hotspotCount: hotspots.length, candidateCount: mapCandidateHotspots.length, + unresolvedCandidateCount, displayedCount: displayedHotspots.length, isPersonalizedLoading, }); @@ -396,6 +437,7 @@ const MapboxMap = forwardRef( isPersonalizedLoading, mapCandidateHotspots.length, personalizedFilter.isActive, + unresolvedCandidateCount, ]); const handleFeaturePress = useCallback(