diff --git a/app/(tabs)/map.tsx b/app/(tabs)/map.tsx index 12e92d9..8eae1d2 100644 --- a/app/(tabs)/map.tsx +++ b/app/(tabs)/map.tsx @@ -1,29 +1,18 @@ import { Ionicons } from "@expo/vector-icons"; -import * as Haptics from "expo-haptics"; -import { useFocusEffect, useNavigation } from "expo-router"; +import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs"; import { StatusBar } from "expo-status-bar"; -import { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; +import { useEffect, useRef, useState } from "react"; import { ActivityIndicator, - InteractionManager, Pressable, StyleSheet, Text, View, } from "react-native"; -import type { Region } from "react-native-maps"; +import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs"; - -import { type GlobeCameraViewState } from "@/components/map/globe-view"; +import { TAB_BAR_CONTENT_HEIGHT } from "@/components/bottom-tab-bar"; import { MapCanvas, type MapCanvasHandle } from "@/components/map/map-canvas"; import { MapControls } from "@/components/map/map-controls"; import { MapCountryFocusPill } from "@/components/map/map-country-focus-pill"; @@ -35,1683 +24,48 @@ import { MapRandomCountryHint } from "@/components/map/map-random-country-hint"; import { MapRegionChrome } from "@/components/map/map-region-chrome"; import { MapSearchRow } from "@/components/map/map-search-row"; import { MapTopChromeScrim } from "@/components/map/map-top-chrome-scrim"; -import { - regionForClusterFocus, - regionForMapCountry, - WORLD_INITIAL_REGION, -} from "@/constants/map-regions"; import { continentDisplayLabel } from "@/constants/regions"; -import { useContinentIntent } from "@/hooks/use-continent-intent"; -import { useMapFlight } from "@/hooks/use-map-flight"; -import { useMapMarkerReveal } from "@/hooks/use-map-marker-reveal"; -import { - deriveCameraZoomState, - resolveFlatZoomTier, -} from "@/lib/map-camera-zoom"; -import { buildMapClusters, type MapCluster } from "@/lib/map-clusters"; -import { getMapDisplayLatLng, isValidLatLng } from "@/lib/map-country"; -import { parseCountryBoundaryPolygons } from "@/lib/map-country-boundaries"; -import { - installMapDebugErrorHandler, - logMapDebug, - summarizeCountry, - summarizeRegion, -} from "@/lib/map-debug"; -import { buildDiscoveryPhases } from "@/lib/map-discovery-flight"; -import { - findClusterAtWorldCoordinate, - type MapPressCoordinate, -} from "@/lib/map-map-tap-hit"; -import { isCountryPreviewOpen } from "@/lib/map-presentation"; -import { - commitMapPresentation, - dismissMapPreview, - resetMapPresentation, -} from "@/lib/map-presentation-transition"; -import { - buildMapRandomPool, - pickBiasedRandomMapCountry, - pickRandomMapCountry, - resolveMapRandomUseWorldPool, -} from "@/lib/map-random-pick"; -import { syncMapRegionFocusForCountry } from "@/lib/map-region-focus"; -import { - GLOBE_DETAIL_CAMERA_DISTANCE, - GLOBE_REGION_CAMERA_DISTANCE, - REGION_FOCUS_INITIAL_DELTA, - resolveRegionMarkerCountries, -} from "@/lib/map-region-markers"; -import { - isGlobeMapUi, - type MapViewTransition, -} from "@/lib/map-view-transition"; -import { useCountryFeedStore } from "@/store/use-country-feed-store"; -import { useExperienceStore } from "@/store/use-experience-store"; -import { - useIdentityStore, - type SelectionSource, -} from "@/store/use-identity-store"; -import { useMapPresentationStore } from "@/store/use-map-presentation-store"; -import { filterMapCountriesByChip, useMapStore } from "@/store/use-map-store"; -import { useMapUiStore } from "@/store/use-map-ui-store"; -import { useRecentlyViewedStore } from "@/store/use-recently-viewed-store"; -import { useSavedCountriesStore } from "@/store/use-saved-countries-store"; -import type { MapCountry } from "@/types/country"; -import type { MapPresentationMode } from "@/types/map-presentation"; - -const countriesGeoJson = require("@/assets/geo/ne_50m_admin_0_countries/ne_50m_admin_0_countries.json"); - -const TAB_BAR_STYLE = { - position: "absolute" as const, - left: 0, - right: 0, - bottom: 0, - backgroundColor: "transparent", - borderTopWidth: 0, - elevation: 0, - shadowOpacity: 0, -}; - -const REGION_SWITCH_HYSTERESIS_MS = 200; -const EXPLICIT_REGION_RELEASE_DISTANCE_DEGREES = 22; - -/** - * Minimum gap between accepted random-FAB taps. Absorbs the brief async window - * (pool resolution) before `isMapAnimating` flips true, so a double-tap can't - * launch two flights whose animateToRegion calls overlap (iOS maps crash). - */ -const RANDOM_FAB_TAP_COOLDOWN_MS = 650; +import { useMapLogic } from "@/hooks/use-map-logic"; /** Preview card "back to continent" action — off until UX is finalized. */ const PREVIEW_CONTINENT_BACK_ENABLED = false; -/** Selected country must stay in the marker list (Explore → Map can freeze before region sync). */ -function withRequiredMapMarker( - markers: MapCountry[], - countryName: string | null, - pool: MapCountry[], -): MapCountry[] { - if (!countryName) return markers; - if (markers.some((c) => c.name === countryName)) return markers; - - const extra = - pool.find((c) => c.name === countryName) ?? - useIdentityStore.getState().activeCountry; - if (!extra || extra.name !== countryName) return markers; - - return [...markers, extra]; -} +/** Keep chrome hidden until preview exit animation finishes (~spring settle). */ +const PREVIEW_EXIT_MS = 320; export default function MapScreen() { const insets = useSafeAreaInsets(); const tabBarHeight = useBottomTabBarHeight(); - const navigation = useNavigation(); const mapRef = useRef(null); - /** True while a programmatic camera flight is sequencing (controller-driven). */ - const isMapAnimatingRef = useRef(false); - const [isMapAnimating, setIsMapAnimating] = useState(false); - /** Supersedes stale async random/shuffle pool resolutions when taps overlap. */ - const randomPickGenerationRef = useRef(0); - /** Timestamp of the last accepted random-FAB tap — throttles rapid taps. */ - const lastRandomFabTapAtRef = useRef(0); - const shufflePickGenerationRef = useRef(0); - /** Monotonic id — only the latest navigation intent may drive the camera. */ - const navigationIntentIdRef = useRef(0); - const pendingExploreRegionSyncRef = useRef(null); - const exploreHandoffSuppressMarkersRef = useRef(false); - const [exploreHandoffSuppressMarkers, setExploreHandoffSuppressMarkers] = - useState(false); - const [markerRefreshToken, setMarkerRefreshToken] = useState(0); - /** Blocks world-zoom reset while animating into a continent/country focus. */ - const suppressWorldResetRef = useRef(false); - const pendingRegionSwitchTimerRef = useRef | null>(null); - const pendingRegionCandidateRef = useRef(null); - const explicitRegionLockRef = useRef<{ - region: string; - anchor: [number, number]; - } | null>(null); - const pendingGlobeFocusNameRef = useRef(null); - /** Continent to center on the globe after 2D → 3D when no country is selected. */ - const pendingGlobeRegionFocusRef = useRef(null); - /** Country to focus on the 2D map after 3D → 2D crossfade completes. */ - const pendingFlatFocusNameRef = useRef(null); - /** Presentation mode to restore after 3D → 2D crossfade completes. */ - const pendingFlatPresentationModeRef = useRef( - null, - ); - /** Prevents duplicate external-focus camera flights from focus + effect racing. */ - const externalFocusAppliedRef = useRef(null); - /** 2D MapView is interactive — external flights must wait or they no-op silently. */ - const flatMapReadyRef = useRef(false); - const [flatMapReadyToken, setFlatMapReadyToken] = useState(0); - /** Live flat-map zoom (latitudeDelta) — synchronous reads for callbacks. */ - const flatLatitudeDeltaRef = useRef(WORLD_INITIAL_REGION.latitudeDelta); - /** Settles globe focuses (3D camera move isn't tracked by the flat controller). */ - const globeSettleTimerRef = useRef | null>( - null, - ); - /** When true, preview dismiss returns to continent zoom instead of world. */ - const [previewDismissToContinent, setPreviewDismissToContinent] = - useState(false); - const [tapRippleAt, setTapRippleAt] = useState( - null, - ); - const [tapRippleToken, setTapRippleToken] = useState(0); - const [focusTransitionCountryName, setFocusTransitionCountryName] = useState< - string | null - >(null); - const [randomCountryHint, setRandomCountryHint] = useState( - null, - ); - /** - * Country just deselected (X / map tap) while staying in continent mode. The - * focal pin isn't always part of the region's marker subset, so we keep it - * pinned as a normal flag until we leave its region — otherwise it vanishes. - */ - const [lingeringDeselectedName, setLingeringDeselectedName] = useState< - string | null - >(null); - - const status = useMapStore((s) => s.status); - const error = useMapStore((s) => s.error); - const activeCountry = useIdentityStore((s) => s.activeCountry); - const clearActiveCountry = useIdentityStore((s) => s.clearActiveCountry); - const activeChip = useMapStore((s) => s.activeChip); - const countries = useMapStore((s) => s.countries); - const mapMode = useMapStore((s) => s.mapMode); - const loadMapCountries = useMapStore((s) => s.loadMapCountries); - const mapCountriesFullyLoaded = useMapStore((s) => s.mapCountriesFullyLoaded); - const setActiveChip = useMapStore((s) => s.setActiveChip); - const setMapMode = useMapStore((s) => s.setMapMode); - const focusCountryOnGlobe = useMapStore((s) => s.focusCountryOnGlobe); - const focusLatLngOnGlobe = useMapStore((s) => s.focusLatLngOnGlobe); - const clearPendingMapIntent = useMapStore((s) => s.clearPendingMapIntent); - const pendingMapIntent = useMapStore((s) => s.pendingMapIntent); - const globeCamera = useMapStore((s) => s.globeCamera); - - const presentationMode = useMapPresentationStore((s) => s.mode); - const setPresentationMode = useMapPresentationStore((s) => s.setMode); - - const pulsing = useExperienceStore((s) => s.pulsing); - const endExperienceTransition = useExperienceStore((s) => s.endTransition); - const resetExperience = useExperienceStore((s) => s.resetExperience); - - const focusedRegion = useMapUiStore((s) => s.focusedRegion); - const setDisplayMode = useMapUiStore((s) => s.setDisplayMode); - const setFocusedRegion = useMapUiStore((s) => s.setFocusedRegion); - const setFeaturedShortcut = useMapUiStore((s) => s.setFeaturedShortcut); - const resetGlobalPulse = useMapUiStore((s) => s.resetGlobalPulse); - const hasSeenMapOnboarding = useMapUiStore((s) => s.hasSeenMapOnboarding); - const hasSeenRandomCountryHint = useMapUiStore( - (s) => s.hasSeenRandomCountryHint, - ); - const dismissMapOnboarding = useMapUiStore((s) => s.dismissMapOnboarding); - const dismissRandomCountryHint = useMapUiStore( - (s) => s.dismissRandomCountryHint, - ); - const countryMarkerMode = useMapUiStore((s) => s.countryMarkerMode); - - const [mapViewTransition, setMapViewTransition] = - useState("idle"); - const [isPreviewShufflePending, setIsPreviewShufflePending] = useState(false); - const [flatLatitudeDelta, setFlatLatitudeDelta] = useState( - WORLD_INITIAL_REGION.latitudeDelta, - ); - const [globeCameraDistance, setGlobeCameraDistance] = useState( - GLOBE_DETAIL_CAMERA_DISTANCE + 2, - ); - const [globeViewCenter, setGlobeViewCenter] = useState({ - latitude: 0, - longitude: -30, - }); - const [lastMapRegion, setLastMapRegion] = useState(WORLD_INITIAL_REGION); - const is3d = isGlobeMapUi(mapMode, mapViewTransition); - - /** Single source of truth for zoom-driven UI + marker density (live camera). */ - const cameraZoomState = useMemo( - () => - deriveCameraZoomState({ - is3d, - latitudeDelta: flatLatitudeDelta, - globeDistance: globeCameraDistance, - }), - [is3d, flatLatitudeDelta, globeCameraDistance], - ); - const cameraTier = cameraZoomState.tier; - const isDetailZoom = cameraZoomState.isDetailZoom; - - const handleGlobeTransitionComplete = useCallback(() => { - setMapViewTransition("ready"); - }, []); - const featuredShortcut = useMapUiStore((s) => s.featuredShortcut); - - const clusters = useMemo(() => buildMapClusters(countries), [countries]); - - const allBoundaryPolygons = useMemo( - () => parseCountryBoundaryPolygons(countriesGeoJson), - [], - ); - - const activeCountryName = activeCountry?.name ?? null; - const focalMarkerName = - activeCountryName ?? focusTransitionCountryName ?? null; - - const pinCountries = useMemo(() => { - if (exploreHandoffSuppressMarkers) { - if (!activeCountryName) return []; - const pin = countries.find((c) => c.name === activeCountryName) ?? null; - return pin ? [pin] : []; - } - - if (!focusedRegion) { - if (is3d) { - if (!activeCountryName) return []; - const pin = countries.find((c) => c.name === activeCountryName) ?? null; - return pin ? [pin] : []; - } - return []; - } - - const base = countries.filter((c) => c.region === focusedRegion); - const visible = filterMapCountriesByChip(base, activeChip); - - // Density follows the live camera zoom, not the selection or flight phase. - const showAllRegionMarkers = - isDetailZoom || (pulsing && !!activeCountryName); - - return showAllRegionMarkers - ? visible - : resolveRegionMarkerCountries(visible, false, focalMarkerName); - }, [ - activeChip, - activeCountryName, - countries, - exploreHandoffSuppressMarkers, - focalMarkerName, - focusedRegion, - is3d, - isDetailZoom, - pulsing, - ]); - - const markerViewportCenter = useMemo(() => { - if (focalMarkerName && !isDetailZoom) { - const focal = - countries.find((c) => c.name === focalMarkerName) ?? null; - if (focal) { - const [lat, lng] = getMapDisplayLatLng(focal); - if (isValidLatLng([lat, lng])) { - return { latitude: lat, longitude: lng }; - } - } - } - - if (!is3d) { - return { - latitude: lastMapRegion.latitude, - longitude: lastMapRegion.longitude, - }; - } - - const cluster = focusedRegion - ? (clusters.find((c) => c.region === focusedRegion) ?? null) - : null; - - // Stable anchor at continent zoom — globe rotation must not reshuffle markers. - if (cluster && globeCameraDistance > GLOBE_DETAIL_CAMERA_DISTANCE) { - return { - latitude: cluster.center[0], - longitude: cluster.center[1], - }; - } - - return globeViewCenter; - }, [ - clusters, - countries, - focalMarkerName, - focusedRegion, - globeCameraDistance, - globeViewCenter, - is3d, - isDetailZoom, - lastMapRegion.latitude, - lastMapRegion.longitude, - ]); - - const markerReveal = useMapMarkerReveal({ - candidateCountries: pinCountries, - viewportCenter: markerViewportCenter, - focusedRegion, - focalCountryName: focalMarkerName, - isDetailZoom, - enabled: !!focusedRegion, - // Freeze marker mounts during flat flights — marker churn overlapping - // animateToRegion crashes react-native-maps on iOS. - paused: isMapAnimating && !is3d, - }); - - const mapMarkerCountries = useMemo(() => { - const withSelected = withRequiredMapMarker( - markerReveal.countriesToRender, - activeCountryName ?? focusTransitionCountryName, - countries, - ); - // Keep the just-deselected country visible (as a plain flag) until we leave - // its region, so pressing X doesn't make the focal pin disappear. - return withRequiredMapMarker( - withSelected, - lingeringDeselectedName, - countries, - ); - }, [ - activeCountryName, - countries, - focusTransitionCountryName, - lingeringDeselectedName, - markerReveal.countriesToRender, - ]); - - // Density adapts live during flights — no frozen snapshot. - const mapMarkersForCanvas = mapMarkerCountries; - - // Drop the lingering deselected pin once the camera leaves its region - // (world reset or a different continent), so it doesn't stick around. - useEffect(() => { - if (!lingeringDeselectedName) return; - const country = - countries.find((c) => c.name === lingeringDeselectedName) ?? null; - if (!focusedRegion || (country && country.region !== focusedRegion)) { - setLingeringDeselectedName(null); - } - }, [countries, focusedRegion, lingeringDeselectedName]); - - useEffect(() => { - logMapDebug("marker", "canvas marker set", { - count: mapMarkersForCanvas.length, - selected: activeCountryName ?? focusTransitionCountryName, - focusedRegion, - isMapAnimating, - paused: isMapAnimating && !is3d, - revealGeneration: markerReveal.revealGeneration, - }); - }, [ - activeCountryName, - focusTransitionCountryName, - focusedRegion, - is3d, - isMapAnimating, - mapMarkersForCanvas.length, - markerReveal.revealGeneration, - ]); - - const selectedMapName = activeCountryName ?? focusTransitionCountryName; - - const isPreviewOpen = isCountryPreviewOpen(presentationMode, activeCountry); - - useLayoutEffect(() => { - const tabNavigation = navigation.getParent(); - if (!tabNavigation) return; - - tabNavigation.setOptions({ - tabBarStyle: isPreviewOpen ? { display: "none" } : TAB_BAR_STYLE, - }); - }, [isPreviewOpen, navigation]); - - useEffect(() => { - return () => { - navigation.getParent()?.setOptions({ tabBarStyle: TAB_BAR_STYLE }); - }; - }, [navigation]); - - useEffect(() => { - installMapDebugErrorHandler(); - }, []); - - useEffect(() => { - if (mapCountriesFullyLoaded || status === "loading") return; - void loadMapCountries(); - }, [mapCountriesFullyLoaded, status, loadMapCountries]); - - /** Navigate-first external entry: always fetch the full map list even if one country was injected. */ - useEffect(() => { - if (!pendingMapIntent || mapCountriesFullyLoaded || status === "loading") { - return; - } - void loadMapCountries(); - }, [pendingMapIntent, mapCountriesFullyLoaded, status, loadMapCountries]); - - useEffect(() => { - // After 2D → 3D, pan once the globe is interactive and the camera handle exists. - // Keep pending until focus succeeds — clearing early caused a no-op when the - // camera registered a frame after transition became "ready". - if (mapMode !== "3d" || mapViewTransition !== "ready" || !globeCamera) { - return; - } - - const focusName = pendingGlobeFocusNameRef.current; - if (focusName) { - focusCountryOnGlobe(focusName, 900); - pendingGlobeFocusNameRef.current = null; - pendingGlobeRegionFocusRef.current = null; - return; - } - - const focusRegion = pendingGlobeRegionFocusRef.current; - if (!focusRegion) { - return; - } - - const cluster = clusters.find((c) => c.region === focusRegion); - pendingGlobeRegionFocusRef.current = null; - if (!cluster) { - return; - } - - const [lat, lng] = cluster.center; - focusLatLngOnGlobe(lat, lng, 900); - }, [ - clusters, - focusCountryOnGlobe, - focusLatLngOnGlobe, - globeCamera, - mapMode, - mapViewTransition, - ]); - - /** Camera flight active-state — drives pulse end + per-marker snapshot smoothing. */ - const handleFlightActiveChange = useCallback( - (active: boolean) => { - logMapDebug("camera", "flight active change", { - active, - focusTransition: focusTransitionCountryName, - activeCountry: activeCountry?.name ?? null, - }); - isMapAnimatingRef.current = active; - setIsMapAnimating(active); - if (!active) { - setFocusTransitionCountryName(null); - endExperienceTransition(); - setMarkerRefreshToken((token) => token + 1); - - if (exploreHandoffSuppressMarkersRef.current) { - exploreHandoffSuppressMarkersRef.current = false; - setExploreHandoffSuppressMarkers(false); - const pending = pendingExploreRegionSyncRef.current; - pendingExploreRegionSyncRef.current = null; - if (pending) { - syncMapRegionFocusForCountry(pending); - logMapDebug("intent", "explore region sync after flight", { - region: pending.region, - country: pending.name, - }); - } - } - } - }, - [activeCountry?.name, endExperienceTransition, focusTransitionCountryName], - ); - - const flatAnimator = useCallback((region: Region, duration: number) => { - const summary = summarizeRegion(region); - logMapDebug("camera", "flat animateToRegion", { - duration, - region: summary, - hasMapRef: !!mapRef.current, - }); - if (!summary.finite) { - logMapDebug("camera", "WARN non-finite region — may crash MapView", { - region: summary, - }); - } - try { - mapRef.current?.animateToRegion(region, duration); - } catch (err) { - logMapDebug("camera", "ERROR flat animateToRegion threw", { - error: err instanceof Error ? err.message : String(err), - region: summary, - }); - throw err; - } - }, []); - - const flight = useMapFlight({ - animateToRegion: flatAnimator, - onActiveChange: handleFlightActiveChange, - }); - - /** Stops any active flight (flat phases or globe settle) and clears the busy flag. */ - const cancelCameraFlight = useCallback(() => { - logMapDebug("camera", "cancelCameraFlight", { - hadGlobeSettleTimer: !!globeSettleTimerRef.current, - flightWasActive: flight.isActive(), - }); - flight.cancel(); - if (globeSettleTimerRef.current) { - clearTimeout(globeSettleTimerRef.current); - globeSettleTimerRef.current = null; - } - isMapAnimatingRef.current = false; - setIsMapAnimating(false); - }, [flight]); - - const animateMapToRegion = useCallback( - (region: Region, duration = 500) => { - flight.flyTo([{ region, duration }]); - }, - [flight], - ); - - const clearPendingRegionSwitch = useCallback(() => { - if (pendingRegionSwitchTimerRef.current) { - clearTimeout(pendingRegionSwitchTimerRef.current); - pendingRegionSwitchTimerRef.current = null; - } - pendingRegionCandidateRef.current = null; - }, []); - - const lockExplicitRegion = useCallback( - (region: string, anchor: [number, number]) => { - explicitRegionLockRef.current = { region, anchor }; - clearPendingRegionSwitch(); - }, - [clearPendingRegionSwitch], - ); - - const clearExplicitRegionLock = useCallback(() => { - explicitRegionLockRef.current = null; - clearPendingRegionSwitch(); - }, [clearPendingRegionSwitch]); - - const syncRegionFocusForCountry = useCallback( - (pick: MapCountry) => { - syncMapRegionFocusForCountry(pick); - lockExplicitRegion(pick.region, getMapDisplayLatLng(pick)); - suppressWorldResetRef.current = true; - }, - [lockExplicitRegion], - ); - - const clearFocusTransition = useCallback(() => { - setFocusTransitionCountryName(null); - }, []); - - const clearCountrySelection = useCallback(() => { - resetMapPresentation(); - clearFocusTransition(); - clearActiveCountry(); - resetExperience(); - setLingeringDeselectedName(null); - }, [clearActiveCountry, clearFocusTransition, resetExperience]); - - const resolveNearestRegionByLatLng = useCallback( - (centerLat: number, centerLng: number): string | null => { - if (clusters.length === 0) return null; - - let nearest: MapCluster | null = null; - let best = Number.POSITIVE_INFINITY; - - for (const cluster of clusters) { - const dLat = cluster.center[0] - centerLat; - const dLng = cluster.center[1] - centerLng; - const d = dLat * dLat + dLng * dLng; - if (d < best) { - best = d; - nearest = cluster; - } - } - - return nearest?.region ?? null; - }, - [clusters], - ); - - const resolveNearestRegionByCenter = useCallback( - (region: Region): string | null => - resolveNearestRegionByLatLng(region.latitude, region.longitude), - [resolveNearestRegionByLatLng], - ); - - const flightCancelRef = useRef(flight.cancel); - flightCancelRef.current = flight.cancel; - - /** Unmount cleanup only — do not depend on `flight` identity (it changes each render). */ - useEffect(() => { - return () => { - clearPendingRegionSwitch(); - flightCancelRef.current(); - if (globeSettleTimerRef.current) { - clearTimeout(globeSettleTimerRef.current); - globeSettleTimerRef.current = null; - } - isMapAnimatingRef.current = false; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional unmount-only cleanup - }, []); - - const commitClusterFocus = useCallback( - (cluster: MapCluster) => { - clearPendingRegionSwitch(); - clearCountrySelection(); - setFeaturedShortcut(null); - setDisplayMode("explore"); - suppressWorldResetRef.current = true; - setFocusedRegion(cluster.region); - lockExplicitRegion(cluster.region, cluster.center); - - const focusRegion = regionForClusterFocus(cluster); - setLastMapRegion(focusRegion); - - if (is3d) { - const [lat, lng] = cluster.center; - if (Number.isFinite(lat) && Number.isFinite(lng)) { - useMapStore.getState().focusLatLngOnGlobe(lat, lng, 650); - } - return; - } - - animateMapToRegion(focusRegion, 650); - }, - [ - animateMapToRegion, - clearCountrySelection, - clearPendingRegionSwitch, - is3d, - setDisplayMode, - setFocusedRegion, - setFeaturedShortcut, - lockExplicitRegion, - ], - ); - - const { previewRegion, requestContinentFocus, cancelIntent } = - useContinentIntent({ - onCommit: commitClusterFocus, - focusedRegion, - }); - - const resolveCountryFlightDuration = useCallback( - (source: Exclude, useGlobeCamera: boolean) => { - if (source === "explore") { - return useGlobeCamera ? 1400 : 900; - } - const baseDuration = - source === "mapTap" ? (useGlobeCamera ? 450 : 500) : 650; - return useGlobeCamera ? Math.max(baseDuration, 1100) : baseDuration; - }, - [], - ); - - const handleFlatMapReady = useCallback(() => { - if (flatMapReadyRef.current) return; - flatMapReadyRef.current = true; - logMapDebug("camera", "flat map ready"); - setFlatMapReadyToken((token) => token + 1); - }, []); - - /** Single-flight 2D move to a country/continent frame (used post 3D→2D restore). */ - const focusCountryOnFlatMap = useCallback( - ( - pick: MapCountry, - duration = 650, - framing: "continent" | "country" = "country", - ) => { - setDisplayMode("explore"); - suppressWorldResetRef.current = true; - setFocusedRegion(pick.region); - lockExplicitRegion(pick.region, getMapDisplayLatLng(pick)); - - const region = - framing === "continent" - ? regionForMapCountry(pick, REGION_FOCUS_INITIAL_DELTA) - : regionForMapCountry(pick); - flight.flyTo([{ region, duration }]); - }, - [flight, lockExplicitRegion, setDisplayMode, setFocusedRegion], - ); - - /** - * Commits selection + presentation immediately (never waits on the camera), - * then runs the structural three-phase flight. Interruptible/retargetable: - * a new intent supersedes any in-flight sequence. - */ - const applyCountryIntent = useCallback( - ( - pick: MapCountry, - mode: "focus" | "preview", - source: Exclude, - ) => { - navigationIntentIdRef.current += 1; - const intentId = navigationIntentIdRef.current; - logMapDebug("intent", "applyCountryIntent start", { - intentId, - source, - mode, - country: summarizeCountry(pick), - mapMode, - mapViewTransition, - presentationMode, - previousCountry: summarizeCountry(activeCountry), - }); - cancelIntent(); - cancelCameraFlight(); - // A fresh focus supersedes any lingering deselected pin. - setLingeringDeselectedName(null); - // "Back to continent" is offered only when we were already exploring a region. - setPreviewDismissToContinent(!!useMapUiStore.getState().focusedRegion); - setDisplayMode("explore"); - - // Intent commits synchronously — focus pill / preview update right away. - commitMapPresentation({ country: pick, mode, source }); - setFocusTransitionCountryName(pick.name); - void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - - const useGlobeCamera = - mapMode === "3d" && mapViewTransition !== "enteringFlat"; - const deferExploreRegionMarkers = source === "explore" && !useGlobeCamera; - - if (deferExploreRegionMarkers) { - exploreHandoffSuppressMarkersRef.current = true; - setExploreHandoffSuppressMarkers(true); - pendingExploreRegionSyncRef.current = pick; - suppressWorldResetRef.current = true; - lockExplicitRegion(pick.region, getMapDisplayLatLng(pick)); - logMapDebug("intent", "explore handoff — deferring region markers", { - intentId, - region: pick.region, - }); - } else { - syncRegionFocusForCountry(pick); - } - - if (useGlobeCamera) { - const globeDuration = resolveCountryFlightDuration(source, true); - logMapDebug("intent", "globe camera path", { - intentId, - source, - globeDuration, - mapViewTransition, - hasGlobeCamera: !!useMapStore.getState().globeCamera, - }); - // The flat flight controller doesn't drive the globe — track the - // settle window manually so pulse/transition end like a flat flight. - flight.cancel(); - isMapAnimatingRef.current = true; - setIsMapAnimating(true); - if (globeSettleTimerRef.current) { - clearTimeout(globeSettleTimerRef.current); - } - globeSettleTimerRef.current = setTimeout(() => { - globeSettleTimerRef.current = null; - handleFlightActiveChange(false); - }, globeDuration + 300); - - if ( - mapViewTransition !== "ready" || - !useMapStore.getState().globeCamera - ) { - pendingGlobeFocusNameRef.current = pick.name; - logMapDebug("intent", "globe focus deferred", { - intentId, - pendingName: pick.name, - mapViewTransition, - }); - return; - } - if (source === "fab") { - const [lat, lng] = getMapDisplayLatLng(pick); - logMapDebug("camera", "focusContinentOnGlobe (fab)", { - intentId, - name: pick.name, - duration: globeDuration, - }); - if (Number.isFinite(lat) && Number.isFinite(lng)) { - focusLatLngOnGlobe( - lat, - lng, - globeDuration, - GLOBE_REGION_CAMERA_DISTANCE, - ); - } - } else { - logMapDebug("camera", "focusCountryOnGlobe", { - intentId, - name: pick.name, - duration: globeDuration, - }); - focusCountryOnGlobe(pick.name, globeDuration); - } - return; - } - - const cluster = clusters.find((c) => c.region === pick.region) ?? null; - const phases = buildDiscoveryPhases({ - pick, - cluster, - source, - includeWorld: source === "search", - }); - logMapDebug("intent", "flat flight path", { - intentId, - source, - phaseCount: phases.length, - clusterRegion: cluster?.region ?? null, - deferExploreRegionMarkers, - }); - - // Mark animating NOW (synchronously) so the marker reveal pauses on the - // very next render — before the deferred camera move runs. Otherwise the - // region's batched pin reveal keeps mounting and collides with - // animateToRegion, crashing react-native-maps on iOS. - isMapAnimatingRef.current = true; - setIsMapAnimating(true); - - // Defer the camera move until React has committed this intent's marker - // changes (paused reveal + new selection). Starting animateToRegion in the - // same frame as a marker mount crashes iOS maps. - InteractionManager.runAfterInteractions(() => { - requestAnimationFrame(() => { - if (navigationIntentIdRef.current !== intentId) { - logMapDebug("intent", "deferred flight skipped (superseded)", { - intentId, - currentIntentId: navigationIntentIdRef.current, - }); - return; - } - flight.flyTo(phases); - }); - }); - }, - [ - activeCountry, - cancelCameraFlight, - cancelIntent, - clusters, - flight, - focusCountryOnGlobe, - focusLatLngOnGlobe, - handleFlightActiveChange, - lockExplicitRegion, - mapMode, - mapViewTransition, - presentationMode, - resolveCountryFlightDuration, - setDisplayMode, - syncRegionFocusForCountry, - ], - ); - - const focusCountryOnMap = useCallback( - (pick: MapCountry, source: Exclude = "mapTap") => { - applyCountryIntent(pick, "focus", source); - }, - [applyCountryIntent], - ); - - const openCountryPreview = useCallback(() => { - setPresentationMode("preview"); - void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - }, [setPresentationMode]); - - const advanceToCountryPreview = useCallback( - (pick: MapCountry, source: Exclude = "shuffle") => { - applyCountryIntent(pick, "preview", source); - }, - [applyCountryIntent], - ); - - const clearCountryFocus = useCallback(() => { - // Exiting country focus but staying in the continent — keep the pin around - // as a normal flag so it doesn't blink out from under the camera. - const deselectedName = - useIdentityStore.getState().activeCountry?.name ?? null; - cancelCameraFlight(); - resetMapPresentation(); - clearFocusTransition(); - clearActiveCountry(); - resetExperience(); - setLingeringDeselectedName( - deselectedName && useMapUiStore.getState().focusedRegion - ? deselectedName - : null, - ); - }, [ - cancelCameraFlight, - clearActiveCountry, - clearFocusTransition, - resetExperience, - ]); - - const showTapRipple = useCallback((coordinate: MapPressCoordinate) => { - setTapRippleAt(coordinate); - setTapRippleToken((token) => token + 1); - }, []); - - const handleMapModeToggle = useCallback(() => { - cancelIntent(); - if (mapMode === "3d") { - pendingFlatFocusNameRef.current = - activeCountryName ?? focusTransitionCountryName ?? null; - pendingFlatPresentationModeRef.current = presentationMode; - pendingGlobeFocusNameRef.current = null; - pendingGlobeRegionFocusRef.current = null; - setMapMode("2d"); - setMapViewTransition("enteringFlat"); - return; - } - - const countryFocus = - activeCountryName ?? focusTransitionCountryName ?? null; - pendingGlobeFocusNameRef.current = countryFocus; - pendingGlobeRegionFocusRef.current = - countryFocus || !focusedRegion ? null : focusedRegion; - - setMapMode("3d"); - setMapViewTransition("enteringGlobe"); - }, [ - activeCountryName, - cancelIntent, - focusTransitionCountryName, - focusedRegion, - mapMode, - presentationMode, - setMapMode, - ]); - - /** Globe camera updates zoom/detail only — continent exploration stays in UI store until explicit exit. */ - const handleGlobeCameraViewChange = useCallback( - (state: GlobeCameraViewState) => { - // Globe may still mount during crossfade — ignore camera ticks unless 3D is active. - if (mapMode !== "3d" || mapViewTransition !== "ready") { - return; - } - - setGlobeCameraDistance((prev) => { - if (Math.abs(prev - state.distance) < 0.06) return prev; - return state.distance; - }); - - setGlobeViewCenter((prev) => { - const dLat = Math.abs(prev.latitude - state.centerLat); - const dLng = Math.abs(prev.longitude - state.centerLng); - if (dLat < 2 && dLng < 2) return prev; - return { - latitude: state.centerLat, - longitude: state.centerLng, - }; - }); - }, - [mapMode, mapViewTransition], - ); - - /** Live (throttled) flat viewport — feeds the camera zoom signal continuously. */ - const handleFlatRegionChange = useCallback((region: Region) => { - flatLatitudeDeltaRef.current = region.latitudeDelta; - setFlatLatitudeDelta(region.latitudeDelta); - setLastMapRegion(region); - }, []); - const handleRegionChangeComplete = useCallback( - (region: Region) => { - flatLatitudeDeltaRef.current = region.latitudeDelta; - setFlatLatitudeDelta(region.latitudeDelta); - setLastMapRegion(region); - if (is3d) return; - - // During a programmatic flight the camera is mid-sequence — let it land - // before running world-reset / region-switch settle logic. - if (isMapAnimatingRef.current) return; - - const nextTier = resolveFlatZoomTier(region.latitudeDelta); - const currentFocused = useMapUiStore.getState().focusedRegion; - - if (suppressWorldResetRef.current && nextTier !== "world") { - suppressWorldResetRef.current = false; - } - - if (nextTier === "world") { - clearPendingRegionSwitch(); - if (!suppressWorldResetRef.current) { - clearExplicitRegionLock(); - resetGlobalPulse(); - clearCountrySelection(); - } - return; - } - - suppressWorldResetRef.current = false; - setDisplayMode("explore"); - - const nearestRegion = resolveNearestRegionByCenter(region); - if (!nearestRegion) { - clearPendingRegionSwitch(); - return; - } - - const explicitLock = explicitRegionLockRef.current; - if (explicitLock && nearestRegion !== explicitLock.region) { - const dLat = region.latitude - explicitLock.anchor[0]; - const dLng = region.longitude - explicitLock.anchor[1]; - const distance = Math.sqrt(dLat * dLat + dLng * dLng); - if (distance < EXPLICIT_REGION_RELEASE_DISTANCE_DEGREES) { - clearPendingRegionSwitch(); - return; - } - explicitRegionLockRef.current = null; - } - - if (nearestRegion === currentFocused) { - clearPendingRegionSwitch(); - return; - } - - if (pendingRegionCandidateRef.current === nearestRegion) { - return; - } - - clearPendingRegionSwitch(); - pendingRegionCandidateRef.current = nearestRegion; - pendingRegionSwitchTimerRef.current = setTimeout(() => { - if ( - resolveFlatZoomTier(flatLatitudeDeltaRef.current) === "world" || - pendingRegionCandidateRef.current !== nearestRegion - ) { - return; - } - setFocusedRegion(nearestRegion); - pendingRegionCandidateRef.current = null; - pendingRegionSwitchTimerRef.current = null; - }, REGION_SWITCH_HYSTERESIS_MS); - }, - [ - clearCountrySelection, - clearExplicitRegionLock, - clearPendingRegionSwitch, - is3d, - resetGlobalPulse, - resolveNearestRegionByCenter, - setDisplayMode, - setFocusedRegion, - ], - ); - - const handleFlatTransitionComplete = useCallback(() => { - const focusName = pendingFlatFocusNameRef.current; - pendingFlatFocusNameRef.current = null; - - setMapMode("2d"); - setMapViewTransition("idle"); - - if (!focusName || countries.length === 0) { - return; - } - - const pick = countries.find((c) => c.name === focusName) ?? null; - if (!pick) { - return; - } - - const wasActive = - useIdentityStore.getState().activeCountry?.name === focusName; - const restoreMode = pendingFlatPresentationModeRef.current; - pendingFlatPresentationModeRef.current = null; - - if (restoreMode === "preview") { - setPresentationMode("preview"); - } - - focusCountryOnFlatMap( - pick, - 650, - wasActive || restoreMode === "preview" ? "country" : "continent", - ); - }, [countries, focusCountryOnFlatMap, setMapMode, setPresentationMode]); - - const applyPendingExternalMapFocus = useCallback(() => { - const intent = useMapStore.getState().pendingMapIntent; - const mapState = useMapStore.getState(); - if (!intent || countries.length === 0) return; - if (externalFocusAppliedRef.current === intent.countryName) return; - - // Wait for the full map dataset — applying while only an injected country - // is present causes a marker storm mid-flight when the API response lands. - if (!mapState.mapCountriesFullyLoaded) { - logMapDebug("intent", "external focus deferred — countries loading", { - countryName: intent.countryName, - countriesCount: countries.length, - }); - return; - } - - const pick = countries.find((c) => c.name === intent.countryName) ?? null; - if (!pick) { - logMapDebug("intent", "external focus deferred — country not in list", { - countryName: intent.countryName, - }); - return; - } - - const useGlobeCamera = - mapMode === "3d" && mapViewTransition !== "enteringFlat"; - // The camera must be able to fly before we apply, or the move no-ops silently. - if (!useGlobeCamera && !flatMapReadyRef.current) { - logMapDebug("intent", "external focus deferred — flat map not ready", { - countryName: intent.countryName, - }); - return; - } - if ( - useGlobeCamera && - (mapViewTransition !== "ready" || !useMapStore.getState().globeCamera) - ) { - return; - } - - externalFocusAppliedRef.current = intent.countryName; - - setActiveChip("all"); - setFeaturedShortcut(null); - - logMapDebug("intent", "applyPendingExternalMapFocus", { - country: summarizeCountry(pick), - source: intent.source, - mode: intent.mode, - mapCountriesFullyLoaded: mapState.mapCountriesFullyLoaded, - countriesCount: countries.length, - }); - - // Intent is applied synchronously and the flight is interruptible, so the - // cross-screen handoff can be cleared immediately. - applyCountryIntent(pick, intent.mode, intent.source); - clearPendingMapIntent(); - }, [ - applyCountryIntent, - clearPendingMapIntent, - countries, - mapMode, - mapViewTransition, - setActiveChip, - setFeaturedShortcut, - ]); + const map = useMapLogic(mapRef); + const [previewExitHold, setPreviewExitHold] = useState(false); + const wasPreviewOpenRef = useRef(false); + const previewOverlayActive = map.isPreviewOpen || previewExitHold; useEffect(() => { - if (!pendingMapIntent) { - externalFocusAppliedRef.current = null; - } - }, [pendingMapIntent]); - - useFocusEffect( - useCallback(() => { - applyPendingExternalMapFocus(); - }, [applyPendingExternalMapFocus]), - ); - - useEffect(() => { - if (pendingMapIntent) { - applyPendingExternalMapFocus(); - } - }, [ - applyPendingExternalMapFocus, - flatMapReadyToken, - globeCamera, - mapCountriesFullyLoaded, - mapViewTransition, - pendingMapIntent, - ]); - - const handleCountryPress = useCallback( - (country: MapCountry) => { - if (activeCountry?.name === country.name && isPreviewOpen) { - return; - } - if (activeCountry?.name === country.name) { - openCountryPreview(); - return; - } - focusCountryOnMap(country, "mapTap"); - }, - [activeCountry?.name, focusCountryOnMap, isPreviewOpen, openCountryPreview], - ); - - const handleAllPress = useCallback(async () => { - if (countries.length === 0) return; - const generation = ++randomPickGenerationRef.current; - useRecentlyViewedStore.getState().seedIfEmpty(); - const entries = useRecentlyViewedStore.getState().entries; - const topName = entries[0]?.country?.name?.trim() ?? ""; - const direct = countries.find((c) => c.name === topName) ?? null; - - let pick = direct; - if (!pick) { - const feed = useCountryFeedStore.getState(); - if (feed.countries.length === 0 && feed.status === "idle") { - await feed.loadInitialFeed(); - } - const feedTop = feed.countries.slice(0, 3); - pick = - feedTop - .map((fc) => countries.find((c) => c.name === fc.name)) - .find(Boolean) ?? null; - } - - if (!pick) { - pick = countries[0] ?? null; - } - - if (!pick) { + if (map.isPreviewOpen) { + wasPreviewOpenRef.current = true; + setPreviewExitHold(false); return; } - if (generation !== randomPickGenerationRef.current) return; - - setActiveChip("all"); - setFeaturedShortcut("all"); - focusCountryOnMap(pick, "shuffle"); - }, [countries, focusCountryOnMap, setActiveChip, setFeaturedShortcut]); - - const handleTerrainPress = useCallback(async () => { - if (countries.length === 0) return; - const generation = ++randomPickGenerationRef.current; - const feed = useCountryFeedStore.getState(); - if (feed.countries.length === 0 && feed.status === "idle") { - await feed.loadInitialFeed(); - } - - const feedTop = feed.countries.slice(0, 3); - const pick = - feedTop - .map((fc) => countries.find((c) => c.name === fc.name)) - .find(Boolean) ?? countries[0]; - - if (!pick) return; - if (generation !== randomPickGenerationRef.current) return; - - setActiveChip("nature"); - setFeaturedShortcut("terrain"); - focusCountryOnMap(pick, "shuffle"); - }, [countries, focusCountryOnMap, setActiveChip, setFeaturedShortcut]); - - const handleSavedPress = useCallback(async () => { - if (countries.length === 0) return; - const generation = ++randomPickGenerationRef.current; - const saved = useSavedCountriesStore.getState().savedCountries; - const pick = - saved - .map((sc) => countries.find((c) => c.name === sc.name)) - .find(Boolean) ?? null; - - if (!pick) return; - if (generation !== randomPickGenerationRef.current) return; - - setActiveChip("all"); - setFeaturedShortcut("saved"); - focusCountryOnMap(pick, "shuffle"); - }, [countries, focusCountryOnMap, setActiveChip, setFeaturedShortcut]); - - const handleRandomCountry = useCallback(async () => { - // Block rapid taps: a flight in progress (or a tap within the cooldown) - // would cancel-then-immediately-restart the camera, overlapping - // animateToRegion calls and crashing react-native-maps on iOS. - if (isMapAnimatingRef.current) { - logMapDebug("fab", "tap ignored — flight in progress"); - return; - } - const now = Date.now(); - if (now - lastRandomFabTapAtRef.current < RANDOM_FAB_TAP_COOLDOWN_MS) { - logMapDebug("fab", "tap ignored — cooldown", { - sinceLastTapMs: now - lastRandomFabTapAtRef.current, - }); - return; - } - lastRandomFabTapAtRef.current = now; - - if (!mapCountriesFullyLoaded) { - logMapDebug("fab", "awaiting full country list"); - await loadMapCountries(); - } - - const countriesSnapshot = useMapStore.getState().countries; - if (countriesSnapshot.length === 0) { - logMapDebug("fab", "tap ignored — no countries loaded"); - return; - } - - const generation = ++randomPickGenerationRef.current; - logMapDebug("fab", "tap", { - generation, - countriesCount: countriesSnapshot.length, - focusedRegion, - activeChip, - previousCountry: activeCountry?.name ?? null, - isMapAnimating: isMapAnimatingRef.current, - }); - - // The FAB always draws from the GLOBAL pool. A focused continent only biases - // the selection toward itself (it does not hard-scope), so featured shortcuts - // and the regional restriction are intentionally ignored here. - const pool = await buildMapRandomPool({ - countries: countriesSnapshot, - activeChip, - featuredShortcut: null, - focusedRegion: null, - useWorldPool: true, - }); - if (generation !== randomPickGenerationRef.current) { - logMapDebug("fab", "stale generation after pool", { - generation, - current: randomPickGenerationRef.current, - }); - return; - } - - const excludeName = activeCountry?.name ?? null; - const pick = pickBiasedRandomMapCountry({ - pool, - region: focusedRegion, - excludeName, - }); - if (!pick) { - logMapDebug("fab", "no pick available", { - generation, - poolSize: pool.length, - }); - return; - } - - const pickLatLng = getMapDisplayLatLng(pick); - logMapDebug("fab", "pick", { - generation, - country: summarizeCountry(pick), - coordsValid: isValidLatLng(pickLatLng), - poolSize: pool.length, - focusedRegion, - biasedToContinent: !!focusedRegion && pick.region === focusedRegion, - isRepeat: pick.name === excludeName, - excludeName, - }); - - const showHint = !hasSeenRandomCountryHint; - focusCountryOnMap(pick, "fab"); - if (showHint) { - setRandomCountryHint(pick); - dismissRandomCountryHint(); - } - }, [ - activeChip, - activeCountry?.name, - dismissRandomCountryHint, - focusCountryOnMap, - focusedRegion, - hasSeenRandomCountryHint, - loadMapCountries, - mapCountriesFullyLoaded, - ]); - - const handleNextCountry = useCallback(async () => { - if (!activeCountry || countries.length === 0) return; - - const generation = ++shufflePickGenerationRef.current; - setIsPreviewShufflePending(true); - try { - const pool = await buildMapRandomPool({ - countries, - activeChip, - featuredShortcut, - focusedRegion, - useWorldPool: resolveMapRandomUseWorldPool({ - focusedRegion, - mapMode, - flatLatitudeDelta: flatLatitudeDeltaRef.current, - contextualOnly: true, - }), - }); - if (generation !== shufflePickGenerationRef.current) return; - - const pick = - pickRandomMapCountry(pool, activeCountry.name) ?? - pickRandomMapCountry(countries, activeCountry.name); - if (!pick) return; - - advanceToCountryPreview(pick); - } finally { - if (generation === shufflePickGenerationRef.current) { - setIsPreviewShufflePending(false); - } - } - }, [ - activeChip, - activeCountry, - advanceToCountryPreview, - countries, - featuredShortcut, - focusedRegion, - mapMode, - ]); - - const flyToWorldView = useCallback(() => { - cancelIntent(); - suppressWorldResetRef.current = false; - clearExplicitRegionLock(); - resetGlobalPulse(); - clearCountrySelection(); - if (is3d) { - cancelCameraFlight(); - mapRef.current?.resetWorldView(); - } else { - flight.flyTo([{ region: WORLD_INITIAL_REGION, duration: 600 }]); - } - }, [ - cancelCameraFlight, - cancelIntent, - clearCountrySelection, - clearExplicitRegionLock, - flight, - is3d, - resetGlobalPulse, - ]); - - const handleReset = useCallback(() => { - flyToWorldView(); - setActiveChip("all"); - }, [flyToWorldView, setActiveChip]); - - const handleBackToWorld = useCallback(() => { - flyToWorldView(); - }, [flyToWorldView]); - - const recenterOnFocusedContinent = useCallback( - (cluster: MapCluster) => { - const flightDuration = 650; - - suppressWorldResetRef.current = true; - clearPendingRegionSwitch(); - lockExplicitRegion(cluster.region, cluster.center); - - const focusRegion = regionForClusterFocus(cluster); - setLastMapRegion(focusRegion); - - if (is3d) { - const [lat, lng] = cluster.center; - if (Number.isFinite(lat) && Number.isFinite(lng)) { - focusLatLngOnGlobe(lat, lng, flightDuration); - } - return; - } - - flight.flyTo([{ region: focusRegion, duration: flightDuration }]); - }, - [ - clearPendingRegionSwitch, - flight, - focusLatLngOnGlobe, - is3d, - lockExplicitRegion, - ], - ); - - const handleBackToContinent = useCallback(() => { - if (!focusedRegion) return; - - const cluster = clusters.find((c) => c.region === focusedRegion); - if (!cluster) return; - - recenterOnFocusedContinent(cluster); - }, [clusters, focusedRegion, recenterOnFocusedContinent]); - - const zoomOutToContinentView = useCallback( - (region: string, duration = 650) => { - const cluster = clusters.find((c) => c.region === region); - if (!cluster) return; - - cancelIntent(); - setFocusedRegion(region); - suppressWorldResetRef.current = true; - clearPendingRegionSwitch(); - lockExplicitRegion(cluster.region, cluster.center); - - const focusRegion = regionForClusterFocus(cluster); - setLastMapRegion(focusRegion); - - if (is3d) { - const [lat, lng] = cluster.center; - if (Number.isFinite(lat) && Number.isFinite(lng)) { - focusLatLngOnGlobe(lat, lng, duration, GLOBE_REGION_CAMERA_DISTANCE); - } - return; - } - - flight.flyTo([{ region: focusRegion, duration }]); - }, - [ - cancelIntent, - clearPendingRegionSwitch, - clusters, - flight, - focusLatLngOnGlobe, - is3d, - lockExplicitRegion, - setFocusedRegion, - ], - ); - - /** Close preview sheet and keep the country focused (camera already there). */ - const dismissCountryPreview = useCallback(() => { - cancelIntent(); - dismissMapPreview(); - setPreviewDismissToContinent(false); - clearFocusTransition(); - }, [cancelIntent, clearFocusTransition]); - - /** Preview "back to continent" — clears country selection and zooms to region. */ - const exitCountryPreviewToContinent = useCallback(() => { - cancelIntent(); - const region = useMapUiStore.getState().focusedRegion; - - resetMapPresentation(); - setPreviewDismissToContinent(false); - clearFocusTransition(); - clearActiveCountry(); - resetExperience(); - - if (region) { - zoomOutToContinentView(region, 650); - } - }, [ - cancelIntent, - clearActiveCountry, - clearFocusTransition, - resetExperience, - zoomOutToContinentView, - ]); - - const handleMapPress = useCallback( - (coordinate?: MapPressCoordinate) => { - if (presentationMode === "preview") { - dismissCountryPreview(); - return; - } - - if (activeCountry) { - if (coordinate) { - showTapRipple(coordinate); - } - clearCountryFocus(); - return; - } - - if (coordinate) { - const cluster = findClusterAtWorldCoordinate( - allBoundaryPolygons, - countries, - clusters, - coordinate, - ); - - if (focusedRegion) { - if (cluster?.region === focusedRegion) { - showTapRipple(coordinate); - void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - recenterOnFocusedContinent(cluster); - } else if (cluster) { - requestContinentFocus(cluster); - } else { - showTapRipple(coordinate); - } - return; - } - - if (is3d || cameraTier === "world") { - if (cluster) { - requestContinentFocus(cluster); - return; - } - showTapRipple(coordinate); - return; - } - } - }, - [ - activeCountry, - allBoundaryPolygons, - cameraTier, - clearCountryFocus, - clusters, - countries, - dismissCountryPreview, - focusedRegion, - is3d, - presentationMode, - recenterOnFocusedContinent, - requestContinentFocus, - showTapRipple, - ], - ); - - const showRegionChrome = !!focusedRegion && !activeCountry && !isPreviewOpen; - const showCountryFocusPill = !!activeCountry && !isPreviewOpen; - const showFlagToggle = mapMarkerCountries.length > 0; + if (!wasPreviewOpenRef.current) return; + wasPreviewOpenRef.current = false; + setPreviewExitHold(true); + const timer = setTimeout(() => setPreviewExitHold(false), PREVIEW_EXIT_MS); + return () => clearTimeout(timer); + }, [map.isPreviewOpen]); const regionChromeBottom = Math.max(insets.bottom, 16); - const countryChromeAboveTabGap = 4; const countryFocusPillBottom = 34; const countryChromeHeight = 44; const countryChromeGap = 24; - const mapFabClearance = showRegionChrome ? 68 : 56; - const mapFabBottom = showCountryFocusPill + const mapFabClearance = map.showRegionChrome ? 68 : 56; + const mapFabBottom = map.showCountryFocusPill ? countryFocusPillBottom + countryChromeHeight + countryChromeGap : regionChromeBottom + mapFabClearance; const randomHintBottom = mapFabBottom + 72; - const previewBottomOffset = 0; - const previewSheetBottomInset = insets.bottom; const onboardingBottom = Math.max(insets.bottom, 16) + 88; - const showOnboarding = - !hasSeenMapOnboarding && - (is3d || cameraTier === "world") && - status !== "loading" && - countries.length > 0 && - activeCountry === null && - focusTransitionCountryName === null; return ( @@ -1719,40 +73,49 @@ export default function MapScreen() { - {isPreviewOpen ? ( - + {map.isPreviewOpen ? ( + ) : null} - {!isPreviewOpen ? ( + {!previewOverlayActive ? ( <> - {is3d || cameraTier === "world" || !focusedRegion ? ( + {map.shouldShowFeaturedChips ? ( void handleAllPress()} - onTerrainPress={() => void handleTerrainPress()} - onSavedPress={() => void handleSavedPress()} + onAllPress={() => void map.handleAllPress()} + onTerrainPress={() => void map.handleTerrainPress()} + onSavedPress={() => void map.handleSavedPress()} /> ) : ( @@ -1774,22 +137,22 @@ export default function MapScreen() { ) : null} - {status === "loading" ? ( + {map.status === "loading" ? ( ) : null} - {status === "error" ? ( + {map.status === "error" ? ( - {error ?? "Could not load map data"} + {map.error ?? "Could not load map data"} void loadMapCountries()} + onPress={() => void map.loadMapCountries({ force: true })} style={({ pressed }) => [ styles.retryButton, pressed && styles.pressed, @@ -1802,81 +165,81 @@ export default function MapScreen() { ) : null} - {isPreviewOpen && activeCountry ? ( - + {map.isPreviewOpen && map.activeCountry ? ( + void handleNextCountry()} - isNextCountryLoading={isPreviewShufflePending} + onNextCountry={() => void map.handleNextCountry()} + isNextCountryLoading={map.isPreviewShufflePending} /> - ) : showOnboarding ? ( + ) : map.showOnboarding ? ( - dismissMapOnboarding()} /> + map.dismissMapOnboarding()} /> ) : null} - {showRegionChrome && focusedRegion ? ( + {map.showRegionChrome && map.focusedRegion ? ( ) : null} - {showCountryFocusPill && activeCountry ? ( + {map.showCountryFocusPill && + map.activeCountry && + !previewOverlayActive ? ( ) : null} - {randomCountryHint ? ( + {map.randomCountryHint ? ( setRandomCountryHint(null)} + onDismiss={() => map.setRandomCountryHint(null)} /> ) : null} - {!isPreviewOpen ? ( - <> - mapRef.current?.zoomBy("in")} - onZoomOut={() => mapRef.current?.zoomBy("out")} - showFlagToggle={showFlagToggle} - showBoundaryControls - bottom={mapFabBottom} - keepCollapsed={showCountryFocusPill} - onRandomCountryPress={() => void handleRandomCountry()} - randomDeemphasized={showCountryFocusPill} - randomDisabled={isMapAnimating} - /> - + {!previewOverlayActive ? ( + mapRef.current?.zoomBy("in")} + onZoomOut={() => mapRef.current?.zoomBy("out")} + showFlagToggle={map.showFlagToggle} + showBoundaryControls + bottom={mapFabBottom} + keepCollapsed={map.showCountryFocusPill} + onRandomCountryPress={() => void map.handleRandomCountry()} + randomDeemphasized={map.showCountryFocusPill} + randomDisabled={map.isMapAnimating} + /> ) : null} @@ -1920,6 +283,8 @@ const styles = StyleSheet.create({ position: "absolute", left: 0, right: 0, + bottom: 0, + overflow: "hidden", }, pressed: { opacity: 0.85, diff --git a/app/dev.tsx b/app/dev.tsx index e3fb066..21500a4 100644 --- a/app/dev.tsx +++ b/app/dev.tsx @@ -3,6 +3,7 @@ import { useEffect } from "react"; import { ScrollView, Text, TouchableOpacity, View } from "react-native"; import { images } from "@/constants/images"; +import { clearAllClientCache } from "@/lib/client-cache"; import { useCountryFeedStore, useSavedCountriesStore } from "@/store"; function DevButton({ @@ -21,7 +22,9 @@ function DevButton({ className="rounded-lg bg-ocean-blue px-3 py-2" style={{ opacity: disabled ? 0.5 : 1 }} > - {label} + + {label} + ); } @@ -107,6 +110,10 @@ export default function DevScreen() { /> + void clearAllClientCache()} + /> diff --git a/backend/src/lib/app-region.ts b/backend/src/lib/app-region.ts index 1161db1..f04129e 100644 --- a/backend/src/lib/app-region.ts +++ b/backend/src/lib/app-region.ts @@ -43,3 +43,13 @@ export function normalizeAppRegion( return NORTH_AMERICA; } + +/** Whether a country belongs to an app continent tab (handles legacy `Americas`). */ +export function countryMatchesExploreRegion( + country: { name: string; region: string }, + targetRegion: string, +): boolean { + return ( + normalizeAppRegion(country.region, undefined, country.name) === targetRegion + ); +} diff --git a/backend/src/services/search.service.ts b/backend/src/services/search.service.ts index 550429e..5b8a14c 100644 --- a/backend/src/services/search.service.ts +++ b/backend/src/services/search.service.ts @@ -1,12 +1,9 @@ -import type { Country, CountryBasic } from "../types/country.js"; +import { countryMatchesExploreRegion } from "../lib/app-region.js"; import { HttpError } from "../lib/http.js"; +import type { Country, CountryBasic } from "../types/country.js"; import { enrichCountryWithAi } from "./ai.service.js"; +import { CACHE_TTL, cacheKeys, getOrSet } from "./cache.service.js"; import { getFeedCountries } from "./country.service.js"; -import { - CACHE_TTL, - cacheKeys, - getOrSet, -} from "./cache.service.js"; import { enrichCountryWithImages } from "./image.service.js"; export type SearchResponse = { @@ -31,17 +28,12 @@ function filterCountries( let matches = countries; if (region) { - const regionLower = region.toLowerCase(); - matches = matches.filter( - (c) => c.region.toLowerCase() === regionLower, - ); + matches = matches.filter((c) => countryMatchesExploreRegion(c, region)); } if (query) { const queryLower = query.toLowerCase(); - matches = matches.filter((c) => - c.name.toLowerCase().includes(queryLower), - ); + matches = matches.filter((c) => c.name.toLowerCase().includes(queryLower)); } return matches.sort((a, b) => a.name.localeCompare(b.name)); diff --git a/components/bottom-tab-bar.tsx b/components/bottom-tab-bar.tsx index 788577e..776d6a7 100644 --- a/components/bottom-tab-bar.tsx +++ b/components/bottom-tab-bar.tsx @@ -12,6 +12,9 @@ const TAB_BAR_BG = "#0b132b"; const TAB_ACTIVE = "#fbbf24"; const TAB_INACTIVE = "#94a3b8"; +/** Bar chrome only — add safe-area bottom inset for full tab bar height. */ +export const TAB_BAR_CONTENT_HEIGHT = 62; + type IoniconsName = keyof typeof Ionicons.glyphMap; type TabItem = { diff --git a/components/map/globe-boundary-hit-targets.tsx b/components/map/globe-boundary-hit-targets.tsx new file mode 100644 index 0000000..a07246f --- /dev/null +++ b/components/map/globe-boundary-hit-targets.tsx @@ -0,0 +1,199 @@ +import type { ThreeEvent } from "@react-three/fiber/native"; +import { useCallback, useEffect, useMemo } from "react"; +import * as THREE from "three"; + +import type { MapZoomTier } from "@/components/map/world-map-view"; +import { buildGlobeBoundaryHitTargets } from "@/lib/globe-boundary-fills"; +import { logGlobeTap } from "@/lib/globe-tap-debug"; +import { + countryNamesMatch, + filterBoundaryPolygonsByMapContext, + getCountryBoundaryPolygons, +} from "@/lib/map-country-boundaries"; +import { areRegionBoundariesTappable } from "@/lib/map-signal-sources"; +import { useMapUiStore } from "@/store/use-map-ui-store"; +import type { MapCountry } from "@/types/country"; + +const countriesGeoJson = require("@/assets/geo/ne_50m_admin_0_countries/ne_50m_admin_0_countries.json"); + +type GlobeBoundaryHitTargetsProps = { + boundaryCountries: MapCountry[]; + selectedName: string | null; + focusedRegion: string | null; + boundaryFocusRegion?: string | null; + zoomTier: MapZoomTier; + onBoundaryCountryPress: (country: MapCountry) => void; + /** Skip taps that exceeded the orbit drag threshold. */ + consumeTapThresholdExceeded: () => boolean; + /** Reset drag guard when R3F sees a new pointer down. */ + beginPointerTap: () => void; +}; + +function BoundaryHitMesh({ + geometry, + onPress, + beginPointerTap, +}: { + geometry: THREE.BufferGeometry; + onPress: () => void; + beginPointerTap: () => void; +}) { + useEffect(() => { + return () => geometry.dispose(); + }, [geometry]); + + const handlePointerDown = useCallback(() => { + beginPointerTap(); + }, [beginPointerTap]); + + const handlePress = useCallback( + (event: ThreeEvent) => { + event.stopPropagation(); + onPress(); + }, + [onPress], + ); + + return ( + + + + ); +} + +export function GlobeBoundaryHitTargets({ + boundaryCountries, + selectedName, + focusedRegion, + boundaryFocusRegion = focusedRegion, + zoomTier, + onBoundaryCountryPress, + consumeTapThresholdExceeded, + beginPointerTap, +}: GlobeBoundaryHitTargetsProps) { + const showBoundaryLines = useMapUiStore((s) => s.showBoundaryLines); + const boundaryStyle = useMapUiStore((s) => s.boundaryStyle); + + const highlightCountryName = selectedName; + const showCountryHighlight = + !!highlightCountryName && boundaryStyle.countryHighlightEnabled; + const boundariesTappable = areRegionBoundariesTappable( + boundaryFocusRegion, + zoomTier, + { allowWorldZoomGlobe: true }, + ); + const showBoundaryStrokes = + boundaryStyle.strokeColorEnabled && showBoundaryLines; + const showWorldBoundaries = + showBoundaryLines && !boundaryFocusRegion && !highlightCountryName; + + const countryBoundaries = useMemo(() => { + const all = getCountryBoundaryPolygons(countriesGeoJson); + return filterBoundaryPolygonsByMapContext(all, { + selectedCountryName: highlightCountryName, + focusedRegion: boundaryFocusRegion, + countries: boundaryCountries, + showWorldBoundaries, + }); + }, [ + boundaryCountries, + boundaryFocusRegion, + highlightCountryName, + showWorldBoundaries, + ]); + + const hitTargets = useMemo( + () => buildGlobeBoundaryHitTargets(countryBoundaries), + [countryBoundaries], + ); + + const handleBoundaryPress = useCallback( + (countryName: string | null) => { + if (consumeTapThresholdExceeded()) { + logGlobeTap({ + source: "boundary-mesh", + stage: "skip", + outcome: "ignored-drag-threshold", + country: countryName, + }); + return; + } + if (!countryName) { + logGlobeTap({ + source: "boundary-mesh", + stage: "skip", + outcome: "missing-country-name", + }); + return; + } + + const country = + boundaryCountries.find((entry) => + countryNamesMatch(entry.name, countryName), + ) ?? null; + if (country) { + logGlobeTap({ + source: "boundary-mesh", + stage: "input", + outcome: "boundary-mesh-hit", + country: country.name, + region: country.region, + focusedRegion, + boundaryFocusRegion, + cameraTier: zoomTier, + }); + onBoundaryCountryPress(country); + return; + } + + logGlobeTap({ + source: "boundary-mesh", + stage: "skip", + outcome: "country-not-in-list", + country: countryName, + focusedRegion, + boundaryFocusRegion, + cameraTier: zoomTier, + }); + }, + [ + boundaryCountries, + boundaryFocusRegion, + consumeTapThresholdExceeded, + focusedRegion, + onBoundaryCountryPress, + zoomTier, + ], + ); + + if ( + !boundariesTappable || + !showBoundaryStrokes || + showCountryHighlight || + hitTargets.length === 0 + ) { + return null; + } + + return ( + + {hitTargets.map((target) => ( + handleBoundaryPress(target.countryName)} + beginPointerTap={beginPointerTap} + /> + ))} + + ); +} diff --git a/components/map/globe-boundary-lines.tsx b/components/map/globe-boundary-lines.tsx index 1e34fc3..137c448 100644 --- a/components/map/globe-boundary-lines.tsx +++ b/components/map/globe-boundary-lines.tsx @@ -12,7 +12,7 @@ import { } from "@/lib/globe-boundary-lines"; import { filterBoundaryPolygonsByMapContext, - parseCountryBoundaryPolygons, + getCountryBoundaryPolygons, } from "@/lib/map-country-boundaries"; import { useMapUiStore } from "@/store/use-map-ui-store"; import type { MapCountry } from "@/types/country"; @@ -22,18 +22,15 @@ const countriesGeoJson = require("@/assets/geo/ne_50m_admin_0_countries/ne_50m_a type GlobeBoundaryLinesProps = { boundaryCountries: MapCountry[]; selectedName: string | null; + focusTransitionName?: string | null; + /** Intent — which polygons to load (continent filter). */ focusedRegion: string | null; + /** Effective region for boundary strokes (may infer from view center). */ + boundaryFocusRegion?: string | null; + /** Live globe camera tier — stroke width/color scaling. */ + zoomTier: MapZoomTier; }; -function resolveGlobeZoomTier( - selectedName: string | null, - focusedRegion: string | null, -): MapZoomTier { - if (selectedName) return "country"; - if (focusedRegion) return "region"; - return "world"; -} - function BoundaryLineSegment({ geometry, color, @@ -63,25 +60,40 @@ function BoundaryLineSegment({ export function GlobeBoundaryLines({ boundaryCountries, selectedName, + focusTransitionName = null, focusedRegion, + boundaryFocusRegion = focusedRegion, + zoomTier, }: GlobeBoundaryLinesProps) { const showBoundaryLines = useMapUiStore((s) => s.showBoundaryLines); const boundaryStyle = useMapUiStore((s) => s.boundaryStyle); - const zoomTier = resolveGlobeZoomTier(selectedName, focusedRegion); + const highlightCountryName = selectedName; + const showCountryHighlight = + !!highlightCountryName && boundaryStyle.countryHighlightEnabled; + const showBoundaryStrokes = + boundaryStyle.strokeColorEnabled && showBoundaryLines; const showWorldBoundaries = - showBoundaryLines && !focusedRegion && !selectedName; + showBoundaryLines && !boundaryFocusRegion && !highlightCountryName; const countryBoundaries = useMemo(() => { - const all = parseCountryBoundaryPolygons(countriesGeoJson); + if (!showBoundaryStrokes || showCountryHighlight) return []; + const all = getCountryBoundaryPolygons(countriesGeoJson); return filterBoundaryPolygonsByMapContext(all, { - selectedCountryName: selectedName, - focusedRegion, + selectedCountryName: null, + focusedRegion: boundaryFocusRegion, countries: boundaryCountries, showWorldBoundaries, }); - }, [boundaryCountries, focusedRegion, selectedName, showWorldBoundaries]); + }, [ + boundaryCountries, + boundaryFocusRegion, + highlightCountryName, + showBoundaryStrokes, + showCountryHighlight, + showWorldBoundaries, + ]); const strokeColor = resolveBoundaryStrokeColor(boundaryStyle, zoomTier); const styleKey = boundaryStyleRenderKey(boundaryStyle, zoomTier); @@ -96,7 +108,11 @@ export function GlobeBoundaryLines({ [strokeColor], ); - if (!showBoundaryLines || lineSegments.length === 0) { + if ( + !showBoundaryStrokes || + showCountryHighlight || + lineSegments.length === 0 + ) { return null; } diff --git a/components/map/globe-cluster-overlay.tsx b/components/map/globe-cluster-overlay.tsx index 513e361..6247ba1 100644 --- a/components/map/globe-cluster-overlay.tsx +++ b/components/map/globe-cluster-overlay.tsx @@ -44,7 +44,7 @@ function PulsingClusterBubble({ -1, false, ); - }, [pulse]); + }, []); const bubbleStyle = useAnimatedStyle(() => { const scale = selected ? 1.12 : pulse.value; diff --git a/components/map/globe-continent-focus-layers.tsx b/components/map/globe-continent-focus-layers.tsx new file mode 100644 index 0000000..9e73409 --- /dev/null +++ b/components/map/globe-continent-focus-layers.tsx @@ -0,0 +1,268 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Easing, + runOnJS, + useAnimatedReaction, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import * as THREE from "three"; + +import { resolveContinentFocusFillRgba } from "@/constants/map-boundary-style"; +import { + MAP_CONTINENT_FOCUS_FADE_MS, + continentPreviewFillOpacityFactor, +} from "@/constants/map-continent-focus"; +import { buildGlobeBoundaryFills } from "@/lib/globe-boundary-fills"; +import { parseCssColorToThree } from "@/lib/globe-boundary-lines"; +import { + filterBoundaryPolygonsByMapContext, + getCountryBoundaryPolygons, + type CountryBoundaryPolygon, +} from "@/lib/map-country-boundaries"; +import { useMapUiStore } from "@/store/use-map-ui-store"; +import type { MapCountry } from "@/types/country"; + +const countriesGeoJson = require("@/assets/geo/ne_50m_admin_0_countries/ne_50m_admin_0_countries.json"); + +/** Limit opacity updates during fades — per-frame setState can overload the GL thread. */ +const BLEND_REACTION_STEPS = 8; + +function isRenderablePolygon(polygon: CountryBoundaryPolygon): boolean { + if (polygon.coordinates.length < 3) return false; + + return polygon.coordinates.every( + (point) => + Number.isFinite(point.latitude) && + Number.isFinite(point.longitude) && + Math.abs(point.latitude) <= 90, + ); +} + +function GlobeContinentFocusFillMesh({ + geometry, + color, + opacity, +}: { + geometry: THREE.BufferGeometry; + color: THREE.Color; + opacity: number; +}) { + useEffect(() => { + return () => geometry.dispose(); + }, [geometry]); + + if (opacity <= 0.001) return null; + + return ( + + + + ); +} + +type GlobeContinentFocusLayersProps = { + focusedRegion: string | null; + selectedCountryName?: string | null; + previewRegion?: string | null; + boundaryCountries: MapCountry[]; +}; + +export function GlobeContinentFocusLayers({ + focusedRegion, + selectedCountryName = null, + previewRegion = null, + boundaryCountries, +}: GlobeContinentFocusLayersProps) { + const boundaryStyle = useMapUiStore((s) => s.boundaryStyle); + + const blend = useSharedValue(0); + const previewBlend = useSharedValue(0); + const lastBlendStep = useSharedValue(-1); + const lastPreviewBlendStep = useSharedValue(-1); + const [displayRegion, setDisplayRegion] = useState(null); + const [displayPreviewRegion, setDisplayPreviewRegion] = useState< + string | null + >(null); + const [renderBlend, setRenderBlend] = useState(0); + const [renderPreviewBlend, setRenderPreviewBlend] = useState(0); + + const allCountryBoundaries = getCountryBoundaryPolygons(countriesGeoJson); + + useEffect(() => { + if (selectedCountryName) { + blend.value = withTiming(0, { + duration: MAP_CONTINENT_FOCUS_FADE_MS, + easing: Easing.inOut(Easing.ease), + }); + return; + } + + if (focusedRegion) { + setDisplayRegion(focusedRegion); + blend.value = withTiming(1, { + duration: MAP_CONTINENT_FOCUS_FADE_MS, + easing: Easing.inOut(Easing.ease), + }); + return; + } + + blend.value = withTiming( + 0, + { + duration: MAP_CONTINENT_FOCUS_FADE_MS, + easing: Easing.inOut(Easing.ease), + }, + (finished) => { + if (finished) { + runOnJS(setDisplayRegion)(null); + } + }, + ); + }, [blend, focusedRegion, selectedCountryName]); + + useEffect(() => { + if (previewRegion && !focusedRegion && !selectedCountryName) { + setDisplayPreviewRegion(previewRegion); + previewBlend.value = withTiming(1, { + duration: MAP_CONTINENT_FOCUS_FADE_MS * 0.6, + easing: Easing.out(Easing.ease), + }); + return; + } + + if (previewRegion && (focusedRegion || selectedCountryName)) { + setDisplayPreviewRegion(null); + previewBlend.value = 0; + return; + } + + previewBlend.value = withTiming( + 0, + { + duration: MAP_CONTINENT_FOCUS_FADE_MS * 0.5, + easing: Easing.inOut(Easing.ease), + }, + (finished) => { + if (finished) { + runOnJS(setDisplayPreviewRegion)(null); + } + }, + ); + }, [focusedRegion, previewBlend, previewRegion, selectedCountryName]); + + useAnimatedReaction( + () => blend.value, + (value) => { + const step = + Math.round(value * BLEND_REACTION_STEPS) / BLEND_REACTION_STEPS; + if (step === lastBlendStep.value) return; + lastBlendStep.value = step; + runOnJS(setRenderBlend)(step); + }, + [blend, lastBlendStep], + ); + + useAnimatedReaction( + () => previewBlend.value, + (value) => { + const step = + Math.round(value * BLEND_REACTION_STEPS) / BLEND_REACTION_STEPS; + if (step === lastPreviewBlendStep.value) return; + lastPreviewBlendStep.value = step; + runOnJS(setRenderPreviewBlend)(step); + }, + [lastPreviewBlendStep, previewBlend], + ); + + const continentPolygons = useMemo(() => { + if (!displayRegion) return []; + return filterBoundaryPolygonsByMapContext(allCountryBoundaries, { + selectedCountryName: null, + focusedRegion: displayRegion, + countries: boundaryCountries, + }).filter(isRenderablePolygon); + }, [allCountryBoundaries, boundaryCountries, displayRegion]); + + const previewPolygons = useMemo(() => { + if (!displayPreviewRegion) return []; + return filterBoundaryPolygonsByMapContext(allCountryBoundaries, { + selectedCountryName: null, + focusedRegion: displayPreviewRegion, + countries: boundaryCountries, + }).filter(isRenderablePolygon); + }, [allCountryBoundaries, boundaryCountries, displayPreviewRegion]); + + const committedFill = useMemo( + () => + parseCssColorToThree( + resolveContinentFocusFillRgba(boundaryStyle, renderBlend), + ), + [boundaryStyle, renderBlend], + ); + + const previewFill = useMemo( + () => + parseCssColorToThree( + resolveContinentFocusFillRgba( + boundaryStyle, + renderPreviewBlend * continentPreviewFillOpacityFactor(), + ), + ), + [boundaryStyle, renderPreviewBlend], + ); + + const committedMeshes = useMemo( + () => buildGlobeBoundaryFills(continentPolygons), + [continentPolygons], + ); + + const previewMeshes = useMemo( + () => buildGlobeBoundaryFills(previewPolygons), + [previewPolygons], + ); + + const showCommitted = + displayRegion && renderBlend > 0.001 && !selectedCountryName; + const showPreview = + displayPreviewRegion && + renderPreviewBlend > 0.001 && + !focusedRegion && + !selectedCountryName; + + if (!boundaryStyle.fillEnabled || (!showCommitted && !showPreview)) { + return null; + } + + return ( + + {showPreview + ? previewMeshes.map((mesh) => ( + + )) + : null} + {showCommitted + ? committedMeshes.map((mesh) => ( + + )) + : null} + + ); +} diff --git a/components/map/globe-country-focus-layers.tsx b/components/map/globe-country-focus-layers.tsx new file mode 100644 index 0000000..fae4588 --- /dev/null +++ b/components/map/globe-country-focus-layers.tsx @@ -0,0 +1,235 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Easing, + cancelAnimation, + runOnJS, + useAnimatedReaction, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import * as THREE from "three"; + +import { + MAP_COUNTRY_FOCUS_FADE_MS, + resolveGlobeCountryFocusFillRgba, + resolveGlobeCountryFocusStrokeRgba, +} from "@/constants/map-country-focus"; +import { buildGlobeBoundaryFills } from "@/lib/globe-boundary-fills"; +import { + buildGlobeBoundaryLines, + parseCssColorToThree, +} from "@/lib/globe-boundary-lines"; +import { + filterBoundaryPolygonsByMapContext, + getCountryBoundaryPolygons, + type CountryBoundaryPolygon, +} from "@/lib/map-country-boundaries"; +import { resolveCountryFocusRenderPolygons } from "@/lib/map-country-focus-polygons"; +import { useMapUiStore } from "@/store/use-map-ui-store"; + +const countriesGeoJson = require("@/assets/geo/ne_50m_admin_0_countries/ne_50m_admin_0_countries.json"); + +/** Limit opacity updates during fades — per-frame setState can overload the GL thread. */ +const BLEND_REACTION_STEPS = 8; + +const COUNTRY_FOCUS_SLOT_COUNT = 24; + +function isRenderablePolygon(polygon: CountryBoundaryPolygon): boolean { + if (polygon.coordinates.length < 3) return false; + + return polygon.coordinates.every( + (point) => + Number.isFinite(point.latitude) && + Number.isFinite(point.longitude) && + Math.abs(point.latitude) <= 90, + ); +} + +function GlobeCountryFocusSlotMesh({ + geometry, + color, + opacity, +}: { + geometry: THREE.BufferGeometry | null; + color: THREE.Color; + opacity: number; +}) { + useEffect(() => { + return () => { + geometry?.dispose(); + }; + }, [geometry]); + + if (!geometry || opacity <= 0.001) return null; + + return ( + + + + ); +} + +function GlobeCountryFocusStrokeLine({ + geometry, + color, + opacity, +}: { + geometry: THREE.BufferGeometry; + color: THREE.Color; + opacity: number; +}) { + useEffect(() => { + return () => geometry.dispose(); + }, [geometry]); + + if (opacity <= 0.001) return null; + + return ( + + + + ); +} + +type GlobeCountryFocusLayersProps = { + selectedCountryName: string | null; + focusTransitionName?: string | null; + fillGapsWhenContinentOverlay?: boolean; +}; + +export function GlobeCountryFocusLayers({ + selectedCountryName, + focusTransitionName: _focusTransitionName = null, + fillGapsWhenContinentOverlay = false, +}: GlobeCountryFocusLayersProps) { + const boundaryStyle = useMapUiStore((s) => s.boundaryStyle); + const highlightName = selectedCountryName; + const blend = useSharedValue(highlightName ? 1 : 0); + const lastBlendStep = useSharedValue(-1); + const [renderBlend, setRenderBlend] = useState(highlightName ? 1 : 0); + + const allCountryBoundaries = getCountryBoundaryPolygons(countriesGeoJson); + + useEffect(() => { + cancelAnimation(blend); + + if (highlightName) { + blend.value = withTiming(1, { + duration: MAP_COUNTRY_FOCUS_FADE_MS, + easing: Easing.inOut(Easing.ease), + }); + return; + } + + blend.value = withTiming(0, { + duration: MAP_COUNTRY_FOCUS_FADE_MS, + easing: Easing.inOut(Easing.ease), + }); + }, [blend, highlightName]); + + useAnimatedReaction( + () => blend.value, + (value) => { + const step = + Math.round(value * BLEND_REACTION_STEPS) / BLEND_REACTION_STEPS; + if (step === lastBlendStep.value) return; + lastBlendStep.value = step; + runOnJS(setRenderBlend)(step); + }, + [blend, lastBlendStep], + ); + + const countryPolygons = useMemo(() => { + if (!highlightName || renderBlend <= 0.001) return []; + const filtered = filterBoundaryPolygonsByMapContext(allCountryBoundaries, { + selectedCountryName: highlightName, + focusedRegion: null, + countries: [], + }) + .filter(isRenderablePolygon) + .slice(0, COUNTRY_FOCUS_SLOT_COUNT); + + return resolveCountryFocusRenderPolygons( + filtered, + fillGapsWhenContinentOverlay, + ); + }, [ + allCountryBoundaries, + fillGapsWhenContinentOverlay, + highlightName, + renderBlend, + ]); + + const fill = useMemo( + () => + parseCssColorToThree( + resolveGlobeCountryFocusFillRgba(boundaryStyle, renderBlend), + ), + [boundaryStyle, renderBlend], + ); + + const fillMeshes = useMemo( + () => + buildGlobeBoundaryFills(countryPolygons, undefined, { + omitHoles: fillGapsWhenContinentOverlay, + }), + [countryPolygons, fillGapsWhenContinentOverlay], + ); + + const stroke = useMemo( + () => + parseCssColorToThree( + resolveGlobeCountryFocusStrokeRgba(boundaryStyle, renderBlend), + ), + [boundaryStyle, renderBlend], + ); + + const strokeLines = useMemo( + () => buildGlobeBoundaryLines(countryPolygons), + [countryPolygons], + ); + + if (!boundaryStyle.countryHighlightEnabled) { + return null; + } + + const isVisible = + !!highlightName && renderBlend > 0.001 && countryPolygons.length > 0; + + if (!isVisible) { + return null; + } + + return ( + + {fillMeshes.map((mesh) => ( + + ))} + {strokeLines.map((segment) => ( + + ))} + + ); +} diff --git a/components/map/globe-country-pin.tsx b/components/map/globe-country-pin.tsx index 8ef5b60..c2700a2 100644 --- a/components/map/globe-country-pin.tsx +++ b/components/map/globe-country-pin.tsx @@ -13,7 +13,7 @@ import type { MapCountry } from "@/types/country"; const PIN_GEOMETRY_RADIUS = 0.018; const BASE_SCALE = 1; -const SELECTED_SCALE = 1.62; +const SELECTED_SCALE = 1.28; const BASE_EMISSIVE = 0.42; const SELECTED_EMISSIVE = 0.95; const FOCUS_TRANSITION_EMISSIVE = 0.68; @@ -29,6 +29,10 @@ type GlobeCountryPinProps = { isFocusTransitioning?: boolean; isDeemphasized?: boolean; onPress: (country: MapCountry) => void; + /** Skip taps that exceeded the orbit drag threshold. */ + consumeTapThresholdExceeded: () => boolean; + /** Reset drag guard when R3F sees a new pointer down. */ + beginPointerTap: () => void; }; export function GlobeCountryPin({ @@ -38,10 +42,11 @@ export function GlobeCountryPin({ isFocusTransitioning = false, isDeemphasized = false, onPress, + consumeTapThresholdExceeded, + beginPointerTap, }: GlobeCountryPinProps) { const { camera } = useThree(); const meshRef = useRef(null); - const ringRef = useRef(null); const transitionElapsedRef = useRef(0); const transitionActiveRef = useRef(false); @@ -86,10 +91,6 @@ export function GlobeCountryPin({ material.emissiveIntensity = SELECTED_EMISSIVE; material.opacity = 1; material.transparent = false; - - if (ringRef.current) { - ringRef.current.scale.setScalar(1.08); - } return; } @@ -107,7 +108,12 @@ export function GlobeCountryPin({ material.transparent = isDeemphasized || fadeT > 0.02; }); + const handlePointerDown = () => { + beginPointerTap(); + }; + const handlePress = (event: ThreeEvent) => { + if (consumeTapThresholdExceeded()) return; event.stopPropagation(); onPress(country); }; @@ -124,7 +130,8 @@ export function GlobeCountryPin({ 0.02)} opacity={ - isSelected - ? 1 - : isDeemphasized - ? 0.34 - : 1 - initialFadeT * 0.22 + isSelected ? 1 : isDeemphasized ? 0.34 : 1 - initialFadeT * 0.22 } /> - - {isSelected ? ( - - - - - ) : null} ); } diff --git a/components/map/globe-label-projector.tsx b/components/map/globe-label-projector.tsx index f8251a6..9aaf9a4 100644 --- a/components/map/globe-label-projector.tsx +++ b/components/map/globe-label-projector.tsx @@ -1,5 +1,6 @@ import { useFrame, useThree } from "@react-three/fiber/native"; -import { useRef } from "react"; +import { useRef, type RefObject } from "react"; +import * as THREE from "three"; import type { GlobeLabel, GlobeLabelScreenPosition } from "@/lib/globe-labels"; import { projectLatLngToScreen } from "@/lib/globe-screen-project"; @@ -7,6 +8,7 @@ import { projectLatLngToScreen } from "@/lib/globe-screen-project"; type GlobeLabelProjectorProps = { labels: GlobeLabel[]; onPositions: (positions: GlobeLabelScreenPosition[]) => void; + globeQuaternionRef: RefObject; }; /** @@ -16,6 +18,7 @@ type GlobeLabelProjectorProps = { export function GlobeLabelProjector({ labels, onPositions, + globeQuaternionRef, }: GlobeLabelProjectorProps) { const { camera } = useThree(); const onPositionsRef = useRef(onPositions); @@ -37,6 +40,8 @@ export function GlobeLabelProjector({ label.lng, camera, state.size, + undefined, + globeQuaternionRef.current ?? undefined, ); return { diff --git a/components/map/globe-pin-projector.tsx b/components/map/globe-pin-projector.tsx index 40a080c..79ea251 100644 --- a/components/map/globe-pin-projector.tsx +++ b/components/map/globe-pin-projector.tsx @@ -1,8 +1,9 @@ import { useFrame, useThree } from "@react-three/fiber/native"; -import { useRef } from "react"; +import { useRef, type RefObject } from "react"; +import * as THREE from "three"; -import { getMapDisplayLatLng, isValidLatLng } from "@/lib/map-country"; import { projectLatLngToScreen } from "@/lib/globe-screen-project"; +import { getMapDisplayLatLng, isValidLatLng } from "@/lib/map-country"; import type { MapCountry } from "@/types/country"; export type GlobePinScreenPosition = { @@ -15,6 +16,7 @@ export type GlobePinScreenPosition = { type GlobePinProjectorProps = { countries: MapCountry[]; onPositions: (positions: GlobePinScreenPosition[]) => void; + globeQuaternionRef: RefObject; }; export function globePinPositionsChanged( @@ -43,6 +45,7 @@ export function globePinPositionsChanged( export function GlobePinProjector({ countries, onPositions, + globeQuaternionRef, }: GlobePinProjectorProps) { const { camera } = useThree(); const onPositionsRef = useRef(onPositions); @@ -70,6 +73,8 @@ export function GlobePinProjector({ lng, camera, state.size, + undefined, + globeQuaternionRef.current ?? undefined, ); next.push({ diff --git a/components/map/globe-view.tsx b/components/map/globe-view.tsx index cae77a5..56b5e9a 100644 --- a/components/map/globe-view.tsx +++ b/components/map/globe-view.tsx @@ -1,8 +1,8 @@ import { Canvas, - type ThreeEvent, useFrame, useThree, + type ThreeEvent, } from "@react-three/fiber/native"; import { forwardRef, @@ -12,11 +12,15 @@ import { useMemo, useRef, useState, + type RefObject, } from "react"; import { StyleSheet, View } from "react-native"; import * as THREE from "three"; +import { GlobeBoundaryHitTargets } from "@/components/map/globe-boundary-hit-targets"; import { GlobeBoundaryLines } from "@/components/map/globe-boundary-lines"; +import { GlobeContinentFocusLayers } from "@/components/map/globe-continent-focus-layers"; +import { GlobeCountryFocusLayers } from "@/components/map/globe-country-focus-layers"; import { GlobeCountryPin } from "@/components/map/globe-country-pin"; import { GlobeLabelOverlay } from "@/components/map/globe-label-overlay"; import { GlobeLabelProjector } from "@/components/map/globe-label-projector"; @@ -24,25 +28,35 @@ import { GlobePinProjector, type GlobePinScreenPosition, } from "@/components/map/globe-pin-projector"; -import { - createGlobeOrbitControls, - type GlobeOrbitControls, -} from "@/lib/globe-orbit-controls"; import { buildGlobeVisibleLabels, globeLabelPositionsChanged, type GlobeLabelScreenPosition, } from "@/lib/globe-labels"; +import { + createGlobeOrbitControls, + type GlobeOrbitControls, +} from "@/lib/globe-orbit-controls"; +import { + GLOBE_CAMERA_VIEW_DIRECTION, + globeQuaternionDeltaForCameraOrbit, + latLngFromWorldNormal, + quaternionForLatLngFacingCamera, + viewCenterLatLngFromGlobeQuaternion, +} from "@/lib/globe-rotation"; import { projectLatLngToScreen, type GlobeScreenPosition, } from "@/lib/globe-screen-project"; -import { latLngToVector3, vector3ToLatLng } from "@/lib/latlng-to-sphere"; +import { logGlobeTap } from "@/lib/globe-tap-debug"; +import { latLngToVector3 } from "@/lib/latlng-to-sphere"; import { useGlobeTexture } from "@/lib/load-globe-texture"; import type { MapCluster } from "@/lib/map-clusters"; import { getMapDisplayLatLng, isValidLatLng } from "@/lib/map-country"; +import { shouldFillCountryHighlightGaps } from "@/lib/map-country-focus-polygons"; import type { MapPressCoordinate } from "@/lib/map-map-tap-hit"; import { + GLOBE_WORLD_CAMERA_DISTANCE, resolveGlobeZoomTier, type GlobeZoomTier, } from "@/lib/map-region-markers"; @@ -69,9 +83,9 @@ globalWithThree.THREE = globalWithThree.THREE ?? THREE; const GLOBE_RADIUS = 1; const PIN_RADIUS = GLOBE_RADIUS * 1.02; const MIN_CAMERA_DISTANCE = 1.4; -const MAX_CAMERA_DISTANCE = 4; -/** World view — slightly closer than before so the globe fills more of the stage. */ -const DEFAULT_CAMERA_DISTANCE = 3.88; +const MAX_CAMERA_DISTANCE = 5; +/** World view — matches `GLOBE_WORLD_CAMERA_DISTANCE` / map controller framing. */ +const DEFAULT_CAMERA_DISTANCE = GLOBE_WORLD_CAMERA_DISTANCE; /** Pull target below equator so the sphere sits in the map “stage” between chrome. */ const GLOBE_VIEW_TARGET_Y = -0.09; @@ -82,9 +96,9 @@ const INITIAL_CAMERA_POSITION = latLngToVector3( DEFAULT_CAMERA_DISTANCE, ); -type CameraFlight = { - fromDir: THREE.Vector3; - toDir: THREE.Vector3; +type GlobeRotationFlight = { + fromQuat: THREE.Quaternion; + toQuat: THREE.Quaternion; fromDistance: number; toDistance: number; elapsed: number; @@ -96,23 +110,10 @@ function easeInOutCubic(t: number): number { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; } -const SLERP_REFERENCE = new THREE.Vector3(0, 0, 1); -const slerpScratchDir = new THREE.Vector3(); -const slerpScratchQuatA = new THREE.Quaternion(); -const slerpScratchQuatB = new THREE.Quaternion(); -const slerpScratchQuat = new THREE.Quaternion(); - -function slerpUnitVectors( - from: THREE.Vector3, - to: THREE.Vector3, - alpha: number, - target: THREE.Vector3, -): THREE.Vector3 { - slerpScratchQuatA.setFromUnitVectors(SLERP_REFERENCE, from); - slerpScratchQuatB.setFromUnitVectors(SLERP_REFERENCE, to); - slerpScratchQuat.slerpQuaternions(slerpScratchQuatA, slerpScratchQuatB, alpha); - return target.copy(SLERP_REFERENCE).applyQuaternion(slerpScratchQuat); -} +const flightScratchQuat = new THREE.Quaternion(); +const orbitPrevDir = new THREE.Vector3(); +const orbitNextDir = new THREE.Vector3(); +const orbitDeltaQuat = new THREE.Quaternion(); /** Fires once after the GL canvas renders its first frame. */ function GlobePaintNotifier({ onPainted }: { onPainted: () => void }) { @@ -135,15 +136,26 @@ type LatLngProjector = ( function GlobeCoordinateProjector({ layoutSize, onReady, + globeQuaternionRef, }: { layoutSize: { width: number; height: number }; onReady: (project: LatLngProjector) => void; + globeQuaternionRef: RefObject; }) { const { camera } = useThree(); useEffect(() => { - onReady((lat, lng) => projectLatLngToScreen(lat, lng, camera, layoutSize)); - }, [camera, layoutSize, onReady]); + onReady((lat, lng) => + projectLatLngToScreen( + lat, + lng, + camera, + layoutSize, + undefined, + globeQuaternionRef.current ?? undefined, + ), + ); + }, [camera, globeQuaternionRef, layoutSize, onReady]); return null; } @@ -155,8 +167,13 @@ type GlobeSceneProps = { selectedName: string | null; focusTransitionName: string | null; focusedRegion: string | null; + /** Effective region for boundary outlines + tap targets (may infer from view center). */ + boundaryFocusRegion?: string | null; + zoomTier: GlobeZoomTier; + previewRegion?: string | null; showGlobePins: boolean; onCountryPress: (country: MapCountry) => void; + onBoundaryCountryPress: (country: MapCountry) => void; controls: GlobeOrbitControls; onReady: (handle: GlobeCameraHandle) => void; onCanvasPainted?: () => void; @@ -166,6 +183,8 @@ type GlobeSceneProps = { layoutSize: { width: number; height: number }; onProjectorReady: (project: LatLngProjector) => void; lockUserGestures: boolean; + /** Seeds globe distance from the map controller (2D latitudeDelta sync). */ + initialCameraDistance?: number; }; function GlobeScene({ @@ -175,8 +194,12 @@ function GlobeScene({ selectedName, focusTransitionName, focusedRegion, + boundaryFocusRegion = focusedRegion, + zoomTier, + previewRegion = null, showGlobePins, onCountryPress, + onBoundaryCountryPress, controls, onReady, onCanvasPainted, @@ -186,11 +209,17 @@ function GlobeScene({ layoutSize, onProjectorReady, lockUserGestures, + initialCameraDistance = DEFAULT_CAMERA_DISTANCE, }: GlobeSceneProps) { const texture = useGlobeTexture(); const { camera } = useThree(); + const globeGroupRef = useRef(null); + const globeQuaternionRef = useRef(new THREE.Quaternion()); const continentSinglePinActive = !!focusedRegion && (!!selectedName || !!focusTransitionName); + const fillCountryHighlightGaps = + !selectedName && + shouldFillCountryHighlightGaps(focusedRegion, previewRegion); const handlePinPress = useCallback( (country: MapCountry) => { @@ -199,37 +228,73 @@ function GlobeScene({ [onCountryPress], ); + const handleGlobeSurfacePointerDown = useCallback(() => { + controls.functions.beginPointerTap(); + }, [controls.functions]); + const handleGlobeSurfacePress = useCallback( (event: ThreeEvent) => { if (controls.functions.consumeTapThresholdExceeded()) { + logGlobeTap({ + source: "surface", + stage: "skip", + outcome: "ignored-drag-threshold", + }); return; } event.stopPropagation(); - const normal = event.point.clone().normalize(); - const latitude = THREE.MathUtils.radToDeg(Math.asin(normal.y)); - const thetaDeg = THREE.MathUtils.radToDeg(Math.atan2(normal.z, -normal.x)); - const longitude = THREE.MathUtils.euclideanModulo( - thetaDeg, - 360, - ) - 180; + const globe = globeGroupRef.current; + if (!globe) return; + const [latitude, longitude] = latLngFromWorldNormal( + event.point, + globe.quaternion, + ); + logGlobeTap({ + source: "surface", + stage: "input", + outcome: "surface-tap", + coordinate: { latitude, longitude }, + }); onGlobeSurfacePress({ latitude, longitude }); }, [controls.functions, onGlobeSurfacePress], ); - const flightRef = useRef(null); - const cameraDistanceRef = useRef(DEFAULT_CAMERA_DISTANCE); + const flightRef = useRef(null); + const cameraDistanceRef = useRef(initialCameraDistance); const onCameraViewChangeRef = useRef(onCameraViewChange); onCameraViewChangeRef.current = onCameraViewChange; const lastCameraViewKeyRef = useRef(""); - const emitCameraView = useCallback(() => { - const distance = camera.position.length(); - const [centerLat, centerLng] = vector3ToLatLng( - camera.position.x, - camera.position.y, - camera.position.z, + const snapOrbitFromFixedCamera = useCallback(() => { + controls.functions.snapCameraToFixedView( + GLOBE_CAMERA_VIEW_DIRECTION, + controls.scope.target, + cameraDistanceRef.current, ); + }, [controls.functions, controls.scope.target]); + + const resetOrbitFromFixedCamera = useCallback(() => { + controls.functions.resetOrbitToFixedView( + GLOBE_CAMERA_VIEW_DIRECTION, + controls.scope.target, + cameraDistanceRef.current, + ); + }, [controls.functions, controls.scope.target]); + + const syncGlobeQuaternionRef = useCallback(() => { + const globe = globeGroupRef.current; + if (globe) { + globeQuaternionRef.current.copy(globe.quaternion); + } + }, []); + + const emitCameraView = useCallback(() => { + const distance = cameraDistanceRef.current; + const globe = globeGroupRef.current; + const [centerLat, centerLng] = globe + ? viewCenterLatLngFromGlobeQuaternion(globe.quaternion) + : ([0, 0] as [number, number]); const zoomTier = resolveGlobeZoomTier(distance); const key = `${zoomTier}:${distance.toFixed(2)}:${centerLat.toFixed(1)}:${centerLng.toFixed(1)}`; if (key === lastCameraViewKeyRef.current) return; @@ -240,7 +305,7 @@ function GlobeScene({ centerLng, zoomTier, }); - }, [camera]); + }, []); const [visibleCirclePinNames, setVisibleCirclePinNames] = useState< Set @@ -273,24 +338,22 @@ function GlobeScene({ const focusLatLng = useCallback( (lat: number, lng: number, duration = 650, targetDistance?: number) => { if (!Number.isFinite(lat) || !Number.isFinite(lng)) return; + const globe = globeGroupRef.current; + if (!globe) return; - const toDir = new THREE.Vector3( - ...latLngToVector3(lat, lng, 1), - ).normalize(); - const fromDir = camera.position.clone().normalize(); const fromDistance = cameraDistanceRef.current; const toDistance = targetDistance ?? fromDistance; flightRef.current = { - fromDir, - toDir, + fromQuat: globe.quaternion.clone(), + toQuat: quaternionForLatLngFacingCamera(lat, lng), fromDistance, toDistance, elapsed: 0, duration: duration / 1000, }; }, - [camera], + [], ); const focusCountry = useCallback( @@ -303,38 +366,53 @@ function GlobeScene({ ); const resetCamera = useCallback(() => { - const toDir = new THREE.Vector3(...INITIAL_CAMERA_POSITION).normalize(); + const globe = globeGroupRef.current; + if (!globe) return; + + const fromDistance = cameraDistanceRef.current; + flightRef.current = { - fromDir: camera.position.clone().normalize(), - toDir, - fromDistance: cameraDistanceRef.current, + fromQuat: globe.quaternion.clone(), + toQuat: new THREE.Quaternion(), + fromDistance, toDistance: DEFAULT_CAMERA_DISTANCE, elapsed: 0, duration: 0.55, }; cameraDistanceRef.current = DEFAULT_CAMERA_DISTANCE; controls.scope.target.set(0, GLOBE_VIEW_TARGET_Y, 0); - camera.lookAt(controls.scope.target); - }, [camera, controls.scope.target]); + resetOrbitFromFixedCamera(); + }, [controls.scope.target, resetOrbitFromFixedCamera]); const zoomBy = useCallback( (direction: "in" | "out") => { flightRef.current = null; const scale = direction === "in" ? 0.82 : 1.22; + const fromDistance = cameraDistanceRef.current; const nextDistance = THREE.MathUtils.clamp( - cameraDistanceRef.current * scale, + fromDistance * scale, MIN_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE, ); cameraDistanceRef.current = nextDistance; - - const directionVector = camera.position.clone().normalize(); - camera.position.copy(directionVector.multiplyScalar(nextDistance)); + resetOrbitFromFixedCamera(); + syncGlobeQuaternionRef(); + emitCameraView(); }, - [camera], + [ + controls.functions, + emitCameraView, + resetOrbitFromFixedCamera, + syncGlobeQuaternionRef, + ], ); + useEffect(() => { + cameraDistanceRef.current = initialCameraDistance; + resetOrbitFromFixedCamera(); + }, [initialCameraDistance, resetOrbitFromFixedCamera]); + useEffect(() => { if (!flightRef.current) { controls.scope.enabled = !lockUserGestures; @@ -344,7 +422,7 @@ function GlobeScene({ useEffect(() => { controls.scope.camera = camera as THREE.PerspectiveCamera; controls.scope.target.set(0, GLOBE_VIEW_TARGET_Y, 0); - camera.lookAt(controls.scope.target); + resetOrbitFromFixedCamera(); controls.scope.enablePan = false; controls.scope.dampingFactor = 0.05; controls.scope.rotateSpeed = 0.9; @@ -352,14 +430,29 @@ function GlobeScene({ controls.scope.minZoom = MIN_CAMERA_DISTANCE; controls.scope.maxZoom = MAX_CAMERA_DISTANCE; controls.scope.onChange = () => { - const distance = camera.position.length(); - cameraDistanceRef.current = distance; + if (!controls.functions.isZoomInteraction()) return; + + const fromDistance = cameraDistanceRef.current; + const nextDistance = THREE.MathUtils.clamp( + camera.position.distanceTo(controls.scope.target), + MIN_CAMERA_DISTANCE, + MAX_CAMERA_DISTANCE, + ); + if (Math.abs(nextDistance - fromDistance) < 0.01) return; + + cameraDistanceRef.current = nextDistance; emitCameraView(); }; controls.scope.onStart = () => { flightRef.current = null; }; - }, [camera, controls.scope, emitCameraView]); + }, [ + camera, + controls.functions, + controls.scope, + emitCameraView, + resetOrbitFromFixedCamera, + ]); useEffect(() => { const handle: GlobeCameraHandle = { @@ -374,43 +467,71 @@ function GlobeScene({ }, [focusCountry, focusLatLng, onReady, resetCamera, zoomBy]); useFrame((_, delta) => { + const globe = globeGroupRef.current; const flight = flightRef.current; - if (flight) { + if (flight && globe) { controls.scope.enabled = false; flight.elapsed += delta; const progress = Math.min(flight.elapsed / flight.duration, 1); const eased = easeInOutCubic(progress); - // Slerp direction at fixed radius — linear lerp dips toward the globe center - // and reads as zoom-in/out while panning. - slerpUnitVectors(flight.fromDir, flight.toDir, eased, slerpScratchDir); - const distance = THREE.MathUtils.lerp( + flightScratchQuat.slerpQuaternions(flight.fromQuat, flight.toQuat, eased); + globe.quaternion.copy(flightScratchQuat); + + cameraDistanceRef.current = THREE.MathUtils.lerp( flight.fromDistance, flight.toDistance, eased, ); - cameraDistanceRef.current = distance; - camera.position.copy(slerpScratchDir.multiplyScalar(distance)); - camera.lookAt(controls.scope.target); + snapOrbitFromFixedCamera(); + syncGlobeQuaternionRef(); if (progress >= 1) { flightRef.current = null; cameraDistanceRef.current = flight.toDistance; + resetOrbitFromFixedCamera(); controls.scope.enabled = !lockUserGestures; - controls.functions.update(); emitCameraView(); } return; } controls.scope.enabled = !lockUserGestures; - controls.functions.update(); - const distance = camera.position.length(); - if (Math.abs(distance - cameraDistanceRef.current) > 0.01) { - cameraDistanceRef.current = distance; - emitCameraView(); + if (globe) { + if (controls.functions.hasActiveMomentum()) { + orbitPrevDir + .copy(camera.position) + .sub(controls.scope.target) + .normalize(); + controls.functions.update(); + orbitNextDir + .copy(camera.position) + .sub(controls.scope.target) + .normalize(); + + if (orbitPrevDir.angleTo(orbitNextDir) > 0.0001) { + globe.quaternion.premultiply( + globeQuaternionDeltaForCameraOrbit(orbitPrevDir, orbitNextDir), + ); + } + + if (controls.functions.isZoomInteraction()) { + const distance = THREE.MathUtils.clamp( + camera.position.distanceTo(controls.scope.target), + MIN_CAMERA_DISTANCE, + MAX_CAMERA_DISTANCE, + ); + if (Math.abs(distance - cameraDistanceRef.current) > 0.01) { + cameraDistanceRef.current = distance; + emitCameraView(); + } + } + } + + snapOrbitFromFixedCamera(); + syncGlobeQuaternionRef(); } }); @@ -424,83 +545,127 @@ function GlobeScene({ - - - + + + + + + + + + + - - - - - - + + + + + {showGlobePins + ? countries.map((country) => { + if (!isValidLatLng(country.latlng)) return null; + const focusCountryName = + selectedName ?? focusTransitionName ?? null; + const isSelected = focusCountryName === country.name; + const isFocusTransitioning = + !!focusTransitionName && + focusTransitionName === country.name && + selectedName !== country.name; + const keepVisible = continentSinglePinActive + ? isSelected || isFocusTransitioning + : isSelected || + isFocusTransitioning || + visibleCirclePinNames.has(country.name); + if (!keepVisible) { + return null; + } + const [lat, lng] = getMapDisplayLatLng(country); + return ( + + ); + }) + : null} + {showGlobePins ? ( ) : null} - - {showGlobePins - ? countries.map((country) => { - if (!isValidLatLng(country.latlng)) return null; - const isSelected = selectedName === country.name; - const isFocusTransitioning = focusTransitionName === country.name; - const keepVisible = - isSelected || - isFocusTransitioning || - visibleCirclePinNames.has(country.name); - if (!keepVisible) { - return null; - } - const [lat, lng] = getMapDisplayLatLng(country); - return ( - - ); - }) - : null} ); } @@ -521,13 +686,20 @@ type GlobeViewProps = { selectedName: string | null; focusTransitionName?: string | null; focusedRegion: string | null; + boundaryFocusRegion?: string | null; + /** Live globe camera tier from the map controller (stroke scaling). */ + zoomTier?: GlobeZoomTier; + previewRegion?: string | null; countryMarkerMode?: CountryMarkerDisplayMode; onClusterPress: (cluster: MapCluster) => void; onCountryPress: (country: MapCountry) => void; + onBoundaryCountryPress: (country: MapCountry) => void; onBackgroundPress: (coordinate?: MapPressCoordinate) => void; onCanvasPainted?: () => void; onCameraViewChange?: (state: GlobeCameraViewState) => void; lockUserGestures?: boolean; + /** Seeds globe distance from the map controller when entering 3D. */ + initialCameraDistance?: number; }; export const GlobeView = forwardRef( @@ -540,13 +712,18 @@ export const GlobeView = forwardRef( selectedName, focusTransitionName = null, focusedRegion, + boundaryFocusRegion = focusedRegion, + zoomTier = "world", + previewRegion = null, countryMarkerMode = "flag", onClusterPress, onCountryPress, + onBoundaryCountryPress, onBackgroundPress, onCanvasPainted, onCameraViewChange, lockUserGestures = false, + initialCameraDistance, }, ref, ) { @@ -559,8 +736,10 @@ export const GlobeView = forwardRef( >([]); const showGlobePins = !!focusedRegion && - isGlobeYellowPinsVisible(countryMarkerMode) && - countries.length > 0; + countries.length > 0 && + (isGlobeYellowPinsVisible(countryMarkerMode) || + !!selectedName || + !!focusTransitionName); const selectedCountry = useMemo( () => @@ -679,8 +858,12 @@ export const GlobeView = forwardRef( selectedName={selectedName} focusTransitionName={focusTransitionName} focusedRegion={focusedRegion} + boundaryFocusRegion={boundaryFocusRegion} + zoomTier={zoomTier} + previewRegion={previewRegion} showGlobePins={showGlobePins} onCountryPress={onCountryPress} + onBoundaryCountryPress={onBoundaryCountryPress} controls={controls} onReady={handleReady} onCanvasPainted={onCanvasPainted} @@ -690,6 +873,7 @@ export const GlobeView = forwardRef( layoutSize={layoutSize} onProjectorReady={handleProjectorReady} lockUserGestures={lockUserGestures} + initialCameraDistance={initialCameraDistance} /> diff --git a/components/map/map-boundary-controls-modal.tsx b/components/map/map-boundary-controls-modal.tsx index 71d59bb..7d1aee3 100644 --- a/components/map/map-boundary-controls-modal.tsx +++ b/components/map/map-boundary-controls-modal.tsx @@ -1,6 +1,12 @@ import { Ionicons } from "@expo/vector-icons"; import Slider from "@react-native-community/slider"; -import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useRef, + useState, + type ReactNode, +} from "react"; import { Modal, Pressable, @@ -19,14 +25,12 @@ import { clampBoundaryStep, DEFAULT_MAP_BOUNDARY_STYLE, displayPercentToFillOpacityStep, - displayPercentToStrokeOpacityStep, fillOpacityStepToDisplayPercent, resolveBoundaryStrokeWidth, - strokeOpacityStepToDisplayPercent, type MapBoundaryStyleSettings, - type MapFillColorMode, type MapZoomTier, } from "@/constants/map-boundary-style"; +import { resolveCountryFocusBoundaryStrokeWidth } from "@/constants/map-country-focus"; import { hueToHex } from "@/lib/color-utils"; import { useMapUiStore } from "@/store/use-map-ui-store"; @@ -90,28 +94,65 @@ function FillPatternIcon() { ); } -type FeatureToggleRowProps = { +function CountryHighlightIcon({ color }: { color: string }) { + return ( + + + + ); +} + +type CollapsibleStyleSectionProps = { icon: ReactNode; label: string; enabled: boolean; - onChange: (enabled: boolean) => void; + expanded: boolean; + onToggleEnabled: (enabled: boolean) => void; + onToggleExpanded: () => void; + children: ReactNode; }; -function FeatureToggleRow({ +function CollapsibleStyleSection({ icon, label, enabled, - onChange, -}: FeatureToggleRowProps) { + expanded, + onToggleEnabled, + onToggleExpanded, + children, +}: CollapsibleStyleSectionProps) { return ( - - {icon} - {label} - + + + [ + styles.collapsibleHeaderMain, + pressed && styles.pressed, + ]} + > + {icon} + {label} + + + + + {expanded ? ( + {children} + ) : null} ); } @@ -230,52 +271,6 @@ type SliderWithValueProps = { showValueInput?: boolean; }; -type FillColorModeToggleProps = { - mode: MapFillColorMode; - disabled?: boolean; - onModeChange: (mode: MapFillColorMode) => void; -}; - -function FillColorModeToggle({ - mode, - disabled = false, - onModeChange, -}: FillColorModeToggleProps) { - return ( - - {(["hue", "grayscale"] as const).map((option) => { - const active = option === mode; - return ( - onModeChange(option)} - style={({ pressed }) => [ - styles.modeToggleButton, - active && styles.modeToggleButtonActive, - pressed && !disabled && styles.pressed, - ]} - > - - {option === "hue" ? "Hue" : "Grayscale"} - - - ); - })} - - ); -} - function SliderWithValue({ value, min, @@ -356,6 +351,8 @@ function SliderWithValue({ ); } +type BoundaryStyleSection = "boundary" | "overlay" | "country"; + export function MapBoundaryControlsModal({ previewZoomTier = "world", onClose, @@ -369,7 +366,28 @@ export function MapBoundaryControlsModal({ const initialStyle = applyBoundaryStyleDraft(boundaryStyle); const committedStyleRef = useRef(initialStyle); const committedShowLinesRef = useRef(showBoundaryLines); + const hasToggledShowLinesRef = useRef(false); + const scrollRef = useRef(null); + const scrollToEndAfterCountryExpandRef = useRef(false); const [draft, setDraft] = useState(initialStyle); + const [expandedSection, setExpandedSection] = + useState("boundary"); + + const toggleSection = useCallback((section: BoundaryStyleSection) => { + setExpandedSection((current) => { + const nextExpanded = current === section ? null : section; + if (section === "country" && nextExpanded === "country") { + scrollToEndAfterCountryExpandRef.current = true; + } + return nextExpanded; + }); + }, []); + + const handleScrollContentSizeChange = useCallback(() => { + if (!scrollToEndAfterCountryExpandRef.current) return; + scrollToEndAfterCountryExpandRef.current = false; + scrollRef.current?.scrollToEnd({ animated: true }); + }, []); // Ensure the 2D map has polygons to preview (restored on dismiss if they were off). useEffect(() => { @@ -383,28 +401,25 @@ export function MapBoundaryControlsModal({ setBoundaryStyle(applyBoundaryStyleDraft(draft)); }, [draft, setBoundaryStyle]); - const hasChanges = boundaryStyleHasChanges( - draft, - committedStyleRef.current, - ); + const hasChanges = boundaryStyleHasChanges(draft, committedStyleRef.current); const boundaryEnabled = draft.strokeColorEnabled; + const countryHighlightEnabled = draft.countryHighlightEnabled; const previewStrokeWidth = resolveBoundaryStrokeWidth( applyBoundaryStyleDraft(draft), previewZoomTier, ); + const previewCountryStrokeWidth = resolveCountryFocusBoundaryStrokeWidth( + applyBoundaryStyleDraft(draft), + ); const formatThicknessStep = useCallback((step: number) => String(step), []); - const formatStrokeOpacityStep = useCallback( - (step: number) => String(strokeOpacityStepToDisplayPercent(step)), - [], - ); - const formatFillOpacityStep = useCallback( + const formatOverlayOpacityStep = useCallback( (step: number) => String(fillOpacityStepToDisplayPercent(step)), [], ); - const formatGrayLevel = useCallback( - (level: number) => String(Math.round(level)), + const formatCountryFillOpacityStep = useCallback( + (step: number) => String(fillOpacityStepToDisplayPercent(step)), [], ); @@ -418,7 +433,11 @@ export function MapBoundaryControlsModal({ const applied = applyBoundaryStyleDraft(draft); setBoundaryStyle(applied); committedStyleRef.current = applied; - committedShowLinesRef.current = showBoundaryLines; + if (hasToggledShowLinesRef.current) { + committedShowLinesRef.current = showBoundaryLines; + } else { + setShowBoundaryLines(committedShowLinesRef.current); + } onClose(); }; @@ -429,10 +448,12 @@ export function MapBoundaryControlsModal({ setShowBoundaryLines(true); committedStyleRef.current = defaults; committedShowLinesRef.current = true; + hasToggledShowLinesRef.current = true; resetBoundaryStyle(); }; const setBoundaryEnabled = (strokeColorEnabled: boolean) => { + hasToggledShowLinesRef.current = true; setShowBoundaryLines(strokeColorEnabled); setDraft((current) => ({ ...current, @@ -441,6 +462,14 @@ export function MapBoundaryControlsModal({ })); }; + const setOverlayEnabled = (fillEnabled: boolean) => { + setDraft((current) => ({ ...current, fillEnabled })); + }; + + const setCountryHighlightEnabled = (enabled: boolean) => { + setDraft((current) => ({ ...current, countryHighlightEnabled: enabled })); + }; + return ( - } label="Boundary" enabled={boundaryEnabled} - onChange={setBoundaryEnabled} - /> - } - label="Fill" - enabled={draft.fillEnabled} - onChange={(fillEnabled) => - setDraft((current) => ({ ...current, fillEnabled })) - } - /> - - - setDraft((current) => ({ ...current, strokeColorHue })) - } - onHexChange={(strokeColorHex) => - setDraft((current) => ({ ...current, strokeColorHex })) - } - /> - - toggleSection("boundary")} > - - Boundary thickness ({previewStrokeWidth.toFixed(1)}px) - - { - const parsed = Number.parseInt(text, 10); - if (Number.isNaN(parsed)) { - return null; - } - return clampBoundaryStep(parsed); - }} - onValueChange={(strokeThicknessStep) => - setDraft((current) => ({ - ...current, - strokeThicknessStep: clampBoundaryStep(strokeThicknessStep), - })) + onHueChange={(strokeColorHue) => + setDraft((current) => ({ ...current, strokeColorHue })) + } + onHexChange={(strokeColorHex) => + setDraft((current) => ({ ...current, strokeColorHex })) } /> - - - Line opacity - { - const parsed = Number.parseInt(text, 10); - if (Number.isNaN(parsed)) { - return null; + + + Boundary thickness ({previewStrokeWidth.toFixed(1)}px) + + { + const parsed = Number.parseInt(text, 10); + if (Number.isNaN(parsed)) { + return null; + } + return clampBoundaryStep(parsed); + }} + onValueChange={(strokeThicknessStep) => + setDraft((current) => ({ + ...current, + strokeThicknessStep: + clampBoundaryStep(strokeThicknessStep), + })) } - return displayPercentToStrokeOpacityStep(parsed); - }} - onValueChange={(strokeOpacityStep) => - setDraft((current) => ({ - ...current, - strokeOpacityStep: clampBoundaryStep(strokeOpacityStep), - })) - } - /> - + /> + + - {draft.fillEnabled ? ( - <> - + - - setDraft((current) => ({ ...current, fillColorHue })) + } + label="Overlay" + enabled={draft.fillEnabled} + expanded={expandedSection === "overlay"} + onToggleEnabled={setOverlayEnabled} + onToggleExpanded={() => toggleSection("overlay")} + > + + Overlay opacity + { + const parsed = Number.parseInt(text, 10); + if (Number.isNaN(parsed)) { + return null; + } + return displayPercentToFillOpacityStep(parsed); + }} + onValueChange={(fillOpacityStep) => + setDraft((current) => ({ + ...current, + fillOpacityStep: clampBoundaryStep(fillOpacityStep), + })) } - onHexChange={(fillColorHex) => - setDraft((current) => ({ ...current, fillColorHex })) + /> + + + + + + + } + label="Highlight" + enabled={countryHighlightEnabled} + expanded={expandedSection === "country"} + onToggleEnabled={setCountryHighlightEnabled} + onToggleExpanded={() => toggleSection("country")} + > + + setDraft((current) => ({ ...current, countryFillColorHue })) + } + onHexChange={(countryFillColorHex) => + setDraft((current) => ({ ...current, countryFillColorHex })) + } + /> - - Fill color mode - - setDraft((current) => ({ ...current, fillColorMode })) + + Fill opacity + { + const parsed = Number.parseInt(text, 10); + if (Number.isNaN(parsed)) { + return null; } - /> - + return displayPercentToFillOpacityStep(parsed); + }} + onValueChange={(countryFillOpacityStep) => + setDraft((current) => ({ + ...current, + countryFillOpacityStep: clampBoundaryStep( + countryFillOpacityStep, + ), + })) + } + /> + - {draft.fillColorMode === "grayscale" ? ( - - Grayscale level - { - const parsed = Number.parseInt(text, 10); - if (Number.isNaN(parsed)) return null; - return Math.min(100, Math.max(0, parsed)); - }} - onValueChange={(fillGrayLevel) => - setDraft((current) => ({ - ...current, - fillGrayLevel: Math.min( - 100, - Math.max(0, fillGrayLevel), - ), - })) - } - /> - - ) : null} + + setDraft((current) => ({ ...current, countryStrokeColorHue })) + } + onHexChange={(countryStrokeColorHex) => + setDraft((current) => ({ ...current, countryStrokeColorHex })) + } + /> - - Fill opacity - { - const parsed = Number.parseInt(text, 10); - if (Number.isNaN(parsed)) { - return null; - } - return displayPercentToFillOpacityStep(parsed); - }} - onValueChange={(fillOpacityStep) => - setDraft((current) => ({ - ...current, - fillOpacityStep: clampBoundaryStep(fillOpacityStep), - })) + + + Boundary thickness ({previewCountryStrokeWidth.toFixed(1)} + px) + + { + const parsed = Number.parseInt(text, 10); + if (Number.isNaN(parsed)) { + return null; } - /> - - - ) : null} + return clampBoundaryStep(parsed); + }} + onValueChange={(countryStrokeThicknessStep) => + setDraft((current) => ({ + ...current, + countryStrokeThicknessStep: clampBoundaryStep( + countryStrokeThicknessStep, + ), + })) + } + /> + + @@ -779,12 +842,25 @@ const styles = StyleSheet.create({ paddingTop: 6, paddingBottom: 6, }, - featureToggleRow: { + collapsibleSection: { + gap: 10, + }, + collapsibleHeader: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + collapsibleHeaderMain: { + flex: 1, flexDirection: "row", alignItems: "center", gap: 12, minHeight: 40, }, + collapsibleContent: { + gap: 10, + paddingBottom: 2, + }, featureIconBox: { width: 28, height: 28, @@ -821,6 +897,13 @@ const styles = StyleSheet.create({ top: 18, opacity: 0.5, }, + countryHighlightSwatch: { + width: 14, + height: 14, + borderRadius: 7, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.25)", + }, featureToggleLabel: { flex: 1, fontSize: 15, @@ -865,31 +948,12 @@ const styles = StyleSheet.create({ marginTop: 0, marginBottom: 0, }, - modeToggleWrap: { - flexDirection: "row", - borderRadius: 10, - backgroundColor: "rgba(255, 255, 255, 0.07)", - padding: 3, - gap: 4, - }, - modeToggleButton: { - flex: 1, - minHeight: 34, - borderRadius: 8, - alignItems: "center", - justifyContent: "center", - }, - modeToggleButtonActive: { - backgroundColor: "rgba(251, 191, 36, 0.65)", - }, - modeToggleLabel: { - fontSize: 12, - fontFamily: "Poppins-Regular", - color: "rgba(255,255,255,0.85)", - }, - modeToggleLabelActive: { - color: "#ffffff", - fontFamily: "Poppins-SemiBold", + /** Separates Boundary vs Overlay groups (offsets scroll gap so spacing stays tight). */ + sectionGroupDivider: { + height: 1, + backgroundColor: "rgba(255,255,255,0.12)", + marginTop: -2, + marginBottom: -4, }, settingBlockDisabled: { opacity: 0.42, diff --git a/components/map/map-canvas.tsx b/components/map/map-canvas.tsx index f712823..d6955a4 100644 --- a/components/map/map-canvas.tsx +++ b/components/map/map-canvas.tsx @@ -7,8 +7,8 @@ import { useState, } from "react"; import { StyleSheet, View } from "react-native"; +import type { Region } from "react-native-maps"; import Animated, { - Easing, runOnJS, useAnimatedStyle, useSharedValue, @@ -16,7 +16,6 @@ import Animated, { withSequence, withTiming, } from "react-native-reanimated"; -import type { Region } from "react-native-maps"; import { GlobeView, @@ -29,20 +28,18 @@ import { type MapZoomTier, type WorldMapViewHandle, } from "@/components/map/world-map-view"; +import { MAP_FOCUS_SCRIM_RGB } from "@/constants/map-continent-focus"; import type { MapCluster } from "@/lib/map-clusters"; import type { MapPressCoordinate } from "@/lib/map-map-tap-hit"; -import { - MAP_CONTINENT_FOCUS_FADE_MS, - MAP_SCRIM_MAX_OPACITY, -} from "@/constants/map-continent-focus"; +import type { MapMarkerPresentation } from "@/lib/map-region-markers"; import { GLOBE_CROSSFADE_MS, MAP_DIM_HOLD_MS, - type MapViewTransition, shouldShowFlatMapMarkers, + shouldShowFlatMapOverlays, shouldShowGlobeLayer, + type MapViewTransition, } from "@/lib/map-view-transition"; -import type { MapMarkerPresentation } from "@/lib/map-region-markers"; import { useMapStore } from "@/store/use-map-store"; import type { CountryMarkerDisplayMode } from "@/store/use-map-ui-store"; import type { MapCountry } from "@/types/country"; @@ -61,6 +58,9 @@ type MapCanvasProps = { selectedName: string | null; focusTransitionName?: string | null; focusedRegion: string | null; + boundaryFocusRegion?: string | null; + /** Continent focus fill/scrim — may lag focusedRegion after cross-region flights. */ + continentOverlayRegion?: string | null; previewRegion?: string | null; tapRippleAt?: MapPressCoordinate | null; tapRippleToken?: number; @@ -72,7 +72,10 @@ type MapCanvasProps = { onGlobeTransitionComplete: () => void; onFlatTransitionComplete: () => void; onGlobeCameraViewChange?: (state: GlobeCameraViewState) => void; + /** Seeds globe camera distance when entering 3D (from 2D latitudeDelta). */ + initialGlobeCameraDistance?: number; onCountryPress: (country: MapCountry) => void; + onBoundaryCountryPress: (country: MapCountry) => void; onClusterPress: (cluster: MapCluster) => void; onMapPress: (coordinate?: MapPressCoordinate) => void; onFlatMapReady?: () => void; @@ -93,6 +96,8 @@ export const MapCanvas = forwardRef( selectedName, focusTransitionName = null, focusedRegion, + boundaryFocusRegion = focusedRegion, + continentOverlayRegion = focusedRegion, previewRegion = null, tapRippleAt = null, tapRippleToken = 0, @@ -104,7 +109,9 @@ export const MapCanvas = forwardRef( onGlobeTransitionComplete, onFlatTransitionComplete, onGlobeCameraViewChange, + initialGlobeCameraDistance, onCountryPress, + onBoundaryCountryPress, onClusterPress, onMapPress, onFlatMapReady, @@ -124,9 +131,10 @@ export const MapCanvas = forwardRef( y: number; } | null>(null); - const globeOpacity = useSharedValue(shouldShowGlobeLayer(mapMode, mapViewTransition) ? 1 : 0); + const globeOpacity = useSharedValue( + shouldShowGlobeLayer(mapMode, mapViewTransition) ? 1 : 0, + ); const flatDimOpacity = useSharedValue(0); - const continentFocusBlend = useSharedValue(focusedRegion ? 1 : 0); const globePaintedRef = useRef(false); const enteringGlobeStartedRef = useRef(false); @@ -136,6 +144,7 @@ export const MapCanvas = forwardRef( const isGlobeInteractive = mapMode === "3d" && mapViewTransition === "ready"; const showFlatMarkers = shouldShowFlatMapMarkers(mapMode); + const showFlatOverlays = shouldShowFlatMapOverlays(mapMode); useImperativeHandle( ref, @@ -183,7 +192,7 @@ export const MapCanvas = forwardRef( } }), ); - }, [finishGlobeEnter, flatDimOpacity, globeOpacity]); + }, [finishGlobeEnter]); const handleGlobePainted = useCallback(() => { if (mapViewTransition !== "enteringGlobe" || globePaintedRef.current) { @@ -212,7 +221,7 @@ export const MapCanvas = forwardRef( }, 2500); return () => clearTimeout(fallback); - }, [flatDimOpacity, globeOpacity, mapViewTransition, startGlobeFadeIn]); + }, [mapViewTransition, startGlobeFadeIn]); const startFlatFadeIn = useCallback(() => { flatDimOpacity.value = withSequence( @@ -227,7 +236,7 @@ export const MapCanvas = forwardRef( } }), ); - }, [finishFlatEnter, flatDimOpacity, globeOpacity]); + }, [finishFlatEnter]); useEffect(() => { if (mapViewTransition !== "enteringFlat") { @@ -240,7 +249,7 @@ export const MapCanvas = forwardRef( globeOpacity.value = 1; flatDimOpacity.value = 0; startFlatFadeIn(); - }, [flatDimOpacity, globeOpacity, mapViewTransition, startFlatFadeIn]); + }, [mapViewTransition, startFlatFadeIn]); useEffect(() => { if (mapViewTransition === "ready" && mapMode === "3d") { @@ -251,7 +260,7 @@ export const MapCanvas = forwardRef( globeOpacity.value = 0; flatDimOpacity.value = 0; } - }, [flatDimOpacity, globeOpacity, mapMode, mapViewTransition]); + }, [mapMode, mapViewTransition]); const globeLayerStyle = useAnimatedStyle(() => ({ opacity: globeOpacity.value, @@ -261,17 +270,6 @@ export const MapCanvas = forwardRef( opacity: flatDimOpacity.value, })); - useEffect(() => { - continentFocusBlend.value = withTiming(focusedRegion ? 1 : 0, { - duration: MAP_CONTINENT_FOCUS_FADE_MS, - easing: Easing.inOut(Easing.ease), - }); - }, [continentFocusBlend, focusedRegion]); - - const continentFocusScrimStyle = useAnimatedStyle(() => ({ - opacity: continentFocusBlend.value * MAP_SCRIM_MAX_OPACITY, - })); - useEffect(() => { if (!tapRippleAt) { setRippleScreen(null); @@ -313,12 +311,15 @@ export const MapCanvas = forwardRef( selectedName, focusTransitionName, focusedRegion, + boundaryFocusRegion, + continentOverlayRegion, previewRegion, zoomTier, countryMarkerMode: showFlatMarkers ? countryMarkerMode : "hidden", markerPresentation, markerRevealGeneration, onCountryPress, + onBoundaryCountryPress, onMapPress, onMapReady: onFlatMapReady, onRegionChange: onFlatRegionChange, @@ -338,6 +339,7 @@ export const MapCanvas = forwardRef( ref={mapRef} {...mapProps} countries={showFlatMarkers ? countries : []} + showFocusLayers={showFlatOverlays} /> ( selectedName={selectedName} focusTransitionName={focusTransitionName} focusedRegion={focusedRegion} + boundaryFocusRegion={boundaryFocusRegion} + zoomTier={zoomTier} + previewRegion={previewRegion} countryMarkerMode={countryMarkerMode} onClusterPress={onClusterPress} onCountryPress={onCountryPress} + onBoundaryCountryPress={onBoundaryCountryPress} onBackgroundPress={onMapPress} onCanvasPainted={handleGlobePainted} onCameraViewChange={onGlobeCameraViewChange} + initialCameraDistance={initialGlobeCameraDistance} lockUserGestures={lockUserGestures || !isGlobeInteractive} /> - ) : null} @@ -389,17 +392,13 @@ export const MapCanvas = forwardRef( const styles = StyleSheet.create({ root: { ...StyleSheet.absoluteFillObject, - backgroundColor: "#0b132b", + backgroundColor: MAP_FOCUS_SCRIM_RGB, }, layer: { ...StyleSheet.absoluteFillObject, }, flatDim: { ...StyleSheet.absoluteFillObject, - backgroundColor: "#0b132b", - }, - continentFocusScrim: { - ...StyleSheet.absoluteFillObject, - backgroundColor: "#0b132b", + backgroundColor: MAP_FOCUS_SCRIM_RGB, }, }); diff --git a/components/map/map-continent-focus-layers.tsx b/components/map/map-continent-focus-layers.tsx index d51c4b3..a86344f 100644 --- a/components/map/map-continent-focus-layers.tsx +++ b/components/map/map-continent-focus-layers.tsx @@ -7,24 +7,25 @@ import { useSharedValue, withTiming, } from "react-native-reanimated"; + +import { resolveContinentFocusFillRgba } from "@/constants/map-boundary-style"; import { MAP_CONTINENT_FOCUS_FADE_MS, - MAP_CONTINENT_FOCUS_FILL_OPACITY, - MAP_CONTINENT_FOCUS_FILL_OPACITY_WITH_COUNTRY, MAP_CONTINENT_FOCUS_POLYGON_Z, MAP_CONTINENT_FOCUS_SCRIM_Z, - MAP_CONTINENT_PREVIEW_FILL_OPACITY, MAP_CONTINENT_PREVIEW_SCRIM_OPACITY, MAP_SCRIM_MAX_OPACITY, MAP_SCRIM_MAX_OPACITY_WITH_COUNTRY, MAP_WORLD_SCRIM_RING, - mapFocusAccentRgba, + continentFocusFillOpacityFactor, + continentPreviewFillOpacityFactor, mapFocusScrimRgba, } from "@/constants/map-continent-focus"; import { filterBoundaryPolygonsByMapContext, type CountryBoundaryPolygon, } from "@/lib/map-country-boundaries"; +import { useMapUiStore } from "@/store/use-map-ui-store"; import type { MapCountry } from "@/types/country"; /** Limit polygon color updates during fades — per-frame setState can crash MapView. */ @@ -58,6 +59,7 @@ export function MapContinentFocusLayers({ allPolygons, boundaryCountries, }: MapContinentFocusLayersProps) { + const boundaryStyle = useMapUiStore((s) => s.boundaryStyle); const blend = useSharedValue(0); const previewBlend = useSharedValue(0); const lastBlendStep = useSharedValue(-1); @@ -126,7 +128,8 @@ export function MapContinentFocusLayers({ useAnimatedReaction( () => blend.value, (value) => { - const step = Math.round(value * BLEND_REACTION_STEPS) / BLEND_REACTION_STEPS; + const step = + Math.round(value * BLEND_REACTION_STEPS) / BLEND_REACTION_STEPS; if (step === lastBlendStep.value) return; lastBlendStep.value = step; runOnJS(setRenderBlend)(step); @@ -170,17 +173,16 @@ export function MapContinentFocusLayers({ ? MAP_SCRIM_MAX_OPACITY_WITH_COUNTRY : MAP_SCRIM_MAX_OPACITY), ); - const fillColor = mapFocusAccentRgba( - renderBlend * - (selectedCountryName - ? MAP_CONTINENT_FOCUS_FILL_OPACITY_WITH_COUNTRY - : MAP_CONTINENT_FOCUS_FILL_OPACITY), + const fillColor = resolveContinentFocusFillRgba( + boundaryStyle, + renderBlend * continentFocusFillOpacityFactor(!!selectedCountryName), ); const previewScrimFill = mapFocusScrimRgba( renderPreviewBlend * MAP_CONTINENT_PREVIEW_SCRIM_OPACITY, ); - const previewFillColor = mapFocusAccentRgba( - renderPreviewBlend * MAP_CONTINENT_PREVIEW_FILL_OPACITY, + const previewFillColor = resolveContinentFocusFillRgba( + boundaryStyle, + renderPreviewBlend * continentPreviewFillOpacityFactor(), ); const showCommitted = displayRegion && renderBlend > 0.001; diff --git a/components/map/map-controls.tsx b/components/map/map-controls.tsx index 7068c50..3ca618c 100644 --- a/components/map/map-controls.tsx +++ b/components/map/map-controls.tsx @@ -7,6 +7,7 @@ import { MapBoundaryControlsModal } from "@/components/map/map-boundary-controls import { MapCircularFab } from "@/components/map/map-circular-fab"; import { applyBoundaryStyleDraft } from "@/constants/map-boundary-style"; import { MAP_CONTROL_STACK } from "@/constants/map-chrome-styles"; +import type { CameraZoomTier } from "@/lib/map-camera-zoom"; import type { MapViewTransition } from "@/lib/map-view-transition"; import type { MapMode } from "@/store/use-map-store"; import { @@ -15,6 +16,9 @@ import { useMapUiStore, } from "@/store/use-map-ui-store"; +/** Re-enable when reset / zoom rail UX is finalized. */ +const MAP_ZOOM_RESET_CONTROLS_ENABLED = false; + type MapControlsProps = { mapMode: MapMode; mapViewTransition: MapViewTransition; @@ -36,6 +40,8 @@ type MapControlsProps = { randomDeemphasized?: boolean; /** Blocks the random FAB while a camera flight is sequencing (prevents overlapping animateToRegion). */ randomDisabled?: boolean; + /** Live camera tier — boundary style preview in the modal (not selection intent). */ + boundaryPreviewZoomTier?: CameraZoomTier; }; export function MapControls({ @@ -53,6 +59,7 @@ export function MapControls({ onRandomCountryPress, randomDeemphasized = false, randomDisabled = false, + boundaryPreviewZoomTier = "world", }: MapControlsProps) { const [isActionRailExpanded, setIsActionRailExpanded] = useState(defaultExpanded); @@ -69,8 +76,6 @@ export function MapControls({ const setShowBoundaryLines = useMapUiStore((s) => s.setShowBoundaryLines); const boundaryStyle = useMapUiStore((s) => s.boundaryStyle); const setBoundaryStyle = useMapUiStore((s) => s.setBoundaryStyle); - const focusedRegion = useMapUiStore((s) => s.focusedRegion); - const boundaryPreviewZoomTier = focusedRegion ? "region" : "world"; const is3d = mapMode === "3d"; const isTransitioning = @@ -228,70 +233,70 @@ export function MapControls({ - - [ - styles.control, - pressed && styles.pressed, - ]} - > - - - - [ - styles.control, - pressed && styles.pressed, - ]} - > - - - - [ - styles.control, - pressed && styles.pressed, - ]} - > - - - + {MAP_ZOOM_RESET_CONTROLS_ENABLED ? ( + + [ + styles.control, + pressed && styles.pressed, + ]} + > + + + + [ + styles.control, + pressed && styles.pressed, + ]} + > + + + + [ + styles.control, + pressed && styles.pressed, + ]} + > + + + + ) : null} {showDisplayStack ? ( {showFlagToggle ? ( - <> - [ - styles.control, - pressed && styles.pressed, - ]} - > - - - - + [ + styles.control, + pressed && styles.pressed, + ]} + > + + ) : null} {showBoundaryControls ? ( <> + {showFlagToggle ? : null} + Number.isFinite(point.latitude) && + Number.isFinite(point.longitude) && + Math.abs(point.latitude) <= 90, + ); +} + +type MapCountryFocusLayersProps = { + selectedCountryName: string | null; + /** In-flight focus animation — keeps fill visible while camera moves. */ + focusTransitionName?: string | null; + allPolygons: CountryBoundaryPolygon[]; + /** Solid fill when continent overlay sits below (no lake/bay holes). */ + fillGapsWhenContinentOverlay?: boolean; +}; + +export function MapCountryFocusLayers({ + selectedCountryName, + focusTransitionName: _focusTransitionName = null, + allPolygons, + fillGapsWhenContinentOverlay = false, +}: MapCountryFocusLayersProps) { + const boundaryStyle = useMapUiStore((s) => s.boundaryStyle); + const boundaryStyleRevision = useMapUiStore((s) => s.boundaryStyleRevision); + const highlightName = selectedCountryName; + const blend = useSharedValue(highlightName ? 1 : 0); + const lastBlendStep = useSharedValue(-1); + const [renderBlend, setRenderBlend] = useState(highlightName ? 1 : 0); + + useEffect(() => { + cancelAnimation(blend); + + if (highlightName) { + blend.value = withTiming(1, { + duration: MAP_COUNTRY_FOCUS_FADE_MS, + easing: Easing.inOut(Easing.ease), + }); + return; + } + + blend.value = withTiming(0, { + duration: MAP_COUNTRY_FOCUS_FADE_MS, + easing: Easing.inOut(Easing.ease), + }); + }, [blend, highlightName]); + + useAnimatedReaction( + () => blend.value, + (value) => { + const step = + Math.round(value * BLEND_REACTION_STEPS) / BLEND_REACTION_STEPS; + if (step === lastBlendStep.value) return; + lastBlendStep.value = step; + runOnJS(setRenderBlend)(step); + }, + [blend, lastBlendStep], + ); + + const countryPolygons = useMemo(() => { + if (!highlightName || renderBlend <= 0.001) return []; + const filtered = filterBoundaryPolygonsByMapContext(allPolygons, { + selectedCountryName: highlightName, + focusedRegion: null, + countries: [], + }) + .filter(isRenderablePolygon) + .slice(0, COUNTRY_FOCUS_SLOT_COUNT); + + return resolveCountryFocusRenderPolygons( + filtered, + fillGapsWhenContinentOverlay, + ); + }, [allPolygons, fillGapsWhenContinentOverlay, highlightName, renderBlend]); + + const fillColor = resolveCountryFocusFillRgba(boundaryStyle, renderBlend); + const strokeColor = resolveCountryFocusStrokeRgba(boundaryStyle, renderBlend); + const strokeWidth = resolveCountryFocusFillStrokeWidth(boundaryStyle); + const isVisible = + boundaryStyle.countryHighlightEnabled && + !!highlightName && + renderBlend > 0.001 && + countryPolygons.length > 0; + + if (!boundaryStyle.countryHighlightEnabled) { + return null; + } + + return ( + <> + {Array.from({ length: COUNTRY_FOCUS_SLOT_COUNT }, (_, slotIndex) => { + const polygon = isVisible ? countryPolygons[slotIndex] : undefined; + + return ( + + ); + })} + + ); +} diff --git a/components/map/map-country-focus-pill.tsx b/components/map/map-country-focus-pill.tsx index 4b03496..ce0708c 100644 --- a/components/map/map-country-focus-pill.tsx +++ b/components/map/map-country-focus-pill.tsx @@ -3,6 +3,7 @@ import * as Haptics from "expo-haptics"; import { Pressable, StyleSheet, Text, View } from "react-native"; import { FlagBadge } from "@/components/explore/flag-badge"; +import { cca3FromFlagUrl } from "@/lib/map-country"; import type { MapCountry } from "@/types/country"; type MapCountryFocusPillProps = { @@ -14,11 +15,6 @@ type MapCountryFocusPillProps = { const PILL_HEIGHT = 44; -function shortCountryName(name: string): string { - if (name.length <= 2) return name; - return `${name.slice(0, 2)}...`; -} - export function MapCountryFocusPill({ country, bottom, @@ -35,6 +31,8 @@ export function MapCountryFocusPill({ onDismiss(); }; + const countryCode = cca3FromFlagUrl(country.flag); + return ( @@ -49,7 +47,7 @@ export function MapCountryFocusPill({ > - {shortCountryName(country.name)} + {countryCode} Details @@ -112,7 +110,7 @@ const styles = StyleSheet.create({ backgroundColor: "rgba(255, 255, 255, 0.1)", }, countryLabel: { - width: 28, + minWidth: 28, fontSize: 13, lineHeight: 18, fontFamily: "Poppins-Medium", diff --git a/components/map/map-country-marker.tsx b/components/map/map-country-marker.tsx index c36342f..57aacfa 100644 --- a/components/map/map-country-marker.tsx +++ b/components/map/map-country-marker.tsx @@ -12,7 +12,6 @@ import Animated, { } from "react-native-reanimated"; import { resolveFlagCdnUrl } from "@/lib/flag-url"; -import { logMapDebug } from "@/lib/map-debug"; import { cca2FromFlagUrl, getMapDisplayLatLng } from "@/lib/map-country"; import { type MapMarkerPresentation, @@ -28,6 +27,7 @@ const SNAPSHOT_SETTLE_MS = 500; /** Fixed marker anchor box — label is positioned outside this so selection does not shift the pin. */ const MARKER_ANCHOR_SIZE = 48; const PIN_SIZE = 36; +const SELECTED_PIN_SIZE = 30; /** Survives marker re-snapshots so flags do not flash on every map action. */ const loadedFlagUris = new Set(); @@ -40,11 +40,14 @@ const FlagImage = memo(function FlagImage({ flagUri, style, loaded, + keepVisibleWhileLoading, onLoad, }: { flagUri: string; style: object; loaded: boolean; + /** Focal pin — avoid opacity-0 snapshots while the CDN image loads. */ + keepVisibleWhileLoading?: boolean; onLoad: () => void; }) { return ( @@ -52,15 +55,9 @@ const FlagImage = memo(function FlagImage({ source={{ uri: flagUri }} recyclingKey={flagUri} cachePolicy="memory-disk" - style={[style, !loaded && styles.flagHidden]} + style={[style, !loaded && !keepVisibleWhileLoading && styles.flagHidden]} contentFit="cover" onLoadEnd={onLoad} - onError={(event) => { - logMapDebug("marker", "flag image error", { - flagUri, - error: event?.error, - }); - }} /> ); }); @@ -118,7 +115,7 @@ function FlagPinBody({ }), ); return () => cancelAnimation(pulse); - }, [focusTransitioning, pulse]); + }, [focusTransitioning]); const pinAnimatedStyle = useAnimatedStyle(() => ({ transform: [{ scale: focusTransitioning ? pulse.value : 1 }], @@ -135,6 +132,7 @@ function FlagPinBody({ flagUri={flagUri} style={flagStyle} loaded={flagLoaded} + keepVisibleWhileLoading={selected || focusTransitioning} onLoad={onFlagLoad} /> ) : ( @@ -178,7 +176,7 @@ export const MapCountryMarker = memo(function MapCountryMarker({ const isEntering = presentation === "entering" && !selected && !focusTransitioning; const fadeOpacity = useSharedValue(0); - const fadeScale = useSharedValue(0.85); + const fadeScale = useSharedValue(1); const prevRevealGenerationRef = useRef(revealGeneration); useEffect(() => { @@ -205,10 +203,9 @@ export const MapCountryMarker = memo(function MapCountryMarker({ } fadeOpacity.value = withTiming(targetOpacity, { duration: 200 }); + fadeScale.value = withTiming(1, { duration: 200 }); }, [ deemphasized, - fadeOpacity, - fadeScale, isEntering, revealGeneration, selected, @@ -418,13 +415,16 @@ const styles = StyleSheet.create({ overflow: "hidden", }, pinSelected: { - borderWidth: 3, + width: SELECTED_PIN_SIZE, + height: SELECTED_PIN_SIZE, + borderRadius: SELECTED_PIN_SIZE / 2, + borderWidth: 2, borderColor: "#fbbf24", shadowColor: "#fbbf24", shadowOffset: { width: 0, height: 0 }, - shadowOpacity: 0.75, - shadowRadius: 8, - elevation: 8, + shadowOpacity: 0.65, + shadowRadius: 6, + elevation: 6, }, flag: { width: PIN_SIZE, @@ -437,9 +437,9 @@ const styles = StyleSheet.create({ borderRadius: 13, }, flagSelected: { - width: PIN_SIZE - 6, - height: PIN_SIZE - 6, - borderRadius: (PIN_SIZE - 6) / 2, + width: SELECTED_PIN_SIZE - 4, + height: SELECTED_PIN_SIZE - 4, + borderRadius: (SELECTED_PIN_SIZE - 4) / 2, }, flagHidden: { opacity: 0, diff --git a/components/map/map-country-preview-card.tsx b/components/map/map-country-preview-card.tsx index beb778b..307b249 100644 --- a/components/map/map-country-preview-card.tsx +++ b/components/map/map-country-preview-card.tsx @@ -2,15 +2,19 @@ import { Ionicons } from "@expo/vector-icons"; import { useEffect, useState } from "react"; import { ActivityIndicator, + Platform, Pressable, StyleSheet, Text, View, } from "react-native"; +import Animated, { SlideInDown, SlideOutDown } from "react-native-reanimated"; import { FlagBadge } from "@/components/explore/flag-badge"; +import { CLIENT_CACHE_KEYS, CLIENT_CACHE_TTL } from "@/constants/client-cache"; import { continentDisplayLabel } from "@/constants/regions"; import { fetchCountryByName } from "@/lib/api"; +import { getClientCache, staleWhileRevalidate } from "@/lib/client-cache"; import { formatPopulation } from "@/lib/format-country"; import { mapCountryToCountry } from "@/lib/map-country"; import { openCountryInExplore } from "@/lib/open-country-in-explore"; @@ -29,7 +33,20 @@ type MapCountryPreviewCardProps = { const FLAG_WIDTH = 56; const FLAG_HEIGHT = 38; const COUNTRY_NAME_FONT_SIZE = 17; +const COUNTRY_NAME_LINE_HEIGHT = 20; const COUNTRY_NAME_MIN_FONT_SIZE = 14; +const ACCENT = "#fbbf24"; +const ACCENT_DARK = "#0b132b"; + +const PREVIEW_CARD_ENTER = SlideInDown.springify() + .damping(20) + .stiffness(150) + .mass(0.85); + +const PREVIEW_CARD_EXIT = SlideOutDown.springify() + .damping(24) + .stiffness(200) + .mass(0.75); export function MapCountryPreviewCard({ country, @@ -48,22 +65,51 @@ export function MapCountryPreviewCard({ useEffect(() => { let cancelled = false; - setDetailStatus("loading"); - setDetailError(null); - void fetchCountryByName(country.name) - .then((data) => { - if (cancelled) return; - setDetail(data); + const loadDetail = async () => { + setDetailError(null); + + const cacheKey = CLIENT_CACHE_KEYS.countryDetail(country.name); + const diskCache = await getClientCache(cacheKey); + + if (cancelled) return; + + if (diskCache.data) { + setDetail(diskCache.data); setDetailStatus("idle"); - }) - .catch((err) => { + } else { + setDetail(null); + setDetailStatus("loading"); + } + + try { + await staleWhileRevalidate({ + key: cacheKey, + ttlSeconds: CLIENT_CACHE_TTL.countryDetail, + fetcher: () => fetchCountryByName(country.name), + onCached: (data) => { + if (cancelled) return; + setDetail(data); + setDetailStatus("idle"); + }, + onFetched: (data) => { + if (cancelled) return; + setDetail(data); + setDetailStatus("idle"); + }, + }); + } catch (err) { if (cancelled) return; - setDetailStatus("error"); - setDetailError( - err instanceof Error ? err.message : "Could not load fun fact", - ); - }); + if (!diskCache.data) { + setDetailStatus("error"); + setDetailError( + err instanceof Error ? err.message : "Could not load fun fact", + ); + } + } + }; + + void loadDetail(); return () => { cancelled = true; @@ -87,12 +133,19 @@ export function MapCountryPreviewCard({ : "Updating…"); return ( - + 0 ? bottomInset : 16 }, + ]} + > - [ - styles.closeButton, - pressed && styles.closeButtonPressed, - ]} - > - - + + [ + styles.closeButton, + pressed && styles.closeButtonPressed, + ]} + > + + + @@ -207,18 +262,16 @@ export function MapCountryPreviewCard({ accessibilityRole="button" accessibilityLabel="Shuffle to another country" accessibilityHint="Picks another country in this region and flies the map there" + accessibilityState={{ disabled: isNextCountryLoading }} + disabled={isNextCountryLoading} onPress={onNextCountry} style={({ pressed }) => [ styles.actionSegment, isNextCountryLoading && styles.actionLoading, - pressed && styles.pressed, + pressed && !isNextCountryLoading && styles.pressed, ]} > - {isNextCountryLoading ? ( - - ) : ( - - )} + Shuffle @@ -231,14 +284,15 @@ export function MapCountryPreviewCard({ onPress={() => openCountryInExplore(countryForActions)} style={({ pressed }) => [ styles.actionSegment, - pressed && styles.pressed, + styles.exploreSegment, + pressed && styles.explorePressed, ]} > - Explore - + Explore + - + ); } @@ -272,6 +326,7 @@ function Stat({ const styles = StyleSheet.create({ card: { paddingTop: 14, + marginBottom: -80, paddingHorizontal: 16, gap: 10, borderTopLeftRadius: 24, @@ -279,6 +334,18 @@ const styles = StyleSheet.create({ backgroundColor: "#121826", borderTopWidth: 1, borderColor: "rgba(255, 255, 255, 0.08)", + ...Platform.select({ + ios: { + shadowColor: "#000000", + shadowOffset: { width: 0, height: -6 }, + shadowOpacity: 0.38, + shadowRadius: 18, + }, + android: { + elevation: 16, + }, + default: {}, + }), }, sectionDivider: { height: StyleSheet.hairlineWidth, @@ -287,31 +354,38 @@ const styles = StyleSheet.create({ }, topRow: { flexDirection: "row", - // justifyContent: "center", - alignItems: "center", - gap: 6, + alignItems: "flex-start", + gap: 4, }, titleTextWrap: { flex: 1, + flexGrow: 1, + flexShrink: 1, minWidth: 0, }, countryName: { fontSize: COUNTRY_NAME_FONT_SIZE, - lineHeight: 21, + lineHeight: COUNTRY_NAME_LINE_HEIGHT, fontFamily: "Poppins-SemiBold", color: "#ffffff", }, - closeButton: { - width: 40, - height: 40, + closeSlot: { + height: COUNTRY_NAME_LINE_HEIGHT, + width: 28, flexShrink: 0, alignItems: "center", justifyContent: "center", - borderRadius: 12, - backgroundColor: "rgba(255, 255, 255, 0.08)", + }, + closeButton: { + width: COUNTRY_NAME_LINE_HEIGHT, + height: COUNTRY_NAME_LINE_HEIGHT, + alignItems: "center", + justifyContent: "center", + borderRadius: 10, }, closeButtonPressed: { - opacity: 0.6, + backgroundColor: "rgba(255, 255, 255, 0.06)", + opacity: 0.85, }, metaRow: { flexDirection: "row", @@ -352,7 +426,7 @@ const styles = StyleSheet.create({ statValue: { fontSize: 14, lineHeight: 17, - fontFamily: "Poppins-SemiBold", + fontFamily: "Poppins-Regular", color: "#ffffff", flexShrink: 1, }, @@ -383,7 +457,7 @@ const styles = StyleSheet.create({ actionStack: { flexDirection: "row", alignItems: "stretch", - marginVertical: 4, + marginTop: 4, minHeight: 48, borderRadius: 12, backgroundColor: "#101828", @@ -414,6 +488,18 @@ const styles = StyleSheet.create({ fontFamily: "Poppins-Medium", color: "#ffffff", }, + exploreSegment: { + backgroundColor: ACCENT, + }, + exploreLabel: { + fontSize: 13, + fontFamily: "Poppins-SemiBold", + color: ACCENT_DARK, + }, + explorePressed: { + opacity: 0.88, + backgroundColor: "#f59e0b", + }, actionLoading: { opacity: 0.45, }, diff --git a/components/map/map-tap-ripple.tsx b/components/map/map-tap-ripple.tsx index f7368a5..da75fcb 100644 --- a/components/map/map-tap-ripple.tsx +++ b/components/map/map-tap-ripple.tsx @@ -32,7 +32,7 @@ export function MapTapRipple({ x, y, triggerKey }: MapTapRippleProps) { duration: RIPPLE_DURATION_MS, easing: Easing.out(Easing.cubic), }); - }, [opacity, scale, triggerKey]); + }, [triggerKey]); const rippleStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }], diff --git a/components/map/world-map-view.tsx b/components/map/world-map-view.tsx index 7654acf..6d0832c 100644 --- a/components/map/world-map-view.tsx +++ b/components/map/world-map-view.tsx @@ -13,25 +13,33 @@ import MapView, { } from "react-native-maps"; import { MapContinentFocusLayers } from "@/components/map/map-continent-focus-layers"; +import { MapCountryFocusLayers } from "@/components/map/map-country-focus-layers"; import { MapCountryMarker } from "@/components/map/map-country-marker"; -import { MAP_CONTINENT_FOCUS_POLYGON_Z } from "@/constants/map-continent-focus"; import { boundaryStyleRenderKey, - resolveBoundaryFillColor, resolveBoundaryStrokeColor, resolveBoundaryStrokeWidth, } from "@/constants/map-boundary-style"; +import { MAP_CONTINENT_FOCUS_POLYGON_Z } from "@/constants/map-continent-focus"; +import { + countryFocusStyleRenderKey, + MAP_COUNTRY_FOCUS_STROKE_Z, + resolveCountryFocusBoundaryStrokeColor, + resolveCountryFocusBoundaryStrokeWidth, +} from "@/constants/map-country-focus"; import { MAP_DARK_STYLE } from "@/constants/map-dark-style"; import { WORLD_INITIAL_REGION } from "@/constants/map-regions"; import { countryNamesMatch, filterBoundaryPolygonsByMapContext, - parseCountryBoundaryPolygons, + getCountryBoundaryPolygons, type CountryBoundaryPolygon, } from "@/lib/map-country-boundaries"; -import { logMapDebug, summarizeRegion } from "@/lib/map-debug"; +import { shouldFillCountryHighlightGaps } from "@/lib/map-country-focus-polygons"; +import { summarizeRegion } from "@/lib/map-debug"; import type { MapPressCoordinate } from "@/lib/map-map-tap-hit"; import type { MapMarkerPresentation } from "@/lib/map-region-markers"; +import { areRegionBoundariesTappable } from "@/lib/map-signal-sources"; import { useMapUiStore, type CountryMarkerDisplayMode, @@ -93,12 +101,15 @@ type WorldMapViewProps = { selectedName: string | null; focusTransitionName?: string | null; focusedRegion: string | null; + boundaryFocusRegion?: string | null; + continentOverlayRegion?: string | null; previewRegion?: string | null; zoomTier: MapZoomTier; countryMarkerMode?: CountryMarkerDisplayMode; markerPresentation?: MapMarkerPresentation; markerRevealGeneration?: number; onCountryPress: (country: MapCountry) => void; + onBoundaryCountryPress: (country: MapCountry) => void; onMapPress: (coordinate?: MapPressCoordinate) => void; onMapReady?: () => void; /** Throttled continuous viewport updates — drives live zoom-tier/marker density. */ @@ -108,6 +119,8 @@ type WorldMapViewProps = { lockUserGestures?: boolean; suspendMarkerSnapshot?: boolean; markerRefreshToken?: number; + /** When false (3D mode), skip flat highlight layers — globe renders them. */ + showFocusLayers?: boolean; }; export const WorldMapView = forwardRef( @@ -118,12 +131,15 @@ export const WorldMapView = forwardRef( selectedName, focusTransitionName = null, focusedRegion, + boundaryFocusRegion = focusedRegion, + continentOverlayRegion = focusedRegion, previewRegion = null, zoomTier, countryMarkerMode = "flag", markerPresentation = "full", markerRevealGeneration = 0, onCountryPress, + onBoundaryCountryPress, onMapPress, onMapReady, onRegionChange, @@ -131,6 +147,7 @@ export const WorldMapView = forwardRef( lockUserGestures = false, suspendMarkerSnapshot = false, markerRefreshToken = 0, + showFocusLayers = true, }, ref, ) { @@ -143,24 +160,15 @@ export const WorldMapView = forwardRef( const input = summarizeRegion(region); const safeRegion = sanitizeRegion(region, regionRef.current); const output = summarizeRegion(safeRegion); - if (!input.finite || input.lat !== output.lat || input.latDelta !== output.latDelta) { - logMapDebug("camera", "world-map-view region sanitized", { - duration, - input, - output, - hasMapRef: !!mapRef.current, - }); + if ( + !input.finite || + input.lat !== output.lat || + input.latDelta !== output.latDelta + ) { + // Region was clamped to safe bounds before animateToRegion. } regionRef.current = safeRegion; - try { - mapRef.current?.animateToRegion(safeRegion, duration); - } catch (err) { - logMapDebug("camera", "ERROR world-map-view animateToRegion threw", { - error: err instanceof Error ? err.message : String(err), - output, - }); - throw err; - } + mapRef.current?.animateToRegion(safeRegion, duration); }, []); useImperativeHandle( @@ -201,54 +209,68 @@ export const WorldMapView = forwardRef( const boundaryStyleRevision = useMapUiStore((s) => s.boundaryStyleRevision); const showBoundaryLines = useMapUiStore((s) => s.showBoundaryLines); - const allCountryBoundaries = useMemo( - () => parseCountryBoundaryPolygons(countriesGeoJson), - [], - ); + const allCountryBoundaries = getCountryBoundaryPolygons(countriesGeoJson); const showWorldBoundaries = - showBoundaryLines && !focusedRegion && !selectedName; + showBoundaryLines && !boundaryFocusRegion && !selectedName; + + const highlightCountryName = selectedName; + const showCountryHighlight = + !!highlightCountryName && boundaryStyle.countryHighlightEnabled; + const showBoundaryStrokes = + boundaryStyle.strokeColorEnabled && showBoundaryLines; + + const fillCountryHighlightGaps = shouldFillCountryHighlightGaps( + continentOverlayRegion, + previewRegion, + ); const countryBoundaries = useMemo( () => filterBoundaryPolygonsByMapContext(allCountryBoundaries, { - selectedCountryName: selectedName, - focusedRegion, + selectedCountryName: highlightCountryName, + focusedRegion: boundaryFocusRegion, countries: boundaryCountries, showWorldBoundaries, }), [ allCountryBoundaries, boundaryCountries, - focusedRegion, - selectedName, + boundaryFocusRegion, + highlightCountryName, showWorldBoundaries, ], ); - const outlineStrokeWidth = resolveBoundaryStrokeWidth( - boundaryStyle, + const outlineStrokeWidth = showCountryHighlight + ? resolveCountryFocusBoundaryStrokeWidth(boundaryStyle) + : resolveBoundaryStrokeWidth(boundaryStyle, zoomTier); + const outlineStrokeColor = showCountryHighlight + ? resolveCountryFocusBoundaryStrokeColor(boundaryStyle) + : resolveBoundaryStrokeColor(boundaryStyle, zoomTier); + /** Country outlines are stroke-only; continent overlay uses fill settings. */ + const outlineFillColor = "rgba(0,0,0,0)"; + const boundaryRenderKey = showCountryHighlight + ? countryFocusStyleRenderKey(boundaryStyle) + : boundaryStyleRenderKey(boundaryStyle, zoomTier); + const boundariesTappable = areRegionBoundariesTappable( + boundaryFocusRegion, zoomTier, ); - const outlineStrokeColor = resolveBoundaryStrokeColor( - boundaryStyle, - zoomTier, - ); - const outlineFillColor = resolveBoundaryFillColor(boundaryStyle); - const boundaryRenderKey = boundaryStyleRenderKey(boundaryStyle, zoomTier); - const boundariesTappable = zoomTier === "country" && !!focusedRegion; - const boundaryZIndex = focusedRegion - ? MAP_CONTINENT_FOCUS_POLYGON_Z + 2 - : 1; + const boundaryZIndex = showCountryHighlight + ? MAP_COUNTRY_FOCUS_STROKE_Z + : focusedRegion + ? MAP_CONTINENT_FOCUS_POLYGON_Z + 2 + : 1; const handleBoundaryPress = useCallback( (polygon: CountryBoundaryPolygon) => { const country = boundaryCountries.find((c) => countryNamesMatch(c.name, polygon.countryName), ); - if (country) onCountryPress(country); + if (country) onBoundaryCountryPress(country); }, - [boundaryCountries, onCountryPress], + [boundaryCountries, onBoundaryCountryPress], ); return ( @@ -301,19 +323,27 @@ export const WorldMapView = forwardRef( showsMyLocationButton={false} mapType={Platform.OS === "android" ? "standard" : "hybridFlyover"} > - {showBoundaryLines ? ( + {showFocusLayers ? ( ) : null} - {showBoundaryLines + + {showFocusLayers && showBoundaryStrokes && !showCountryHighlight ? countryBoundaries.map((polygon) => ( ( )) : null} {countries.map((country) => { - const isSelected = selectedName === country.name; - const isFocusTransitioning = focusTransitionName === country.name; + const focusCountryName = selectedName ?? focusTransitionName ?? null; + const isSelected = focusCountryName === country.name; + const isFocusTransitioning = + !!focusTransitionName && + focusTransitionName === country.name && + selectedName !== country.name; const isHighlighted = isSelected || isFocusTransitioning; - const focusedPinName = selectedName ?? focusTransitionName; return ( ( focusTransitioning={isFocusTransitioning} deemphasized={ !!focusedRegion && - !!focusedPinName && - focusedPinName !== country.name + !!focusCountryName && + focusCountryName !== country.name } - displayMode={countryMarkerMode} + displayMode={isSelected ? "flag" : countryMarkerMode} presentation={markerPresentation} revealGeneration={markerRevealGeneration} keepLive={keepSingleMarkerLive || isHighlighted} diff --git a/components/search/search-overlay.tsx b/components/search/search-overlay.tsx index db59db0..487ecca 100644 --- a/components/search/search-overlay.tsx +++ b/components/search/search-overlay.tsx @@ -18,10 +18,13 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { FlagBadge } from "@/components/explore/flag-badge"; import { FeedErrorBanner } from "@/components/home/feed-error-banner"; import { CONTINENTS, continentDisplayLabel } from "@/constants/regions"; -import { fetchSearchCountries } from "@/lib/api"; import { getAiFact, getCountryImages } from "@/lib/format-country"; import { openCountryInExplore } from "@/lib/open-country-in-explore"; import { openCountryOnMap } from "@/lib/open-country-on-map"; +import { + getCachedSearchResults, + searchCountriesWithCache, +} from "@/lib/search-countries"; import { useSearchUiStore } from "@/store/use-search-ui-store"; import type { Country } from "@/types/country"; @@ -48,6 +51,7 @@ export function SearchOverlay() { const inputRef = useRef(null); const debounceRef = useRef | null>(null); const lastRequestRef = useRef({ query: "", region: "" }); + const searchRequestIdRef = useRef(0); const loadRecentSearches = useCallback(async () => { try { @@ -89,23 +93,47 @@ export function SearchOverlay() { } lastRequestRef.current = { query: q, region: r }; - setStatus("loading"); + const requestId = ++searchRequestIdRef.current; setError(null); - try { - const { data } = await fetchSearchCountries( - q || undefined, - r || undefined, - ); - setResults(data); + const cached = await getCachedSearchResults(q, r); + if (requestId !== searchRequestIdRef.current) return; + + let showedCached = false; + if (cached) { + showedCached = true; + setResults(cached); setStatus("success"); + } else { + setStatus("loading"); + } + + try { + await searchCountriesWithCache(q, r, { + onCached: (data) => { + if (requestId !== searchRequestIdRef.current) return; + showedCached = true; + setResults(data); + setStatus("success"); + }, + onFetched: (data) => { + if (requestId !== searchRequestIdRef.current) return; + setResults(data); + setStatus("success"); + }, + }); + + if (requestId !== searchRequestIdRef.current) return; if (q) void saveRecentSearch(q); } catch (err) { - setResults([]); - setStatus("error"); - setError( - err instanceof Error ? err.message : "Search failed. Try again.", - ); + if (requestId !== searchRequestIdRef.current) return; + if (!showedCached) { + setResults([]); + setStatus("error"); + setError( + err instanceof Error ? err.message : "Search failed. Try again.", + ); + } } }, [saveRecentSearch], diff --git a/constants/client-cache.ts b/constants/client-cache.ts new file mode 100644 index 0000000..5ec418b --- /dev/null +++ b/constants/client-cache.ts @@ -0,0 +1,21 @@ +export const CLIENT_CACHE_SCHEMA_VERSION = 1; + +export const CLIENT_CACHE_KEYS = { + schemaVersion: "cache:meta:schemaVersion", + mapCountries: "cache:map:countries", + countryDetail: (name: string) => `cache:country:${name.trim().toLowerCase()}`, + feedFirstPage: "cache:feed:countries:cursor=all", + feedRegion: (region: string) => + `cache:feed:region:${region.trim().toLowerCase()}`, + search: (query: string, region: string) => + `cache:search:${query.trim().toLowerCase()}:${region.trim().toLowerCase()}`, +} as const; + +/** Align with prompts-worldloop TTLs where it matters. */ +export const CLIENT_CACHE_TTL = { + mapCountries: 30 * 24 * 60 * 60, // 30d — match backend map TTL + countryDetail: 7 * 24 * 60 * 60, // 7d — AI + images can change + feedFirstPage: 24 * 60 * 60, // 1d — feed order is shuffled server-side + feedRegion: 7 * 24 * 60 * 60, + search: 7 * 24 * 60 * 60, // 7d — match backend search TTL +} as const; diff --git a/constants/map-activity.ts b/constants/map-activity.ts index 5b32a77..b4e800f 100644 --- a/constants/map-activity.ts +++ b/constants/map-activity.ts @@ -1,17 +1,22 @@ import type { MapCountry } from "@/types/country"; export type MapClusterActivity = "rising" | "quiet"; -export function getClusterActivity(countries: MapCountry[]): MapClusterActivity { - // Rising: cluster contains any country in the top 20% by population (within cluster). - const sorted = [...countries].sort((a, b) => b.population - a.population); +export function getClusterActivity( + clusterCountries: MapCountry[], + globalCountries: MapCountry[], +): MapClusterActivity { + if (clusterCountries.length === 0) return "quiet"; + + // Rising: cluster contains any country in the global top 20% by population. + const sorted = [...globalCountries].sort( + (a, b) => b.population - a.population, + ); const topCount = Math.max(1, Math.ceil(sorted.length * 0.2)); const topNames = new Set(sorted.slice(0, topCount).map((c) => c.name)); - if (countries.some((c) => topNames.has(c.name))) { - return "rising"; - } - - return "quiet"; + return clusterCountries.some((c) => topNames.has(c.name)) + ? "rising" + : "quiet"; } export function getActivityVisual(activity: MapClusterActivity) { @@ -35,4 +40,3 @@ export function getActivityVisual(activity: MapClusterActivity) { }; } } - diff --git a/constants/map-boundary-style.ts b/constants/map-boundary-style.ts index 29cfed2..e70feb9 100644 --- a/constants/map-boundary-style.ts +++ b/constants/map-boundary-style.ts @@ -1,4 +1,4 @@ -import { hexToRgb, hslToRgb } from "@/lib/color-utils"; +import { hexToRgb, hslToRgb, type RgbColor } from "@/lib/color-utils"; export type MapZoomTier = "world" | "region" | "country"; export type MapFillColorMode = "hue" | "grayscale"; @@ -15,20 +15,58 @@ export type MapBoundaryStyleSettings = { fillEnabled: boolean; fillOpacityStep: number; fillColorMode: MapFillColorMode; - /** 0 = black, 100 = white (used when fillColorMode is grayscale). */ + /** 0 = black, 50 = gray, 100 = white (grayscale mode only). */ fillGrayLevel: number; fillColorHue: number; /** Optional exact hex override (allows grayscale like #000000 / #FFFFFF). */ fillColorHex: string | null; + /** Selected-country fill + stroke when a country is focused. */ + countryHighlightEnabled: boolean; + countryFillColorHue: number; + countryFillColorHex: string | null; + countryFillOpacityStep: number; + countryStrokeColorHue: number; + countryStrokeColorHex: string | null; + countryStrokeThicknessStep: number; + countryStrokeOpacityStep: number; }; -/** Amber gold default (#fbbf24 ≈ hue 43). */ +/** Amber gold default (#fbbf24 ≈ hue 43) — matches continent focus overlay. */ export const DEFAULT_STROKE_COLOR_HUE = 43; -export const DEFAULT_FILL_COLOR_HUE = 210; +export const DEFAULT_FILL_COLOR_HUE = DEFAULT_STROKE_COLOR_HUE; +export const DEFAULT_FILL_COLOR_HEX = "#FBBF24"; + +/** Amber gold default (#fbbf24) — matches map pins and continent overlay. */ +export const DEFAULT_COUNTRY_HIGHLIGHT_COLOR_HUE = DEFAULT_STROKE_COLOR_HUE; +export const DEFAULT_COUNTRY_HIGHLIGHT_COLOR_HEX = DEFAULT_FILL_COLOR_HEX; + +/** Legacy sky-cyan country highlight — migrated to pin amber on load. */ +const LEGACY_COUNTRY_HIGHLIGHT_COLOR_HUE = 199; +const LEGACY_COUNTRY_HIGHLIGHT_COLOR_HEX = "#38BDF8"; + +/** Pre-amber default fill hue (blue) — migrated on load. */ +const LEGACY_DEFAULT_FILL_COLOR_HUE = 210; + +export const CONTINENT_OVERLAY_FILL_DEFAULTS: Pick< + MapBoundaryStyleSettings, + "fillColorHue" | "fillColorHex" | "fillColorMode" +> = { + fillColorMode: "hue", + fillColorHue: DEFAULT_FILL_COLOR_HUE, + fillColorHex: DEFAULT_FILL_COLOR_HEX, +}; export const BOUNDARY_STEP_MIN = 1; export const BOUNDARY_STEP_MAX = 5; +/** Grayscale fill slider — 3 steps only: black, gray, white. */ +export const GRAY_LEVEL_STEP_MIN = 1; +export const GRAY_LEVEL_STEP_MAX = 3; + +export const FILL_GRAY_LEVEL_VALUES = [0, 50, 100] as const; + +export const FILL_GRAY_LEVEL_STEP_LABELS = ["Black", "Gray", "White"] as const; + export const FILL_OPACITY_STEP_LABELS = [ "Light", "Soft", @@ -47,12 +85,20 @@ export const DEFAULT_MAP_BOUNDARY_STYLE: MapBoundaryStyleSettings = { strokeThicknessStep: 1, /** Soft (25%) — subtle borders that stay out of the way. */ strokeOpacityStep: 2, - fillEnabled: false, + fillEnabled: true, fillOpacityStep: 2, fillColorMode: "hue", fillGrayLevel: 50, fillColorHue: DEFAULT_FILL_COLOR_HUE, - fillColorHex: null, + fillColorHex: DEFAULT_FILL_COLOR_HEX, + countryHighlightEnabled: true, + countryFillColorHue: DEFAULT_COUNTRY_HIGHLIGHT_COLOR_HUE, + countryFillColorHex: DEFAULT_COUNTRY_HIGHLIGHT_COLOR_HEX, + countryFillOpacityStep: 2, + countryStrokeColorHue: DEFAULT_COUNTRY_HIGHLIGHT_COLOR_HUE, + countryStrokeColorHex: DEFAULT_COUNTRY_HIGHLIGHT_COLOR_HEX, + countryStrokeThicknessStep: 4, + countryStrokeOpacityStep: 4, }; const STROKE_WIDTH_RANGE: Record = { @@ -76,7 +122,33 @@ function clampGrayLevel(value: unknown): number { if (typeof value !== "number" || !Number.isFinite(value)) { return DEFAULT_MAP_BOUNDARY_STYLE.fillGrayLevel; } - return Math.min(100, Math.max(0, Math.round(value))); + const rounded = Math.min(100, Math.max(0, Math.round(value))); + if (rounded <= 25) return FILL_GRAY_LEVEL_VALUES[0]; + if (rounded <= 75) return FILL_GRAY_LEVEL_VALUES[1]; + return FILL_GRAY_LEVEL_VALUES[2]; +} + +export function clampGrayLevelStep(value: number): number { + const rounded = Math.round(value); + return Math.min(GRAY_LEVEL_STEP_MAX, Math.max(GRAY_LEVEL_STEP_MIN, rounded)); +} + +export function grayLevelToStep(level: number): number { + const clamped = clampGrayLevel(level); + const index = FILL_GRAY_LEVEL_VALUES.indexOf( + clamped as (typeof FILL_GRAY_LEVEL_VALUES)[number], + ); + return index >= 0 ? index + GRAY_LEVEL_STEP_MIN : 2; +} + +export function grayLevelStepToGrayLevel(step: number): number { + const index = clampGrayLevelStep(step) - GRAY_LEVEL_STEP_MIN; + return FILL_GRAY_LEVEL_VALUES[index] ?? FILL_GRAY_LEVEL_VALUES[1]; +} + +export function getFillGrayLevelStepLabel(step: number): string { + const index = clampGrayLevelStep(step) - GRAY_LEVEL_STEP_MIN; + return FILL_GRAY_LEVEL_STEP_LABELS[index] ?? FILL_GRAY_LEVEL_STEP_LABELS[1]; } function parseGrayscaleFromHex(hex: string): number | null { @@ -184,6 +256,43 @@ function migrateLegacyThickness( return DEFAULT_MAP_BOUNDARY_STYLE.strokeThicknessStep; } +/** Upgrade persisted cyan country highlight to pin-matched amber. */ +export function migrateLegacyCountryHighlightColorToAmber( + settings: MapBoundaryStyleSettings, +): MapBoundaryStyleSettings { + const isLegacyCyan = + settings.countryFillColorHue === LEGACY_COUNTRY_HIGHLIGHT_COLOR_HUE && + (settings.countryFillColorHex === LEGACY_COUNTRY_HIGHLIGHT_COLOR_HEX || + settings.countryFillColorHex === null) && + settings.countryStrokeColorHue === LEGACY_COUNTRY_HIGHLIGHT_COLOR_HUE && + (settings.countryStrokeColorHex === LEGACY_COUNTRY_HIGHLIGHT_COLOR_HEX || + settings.countryStrokeColorHex === null); + if (!isLegacyCyan) return settings; + + return { + ...settings, + countryFillColorHue: DEFAULT_COUNTRY_HIGHLIGHT_COLOR_HUE, + countryFillColorHex: DEFAULT_COUNTRY_HIGHLIGHT_COLOR_HEX, + countryStrokeColorHue: DEFAULT_COUNTRY_HIGHLIGHT_COLOR_HUE, + countryStrokeColorHex: DEFAULT_COUNTRY_HIGHLIGHT_COLOR_HEX, + }; +} + +/** Upgrade persisted blue fill defaults to amber continent-overlay color. */ +export function migrateLegacyFillColorToAmber( + settings: MapBoundaryStyleSettings, +): MapBoundaryStyleSettings { + const isLegacyBlue = + settings.fillColorMode === "hue" && + settings.fillColorHue === LEGACY_DEFAULT_FILL_COLOR_HUE && + settings.fillColorHex === null; + if (!isLegacyBlue) return settings; + return { + ...settings, + ...CONTINENT_OVERLAY_FILL_DEFAULTS, + }; +} + export function normalizeBoundaryStyle( value: unknown, ): MapBoundaryStyleSettings { @@ -219,33 +328,62 @@ export function normalizeBoundaryStyle( ? "grayscale" : "hue"; - return { - strokeColorEnabled, - strokeColorHue: clampHue( - candidate.strokeColorHue ?? DEFAULT_MAP_BOUNDARY_STYLE.strokeColorHue, - ), - strokeColorHex: normalizeHexOverride(candidate.strokeColorHex), - strokeWidthEnabled: strokeColorEnabled - ? (candidate.strokeWidthEnabled ?? - DEFAULT_MAP_BOUNDARY_STYLE.strokeWidthEnabled) - : false, - strokeThicknessStep: migrateLegacyThickness(candidate), - strokeOpacityStep: clampBoundaryStep( - candidate.strokeOpacityStep ?? - DEFAULT_MAP_BOUNDARY_STYLE.strokeOpacityStep, - ), - fillEnabled: fill.fillEnabled, - fillOpacityStep: fill.fillOpacityStep, - fillColorMode, - fillGrayLevel: - fillColorMode === "grayscale" - ? (grayscaleFromHex ?? clampGrayLevel(candidate.fillGrayLevel)) - : clampGrayLevel(candidate.fillGrayLevel), - fillColorHue: clampHue( - candidate.fillColorHue ?? DEFAULT_MAP_BOUNDARY_STYLE.fillColorHue, - ), - fillColorHex: normalizedFillHex, - }; + return migrateLegacyCountryHighlightColorToAmber( + migrateLegacyFillColorToAmber({ + strokeColorEnabled, + strokeColorHue: clampHue( + candidate.strokeColorHue ?? DEFAULT_MAP_BOUNDARY_STYLE.strokeColorHue, + ), + strokeColorHex: normalizeHexOverride(candidate.strokeColorHex), + strokeWidthEnabled: strokeColorEnabled + ? (candidate.strokeWidthEnabled ?? + DEFAULT_MAP_BOUNDARY_STYLE.strokeWidthEnabled) + : false, + strokeThicknessStep: migrateLegacyThickness(candidate), + strokeOpacityStep: clampBoundaryStep( + candidate.strokeOpacityStep ?? + DEFAULT_MAP_BOUNDARY_STYLE.strokeOpacityStep, + ), + fillEnabled: fill.fillEnabled, + fillOpacityStep: fill.fillOpacityStep, + fillColorMode, + fillGrayLevel: + fillColorMode === "grayscale" + ? (grayscaleFromHex ?? clampGrayLevel(candidate.fillGrayLevel)) + : clampGrayLevel(candidate.fillGrayLevel), + fillColorHue: clampHue( + candidate.fillColorHue ?? DEFAULT_MAP_BOUNDARY_STYLE.fillColorHue, + ), + fillColorHex: normalizedFillHex, + countryHighlightEnabled: + candidate.countryHighlightEnabled ?? + DEFAULT_MAP_BOUNDARY_STYLE.countryHighlightEnabled, + countryFillColorHue: clampHue( + candidate.countryFillColorHue ?? + DEFAULT_MAP_BOUNDARY_STYLE.countryFillColorHue, + ), + countryFillColorHex: normalizeHexOverride(candidate.countryFillColorHex), + countryFillOpacityStep: clampBoundaryStep( + candidate.countryFillOpacityStep ?? + DEFAULT_MAP_BOUNDARY_STYLE.countryFillOpacityStep, + ), + countryStrokeColorHue: clampHue( + candidate.countryStrokeColorHue ?? + DEFAULT_MAP_BOUNDARY_STYLE.countryStrokeColorHue, + ), + countryStrokeColorHex: normalizeHexOverride( + candidate.countryStrokeColorHex, + ), + countryStrokeThicknessStep: clampBoundaryStep( + candidate.countryStrokeThicknessStep ?? + DEFAULT_MAP_BOUNDARY_STYLE.countryStrokeThicknessStep, + ), + countryStrokeOpacityStep: clampBoundaryStep( + candidate.countryStrokeOpacityStep ?? + DEFAULT_MAP_BOUNDARY_STYLE.countryStrokeOpacityStep, + ), + }), + ); } export function resolveBoundaryStrokeOpacity(step: number): number { @@ -289,29 +427,85 @@ export function resolveBoundaryFillOpacity(step: number): number { return unit * 0.8; } -export function resolveBoundaryFillColor( +export function isContinentOverlayActive( + focusedRegion: string | null | undefined, + previewRegion: string | null | undefined, +): boolean { + return !!(focusedRegion || previewRegion); +} + +/** Applies legacy blue→amber fill migration when continent overlay is active. */ +export function syncFillEnabledToContinentOverlay( + focusedRegion: string | null | undefined, + previewRegion: string | null | undefined, settings: MapBoundaryStyleSettings, -): string { - if (!settings.fillEnabled) { - return "rgba(0, 0, 0, 0)"; +): MapBoundaryStyleSettings | null { + if (!isContinentOverlayActive(focusedRegion, previewRegion)) { + return null; } + const withAmberFill = migrateLegacyFillColorToAmber(settings); + if ( + withAmberFill.fillColorHue === settings.fillColorHue && + withAmberFill.fillColorHex === settings.fillColorHex && + withAmberFill.fillColorMode === settings.fillColorMode + ) { + return null; + } + return withAmberFill; +} + +export function resolveBoundaryFillRgb( + settings: MapBoundaryStyleSettings, +): RgbColor { + const isGrayscale = settings.fillColorMode === "grayscale"; + const grayLevel = clampGrayLevel(settings.fillGrayLevel); + if (isGrayscale) { + const g = Math.round((grayLevel / 100) * 255); + return { r: g, g, b: g }; + } + return ( + (settings.fillColorHex && hexToRgb(settings.fillColorHex)) || + hslToRgb(settings.fillColorHue, 0.85, 0.58) + ); +} + +function resolveBoundaryFillAlpha(settings: MapBoundaryStyleSettings): number { const isGrayscale = settings.fillColorMode === "grayscale"; const grayLevel = clampGrayLevel(settings.fillGrayLevel); - const rgb = isGrayscale - ? (() => { - const g = Math.round((grayLevel / 100) * 255); - return { r: g, g, b: g }; - })() - : (settings.fillColorHex && hexToRgb(settings.fillColorHex)) || - hslToRgb(settings.fillColorHue, 0.85, 0.58); const baseOpacity = resolveBoundaryFillOpacity(settings.fillOpacityStep); - const opacity = isGrayscale + return isGrayscale ? Math.min(1, baseOpacity * (1 + ((100 - grayLevel) / 100) * 0.5)) : baseOpacity; +} + +export function resolveBoundaryFillColor( + settings: MapBoundaryStyleSettings, +): string { + if (!settings.fillEnabled) { + return "rgba(0, 0, 0, 0)"; + } + + const rgb = resolveBoundaryFillRgb(settings); + const opacity = resolveBoundaryFillAlpha(settings); return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${opacity})`; } +/** Continent focus overlay — same fill color/opacity as boundary fill, scaled by factor. */ +export function resolveContinentFocusFillRgba( + settings: MapBoundaryStyleSettings, + opacityFactor: number, +): string { + if (!settings.fillEnabled) { + return "rgba(0, 0, 0, 0)"; + } + + const rgb = resolveBoundaryFillRgb(settings); + const baseOpacity = resolveBoundaryFillAlpha(settings); + const alpha = Math.min(1, Math.max(0, baseOpacity * opacityFactor)); + return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`; +} + /** Clamp steps and align width toggle before persisting or live-previewing. */ export function applyBoundaryStyleDraft( draft: MapBoundaryStyleSettings, @@ -321,6 +515,14 @@ export function applyBoundaryStyleDraft( strokeThicknessStep: clampBoundaryStep(draft.strokeThicknessStep), strokeOpacityStep: clampBoundaryStep(draft.strokeOpacityStep), fillOpacityStep: clampBoundaryStep(draft.fillOpacityStep), + countryFillOpacityStep: clampBoundaryStep(draft.countryFillOpacityStep), + countryStrokeThicknessStep: clampBoundaryStep( + draft.countryStrokeThicknessStep, + ), + countryStrokeOpacityStep: clampBoundaryStep(draft.countryStrokeOpacityStep), + fillGrayLevel: grayLevelStepToGrayLevel( + grayLevelToStep(draft.fillGrayLevel), + ), strokeWidthEnabled: draft.strokeColorEnabled, }); } @@ -341,7 +543,15 @@ export function boundaryStyleHasChanges( draft.fillColorMode !== current.fillColorMode || draft.fillGrayLevel !== current.fillGrayLevel || draft.fillColorHue !== current.fillColorHue || - draft.fillColorHex !== current.fillColorHex + draft.fillColorHex !== current.fillColorHex || + draft.countryHighlightEnabled !== current.countryHighlightEnabled || + draft.countryFillColorHue !== current.countryFillColorHue || + draft.countryFillColorHex !== current.countryFillColorHex || + draft.countryFillOpacityStep !== current.countryFillOpacityStep || + draft.countryStrokeColorHue !== current.countryStrokeColorHue || + draft.countryStrokeColorHex !== current.countryStrokeColorHex || + draft.countryStrokeThicknessStep !== current.countryStrokeThicknessStep || + draft.countryStrokeOpacityStep !== current.countryStrokeOpacityStep ); } diff --git a/constants/map-continent-focus.ts b/constants/map-continent-focus.ts index 5850d6a..6da0a5a 100644 --- a/constants/map-continent-focus.ts +++ b/constants/map-continent-focus.ts @@ -14,17 +14,18 @@ export const MAP_FOCUS_SCRIM = { b: 43, } as const; -/** Target fill opacity at full blend (0–1). */ -export const MAP_CONTINENT_FOCUS_FILL_OPACITY = 0.18; -export const MAP_SCRIM_MAX_OPACITY = 0.12; +/** Target fill opacity at full blend (0–1) — matches boundary fill step 2 (0.20). */ +export const MAP_CONTINENT_FOCUS_FILL_OPACITY = 0.2; +/** World dim outside focused continent — strong enough to read at a glance. */ +export const MAP_SCRIM_MAX_OPACITY = 0.9; /** Softer continent wash when a country pin is already selected. */ -export const MAP_CONTINENT_FOCUS_FILL_OPACITY_WITH_COUNTRY = 0.11; -export const MAP_SCRIM_MAX_OPACITY_WITH_COUNTRY = 0.08; +export const MAP_CONTINENT_FOCUS_FILL_OPACITY_WITH_COUNTRY = 0.12; +export const MAP_SCRIM_MAX_OPACITY_WITH_COUNTRY = 0.2; /** Softer highlight while continent intent is pending (before commit). */ -export const MAP_CONTINENT_PREVIEW_FILL_OPACITY = 0.12; -export const MAP_CONTINENT_PREVIEW_SCRIM_OPACITY = 0.05; +export const MAP_CONTINENT_PREVIEW_FILL_OPACITY = 0.13; +export const MAP_CONTINENT_PREVIEW_SCRIM_OPACITY = 0.14; /** Pause before committing continent focus (ms). */ export const MAP_CONTINENT_INTENT_DELAY_MS = 200; @@ -54,3 +55,22 @@ export function mapFocusScrimRgba(alpha: number): string { const a = Math.min(1, Math.max(0, alpha)); return `rgba(${MAP_FOCUS_SCRIM.r}, ${MAP_FOCUS_SCRIM.g}, ${MAP_FOCUS_SCRIM.b}, ${a})`; } + +/** Solid scrim RGB for canvas dim layers (opacity applied on the view). */ +export const MAP_FOCUS_SCRIM_RGB = `rgb(${MAP_FOCUS_SCRIM.r}, ${MAP_FOCUS_SCRIM.g}, ${MAP_FOCUS_SCRIM.b})`; + +/** Scales boundary fill opacity for committed continent focus (with optional country pin). */ +export function continentFocusFillOpacityFactor( + withSelectedCountry: boolean, +): number { + if (!withSelectedCountry) return 1; + return ( + MAP_CONTINENT_FOCUS_FILL_OPACITY_WITH_COUNTRY / + MAP_CONTINENT_FOCUS_FILL_OPACITY + ); +} + +/** Scales boundary fill opacity for pending continent preview. */ +export function continentPreviewFillOpacityFactor(): number { + return MAP_CONTINENT_PREVIEW_FILL_OPACITY / MAP_CONTINENT_FOCUS_FILL_OPACITY; +} diff --git a/constants/map-country-focus.ts b/constants/map-country-focus.ts new file mode 100644 index 0000000..fb52b85 --- /dev/null +++ b/constants/map-country-focus.ts @@ -0,0 +1,155 @@ +import { + resolveBoundaryStrokeOpacity, + resolveBoundaryStrokeWidth, + type MapBoundaryStyleSettings, +} from "@/constants/map-boundary-style"; +import { + MAP_CONTINENT_FOCUS_POLYGON_Z, + MAP_FOCUS_ACCENT, +} from "@/constants/map-continent-focus"; +import { hexToRgb, hslToRgb, type RgbColor } from "@/lib/color-utils"; + +/** Selected-country fill — same amber as map pins (#fbbf24). */ +export const GLOBE_COUNTRY_FOCUS_FILL_RGB = MAP_FOCUS_ACCENT; + +/** Selected pin glow on 2D markers — country overlay uses the same alpha. */ +export const MAP_COUNTRY_FOCUS_FILL_OPACITY = 0.65; + +/** Globe 3D selected-country stroke — dark gray outline. */ +export const GLOBE_COUNTRY_FOCUS_STROKE_RGB = { + r: 82, + g: 82, + b: 82, +} as const; + +/** Minimum stroke width for the selected-country outline at detail zoom. */ +export const MAP_COUNTRY_FOCUS_STROKE_WIDTH_MIN = 1.4; + +export const MAP_COUNTRY_FOCUS_FADE_MS = 200; + +/** Above continent highlight fills. */ +export const MAP_COUNTRY_FOCUS_POLYGON_Z = MAP_CONTINENT_FOCUS_POLYGON_Z + 2; + +/** Boundary strokes render above the country fill. */ +export const MAP_COUNTRY_FOCUS_STROKE_Z = MAP_COUNTRY_FOCUS_POLYGON_Z + 1; + +function resolveCountryStrokeRgb(settings: MapBoundaryStyleSettings): RgbColor { + return ( + (settings.countryStrokeColorHex && + hexToRgb(settings.countryStrokeColorHex)) || + hslToRgb(settings.countryStrokeColorHue, 0.72, 0.62) + ); +} + +function resolveCountryFocusFillAlpha(blendFactor: number): number { + return Math.min(1, Math.max(0, MAP_COUNTRY_FOCUS_FILL_OPACITY * blendFactor)); +} + +export function resolveCountryFocusFillRgba( + settings: MapBoundaryStyleSettings, + blendFactor: number, +): string { + if (!settings.countryHighlightEnabled) { + return "rgba(0, 0, 0, 0)"; + } + + const { r, g, b } = GLOBE_COUNTRY_FOCUS_FILL_RGB; + const alpha = resolveCountryFocusFillAlpha(blendFactor); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +/** Globe selected-country fill — same amber + pin-matched opacity as 2D. */ +export function resolveGlobeCountryFocusFillRgba( + settings: MapBoundaryStyleSettings, + blendFactor: number, +): string { + return resolveCountryFocusFillRgba(settings, blendFactor); +} + +/** Brighter edge on the fill polygon so the outline stays visible at country zoom. */ +export function resolveCountryFocusStrokeRgba( + settings: MapBoundaryStyleSettings, + blendFactor: number, +): string { + if (!settings.countryHighlightEnabled || !settings.strokeColorEnabled) { + return "rgba(0, 0, 0, 0)"; + } + + const rgb = resolveCountryStrokeRgb(settings); + const baseOpacity = resolveBoundaryStrokeOpacity(settings.strokeOpacityStep); + const alpha = Math.min(1, Math.max(0, baseOpacity * blendFactor)); + return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`; +} + +/** Globe-only selected-country stroke — dark gray outline. */ +export function resolveGlobeCountryFocusStrokeRgba( + settings: MapBoundaryStyleSettings, + blendFactor: number, +): string { + if (!settings.countryHighlightEnabled || !settings.strokeColorEnabled) { + return "rgba(0, 0, 0, 0)"; + } + + const { r, g, b } = GLOBE_COUNTRY_FOCUS_STROKE_RGB; + const baseOpacity = resolveBoundaryStrokeOpacity( + settings.countryStrokeOpacityStep, + ); + const alpha = Math.min(1, Math.max(0, baseOpacity * blendFactor)); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +/** Selected-country boundary stroke layer — always visible at country zoom tier. */ +export function resolveCountryFocusBoundaryStrokeColor( + settings: MapBoundaryStyleSettings, +): string { + if (!settings.countryHighlightEnabled || !settings.strokeColorEnabled) { + return "rgba(0, 0, 0, 0)"; + } + + const rgb = resolveCountryStrokeRgb(settings); + const opacity = resolveBoundaryStrokeOpacity(settings.strokeOpacityStep); + return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${opacity})`; +} + +export function resolveCountryFocusBoundaryStrokeWidth( + settings: MapBoundaryStyleSettings, +): number { + if (!settings.countryHighlightEnabled || !settings.strokeColorEnabled) { + return 0; + } + + const width = resolveBoundaryStrokeWidth( + { + ...settings, + strokeColorEnabled: true, + strokeWidthEnabled: true, + strokeThicknessStep: settings.countryStrokeThicknessStep, + }, + "country", + ); + + return Math.max(width, MAP_COUNTRY_FOCUS_STROKE_WIDTH_MIN); +} + +export function resolveCountryFocusFillStrokeWidth( + settings: MapBoundaryStyleSettings, +): number { + if (!settings.countryHighlightEnabled || !settings.strokeColorEnabled) { + return 0; + } + + return Math.max( + resolveCountryFocusBoundaryStrokeWidth(settings) * 0.85, + MAP_COUNTRY_FOCUS_STROKE_WIDTH_MIN, + ); +} + +export function countryFocusStyleRenderKey( + settings: MapBoundaryStyleSettings, +): string { + return [ + resolveCountryFocusFillRgba(settings, 1), + resolveCountryFocusBoundaryStrokeColor(settings), + resolveCountryFocusBoundaryStrokeWidth(settings), + ].join("|"); +} diff --git a/constants/map-regions.ts b/constants/map-regions.ts index 6c5d031..095ddac 100644 --- a/constants/map-regions.ts +++ b/constants/map-regions.ts @@ -11,10 +11,7 @@ export const WORLD_INITIAL_REGION: Region = { longitudeDelta: 120, }; -export function regionForCountry( - latlng: [number, number], - delta = 18, -): Region { +export function regionForCountry(latlng: [number, number], delta = 18): Region { return { latitude: latlng[0], longitude: latlng[1], @@ -31,6 +28,53 @@ export function regionForMapCountry( return regionForCountry(getMapDisplayLatLng(country), delta); } +/** World-scale viewport centered on a country (phase 2 of rotate-then-pan at world zoom). */ +export function regionForWorldViewCountry( + country: Pick, +): Region { + const [lat, lng] = getMapDisplayLatLng(country); + return { + latitude: lat, + longitude: lng, + latitudeDelta: WORLD_INITIAL_REGION.latitudeDelta, + longitudeDelta: WORLD_INITIAL_REGION.longitudeDelta, + }; +} + +function normalizeLongitude(longitude: number): number { + if (!Number.isFinite(longitude)) return WORLD_INITIAL_REGION.longitude; + const wrapped = ((((longitude + 180) % 360) + 360) % 360) - 180; + return Object.is(wrapped, -0) ? 0 : wrapped; +} + +/** Shortest east/west path between two meridians (degrees). */ +export function shortestLongitudeDelta(fromLng: number, toLng: number): number { + let delta = toLng - fromLng; + if (delta > 180) delta -= 360; + if (delta < -180) delta += 360; + return delta; +} + +/** + * World zoom — phase 1 of rotate-then-pan: spin toward the country's meridian + * while keeping the current latitude. + */ +export function regionForWorldViewRotateToCountry( + country: Pick, + fromRegion: Region = WORLD_INITIAL_REGION, +): Region { + const [, targetLng] = getMapDisplayLatLng(country); + return { + latitude: fromRegion.latitude, + longitude: normalizeLongitude( + fromRegion.longitude + + shortestLongitudeDelta(fromRegion.longitude, targetLng), + ), + latitudeDelta: WORLD_INITIAL_REGION.latitudeDelta, + longitudeDelta: WORLD_INITIAL_REGION.longitudeDelta, + }; +} + /** Framed for the Antarctic continent + nearby island territories. */ export const ANTARCTIC_FOCUS_REGION: Region = { latitude: -72, diff --git a/hooks/use-map-flight.ts b/hooks/use-map-flight.ts index 00a4556..9325169 100644 --- a/hooks/use-map-flight.ts +++ b/hooks/use-map-flight.ts @@ -1,8 +1,6 @@ import { useCallback, useEffect, useMemo, useRef } from "react"; import type { Region } from "react-native-maps"; -import { logMapDebug, summarizeRegion } from "@/lib/map-debug"; - export type FlightPhase = { region: Region; duration: number; @@ -15,6 +13,11 @@ type UseMapFlightParams = { onActiveChange?: (active: boolean) => void; }; +export type FlightCancelOptions = { + /** When true, stale timers/runId are cleared without firing active=false. */ + keepActive?: boolean; +}; + export type MapFlightController = { /** * Run an ordered phase sequence (e.g. world -> continent -> country). @@ -22,7 +25,7 @@ export type MapFlightController = { */ flyTo: (phases: FlightPhase[], onComplete?: () => void) => void; /** Cancel pending phases and mark the current flight stale. */ - cancel: () => void; + cancel: (options?: FlightCancelOptions) => void; isActive: () => boolean; }; @@ -60,18 +63,19 @@ export function useMapFlight({ [onActiveChange], ); - const cancel = useCallback(() => { - const cancelledRunId = runIdRef.current; - const wasActive = activeRef.current; - clearTimers(); - runIdRef.current += 1; - setActive(false); - logMapDebug("flight", "cancel", { - cancelledRunId, - nextRunId: runIdRef.current, - wasActive, - }); - }, [clearTimers, setActive]); + const cancel = useCallback( + (options?: FlightCancelOptions) => { + const cancelledRunId = runIdRef.current; + const wasActive = activeRef.current; + const keepActive = options?.keepActive === true; + clearTimers(); + runIdRef.current += 1; + if (!keepActive) { + setActive(false); + } + }, + [clearTimers, setActive], + ); const flyTo = useCallback( (phases: FlightPhase[], onComplete?: () => void) => { @@ -85,34 +89,12 @@ export function useMapFlight({ const runId = ++runIdRef.current; setActive(true); - logMapDebug("flight", "flyTo start", { - runId, - wasActive, - phaseCount: phases.length, - phases: phases.map((p, i) => ({ - index: i, - duration: p.duration, - region: summarizeRegion(p.region), - })), - }); - let elapsed = 0; phases.forEach((phase, index) => { const issue = () => { if (runIdRef.current !== runId) { - logMapDebug("flight", "phase skipped (stale run)", { - runId, - currentRunId: runIdRef.current, - phaseIndex: index, - }); return; } - logMapDebug("flight", "phase issue animateToRegion", { - runId, - phaseIndex: index, - duration: phase.duration, - region: summarizeRegion(phase.region), - }); animateToRegion(phase.region, phase.duration); }; @@ -131,16 +113,14 @@ export function useMapFlight({ elapsed += phase.duration + PHASE_GAP_MS; }); - const totalDuration = Math.max(0, elapsed - PHASE_GAP_MS + SETTLE_BUFFER_MS); + const totalDuration = Math.max( + 0, + elapsed - PHASE_GAP_MS + SETTLE_BUFFER_MS, + ); const completeId = setTimeout(() => { if (runIdRef.current !== runId) { - logMapDebug("flight", "complete skipped (stale run)", { - runId, - currentRunId: runIdRef.current, - }); return; } - logMapDebug("flight", "flyTo settled", { runId, totalDuration }); setActive(false); onComplete?.(); }, totalDuration); diff --git a/hooks/use-map-logic.ts b/hooks/use-map-logic.ts new file mode 100644 index 0000000..20caef8 --- /dev/null +++ b/hooks/use-map-logic.ts @@ -0,0 +1,2506 @@ +import * as Haptics from "expo-haptics"; +import { useFocusEffect, useNavigation } from "expo-router"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type RefObject, +} from "react"; +import { InteractionManager } from "react-native"; +import type { Region } from "react-native-maps"; + +import { type GlobeCameraViewState } from "@/components/map/globe-view"; +import { type MapCanvasHandle } from "@/components/map/map-canvas"; +import { syncFillEnabledToContinentOverlay } from "@/constants/map-boundary-style"; +import { + regionForMapCountry, + WORLD_INITIAL_REGION, +} from "@/constants/map-regions"; +import { useContinentIntent } from "@/hooks/use-continent-intent"; +import { useMapFlight } from "@/hooks/use-map-flight"; +import { + CROSS_REGION_OVERLAY_LAG_MS, + CROSS_REGION_REVEAL_EXTRA_DELAY_MS, + MARKER_REGION_SWAP_CLEAR_DELAY_MS, + useMapMarkerReveal, +} from "@/hooks/use-map-marker-reveal"; +import { logGlobeTap } from "@/lib/globe-tap-debug"; +import { + deriveCameraZoomState, + resolveGlobeDistanceFromLatitudeDelta, +} from "@/lib/map-camera-zoom"; +import { buildMapClusters, type MapCluster } from "@/lib/map-clusters"; +import { getMapDisplayLatLng, isValidLatLng } from "@/lib/map-country"; +import { getCountryBoundaryPolygons } from "@/lib/map-country-boundaries"; +import { summarizeRegion } from "@/lib/map-debug"; +import { buildDiscoveryPhases } from "@/lib/map-discovery-flight"; +import { resolveExternalMapFocusEligibility } from "@/lib/map-external-focus"; +import { + findClusterAtWorldCoordinate, + resolveGlobeSurfaceTapCountry, + resolveMapCountryAtCoordinate, + type MapPressCoordinate, +} from "@/lib/map-map-tap-hit"; +import { + resolveFlatTransitionRestore, + resolveMapModeTogglePending, +} from "@/lib/map-mode-transition"; +import { + isCountryPreviewOpen, + showCountryFocusPill, + showRegionChrome, +} from "@/lib/map-presentation"; +import { + commitMapPresentation, + dismissMapPreview, + resetMapPresentation, +} from "@/lib/map-presentation-transition"; +import { + isRandomPickGenerationCurrent, + shouldAcceptRandomFabTap, +} from "@/lib/map-random-fab"; +import { + buildMapRandomPool, + pickRandomMapCountry, + resolveMapRandomUseWorldPool, +} from "@/lib/map-random-pick"; +import { + shouldSyncFocusedRegionForCountry, + shouldSyncFocusedRegionForSelectionSource, + syncMapRegionFocusForCountry, +} from "@/lib/map-region-focus"; +import { + GLOBE_DETAIL_CAMERA_DISTANCE, + GLOBE_REGION_CAMERA_DISTANCE, + GLOBE_WORLD_CAMERA_DISTANCE, + REGION_FOCUS_INITIAL_DELTA, + resolveGlobeCountryTargetDistance, + resolveRegionMarkerCountries, +} from "@/lib/map-region-markers"; +import { + REGION_SWITCH_HYSTERESIS_MS, + resolveRegionSettleDecision, + shouldCommitScheduledRegionSwitch, +} from "@/lib/map-region-settle"; +import { + canFocusContinentFromMapTap, + canRefocusContinentFromMapTap, + flightRegionForClusterFocus, + shouldDelegateMapTapToCountrySelection, + shouldSelectCountryAcrossFocusedContinentFromMapTap, + shouldSelectCountryInFocusedContinentFromMapTap, + shouldShowFeaturedChipsInMapChrome, + shouldShowMapOnboarding, +} from "@/lib/map-signal-sources"; +import { + isGlobeMapUi, + resolveStableMapViewTransition, + syncMapViewTransitionForMode, + type MapViewTransition, +} from "@/lib/map-view-transition"; +import { useCountryFeedStore } from "@/store/use-country-feed-store"; +import { useExperienceStore } from "@/store/use-experience-store"; +import { + useIdentityStore, + type SelectionSource, +} from "@/store/use-identity-store"; +import { useMapPresentationStore } from "@/store/use-map-presentation-store"; +import { + filterMapCountriesByChip, + useMapStore, + type MapMode, +} from "@/store/use-map-store"; +import { useMapUiStore } from "@/store/use-map-ui-store"; +import { useRecentlyViewedStore } from "@/store/use-recently-viewed-store"; +import { useSavedCountriesStore } from "@/store/use-saved-countries-store"; +import type { MapCountry } from "@/types/country"; +import type { MapPresentationMode } from "@/types/map-presentation"; + +const countriesGeoJson = require("@/assets/geo/ne_50m_admin_0_countries/ne_50m_admin_0_countries.json"); + +/** Selected country must stay in the marker list (Explore → Map can freeze before region sync). */ +function withRequiredMapMarker( + markers: MapCountry[], + countryName: string | null, + pool: MapCountry[], +): MapCountry[] { + if (!countryName) return markers; + if (markers.some((c) => c.name === countryName)) return markers; + + const extra = + pool.find((c) => c.name === countryName) ?? + useIdentityStore.getState().activeCountry; + if (!extra || extra.name !== countryName) return markers; + + return [...markers, extra]; +} + +export function useMapLogic(mapRef: RefObject) { + const navigation = useNavigation(); + /** True while a programmatic camera flight is sequencing (controller-driven). */ + const isMapAnimatingRef = useRef(false); + const [isMapAnimating, setIsMapAnimating] = useState(false); + /** Supersedes stale async random/shuffle pool resolutions when taps overlap. */ + const randomPickGenerationRef = useRef(0); + /** Timestamp of the last accepted random-FAB tap — throttles rapid taps. */ + const lastRandomFabTapAtRef = useRef(0); + const lastShuffleTapAtRef = useRef(0); + const shufflePickGenerationRef = useRef(0); + /** Monotonic id — only the latest navigation intent may drive the camera. */ + const navigationIntentIdRef = useRef(0); + const pendingExploreRegionSyncRef = useRef(null); + const exploreHandoffSuppressMarkersRef = useRef(false); + const [exploreHandoffSuppressMarkers, setExploreHandoffSuppressMarkers] = + useState(false); + const [markerPrepareSwapToken, setMarkerPrepareSwapToken] = useState(0); + const [suppressMarkersForRegionSwap, setSuppressMarkersForRegionSwap] = + useState(false); + /** Keeps marker reveal frozen until deferred focusedRegion commits (2D cross-region). */ + const [holdRevealForCrossRegion, setHoldRevealForCrossRegion] = + useState(false); + const [markerRegionClearDelayMs, setMarkerRegionClearDelayMs] = useState( + MARKER_REGION_SWAP_CLEAR_DELAY_MS, + ); + /** Defers focusedRegion commit until marker removal has settled (2D cross-region). */ + const continentNavSwapTimerRef = useRef | null>( + null, + ); + /** + * 2D cross-region continent nav: fly first, commit focusedRegion only after + * the camera settles. Updating focusedRegion during animateToRegion remounts + * continent polygons and crashes react-native-maps on iOS. + */ + const pendingCrossRegionFocusRef = useRef<{ + cluster: MapCluster; + intentId: number; + } | null>(null); + const [markerRefreshToken, setMarkerRefreshToken] = useState(0); + /** Blocks world-zoom reset while animating into a continent/country focus. */ + const suppressWorldResetRef = useRef(false); + const pendingRegionSwitchTimerRef = useRef | null>(null); + const pendingRegionCandidateRef = useRef(null); + const explicitRegionLockRef = useRef<{ + region: string; + anchor: [number, number]; + } | null>(null); + const pendingGlobeFocusNameRef = useRef(null); + /** Paired with `pendingGlobeFocusNameRef` — same framing as immediate `applyCountryIntent`. */ + const pendingGlobeFocusDistanceRef = useRef(undefined); + /** Continent to center on the globe after 2D → 3D when no country is selected. */ + const pendingGlobeRegionFocusRef = useRef(null); + /** Apply flat-map zoom/center to the globe after 2D → 3D with no pending country/continent flight. */ + const pendingGlobeViewportSyncRef = useRef(false); + /** Country to focus on the 2D map after 3D → 2D crossfade completes. */ + const pendingFlatFocusNameRef = useRef(null); + /** Presentation mode to restore after 3D → 2D crossfade completes. */ + const pendingFlatPresentationModeRef = useRef( + null, + ); + /** Map mode when preview opened — restored on dismiss if it drifted. */ + const mapModeAtPreviewOpenRef = useRef(null); + const prevPreviewOpenRef = useRef(false); + /** Prevents duplicate external-focus camera flights from focus + effect racing. */ + const externalFocusAppliedRef = useRef(null); + /** 2D MapView is interactive — external flights must wait or they no-op silently. */ + const flatMapReadyRef = useRef(false); + const [flatMapReadyToken, setFlatMapReadyToken] = useState(0); + /** Live flat-map zoom (latitudeDelta) — synchronous reads for callbacks. */ + const flatLatitudeDeltaRef = useRef(WORLD_INITIAL_REGION.latitudeDelta); + /** Live globe distance — synchronous reads for region settle on 3D. */ + const globeCameraDistanceRef = useRef(GLOBE_WORLD_CAMERA_DISTANCE); + /** Settles globe focuses (3D camera move isn't tracked by the flat controller). */ + const globeSettleTimerRef = useRef | null>( + null, + ); + /** Debounced region settle when the globe camera stops moving (2D parity). */ + const globeRegionSettleTimerRef = useRef | null>(null); + /** When true, preview dismiss returns to continent zoom instead of world. */ + const [previewDismissToContinent, setPreviewDismissToContinent] = + useState(false); + const [tapRippleAt, setTapRippleAt] = useState( + null, + ); + const [tapRippleToken, setTapRippleToken] = useState(0); + const [focusTransitionCountryName, setFocusTransitionCountryName] = useState< + string | null + >(null); + const [randomCountryHint, setRandomCountryHint] = useState( + null, + ); + /** + * Country just deselected (X / map tap) while staying in continent mode. The + * focal pin isn't always part of the region's marker subset, so we keep it + * pinned as a normal flag until we leave its region — otherwise it vanishes. + */ + const [lingeringDeselectedName, setLingeringDeselectedName] = useState< + string | null + >(null); + + const status = useMapStore((s) => s.status); + const error = useMapStore((s) => s.error); + const activeCountry = useIdentityStore((s) => s.activeCountry); + const clearActiveCountry = useIdentityStore((s) => s.clearActiveCountry); + const activeChip = useMapStore((s) => s.activeChip); + const countries = useMapStore((s) => s.countries); + const mapMode = useMapStore((s) => s.mapMode); + const loadMapCountries = useMapStore((s) => s.loadMapCountries); + const mapCountriesFullyLoaded = useMapStore((s) => s.mapCountriesFullyLoaded); + const setActiveChip = useMapStore((s) => s.setActiveChip); + const setMapMode = useMapStore((s) => s.setMapMode); + const focusCountryOnGlobe = useMapStore((s) => s.focusCountryOnGlobe); + const focusLatLngOnGlobe = useMapStore((s) => s.focusLatLngOnGlobe); + const clearPendingMapIntent = useMapStore((s) => s.clearPendingMapIntent); + const pendingMapIntent = useMapStore((s) => s.pendingMapIntent); + const globeCamera = useMapStore((s) => s.globeCamera); + + const presentationMode = useMapPresentationStore((s) => s.mode); + const setPresentationMode = useMapPresentationStore((s) => s.setMode); + + const pulsing = useExperienceStore((s) => s.pulsing); + const endExperienceTransition = useExperienceStore((s) => s.endTransition); + const resetExperience = useExperienceStore((s) => s.resetExperience); + + const focusedRegion = useMapUiStore((s) => s.focusedRegion); + /** Lags continent focus polygons behind focusedRegion after cross-region flights. */ + const [continentOverlayRegion, setContinentOverlayRegion] = useState< + string | null + >(focusedRegion); + const continentOverlayLagTimerRef = useRef | null>(null); + const setDisplayMode = useMapUiStore((s) => s.setDisplayMode); + const setFocusedRegion = useMapUiStore((s) => s.setFocusedRegion); + const setFeaturedShortcut = useMapUiStore((s) => s.setFeaturedShortcut); + const setBoundaryStyle = useMapUiStore((s) => s.setBoundaryStyle); + const resetGlobalPulse = useMapUiStore((s) => s.resetGlobalPulse); + const hasSeenMapOnboarding = useMapUiStore((s) => s.hasSeenMapOnboarding); + const hasSeenRandomCountryHint = useMapUiStore( + (s) => s.hasSeenRandomCountryHint, + ); + const dismissMapOnboarding = useMapUiStore((s) => s.dismissMapOnboarding); + const dismissRandomCountryHint = useMapUiStore( + (s) => s.dismissRandomCountryHint, + ); + const countryMarkerMode = useMapUiStore((s) => s.countryMarkerMode); + + const [mapViewTransition, setMapViewTransition] = useState( + () => resolveStableMapViewTransition(useMapStore.getState().mapMode), + ); + const [isPreviewShufflePending, setIsPreviewShufflePending] = useState(false); + const [flatLatitudeDelta, setFlatLatitudeDelta] = useState( + WORLD_INITIAL_REGION.latitudeDelta, + ); + const [globeCameraDistance, setGlobeCameraDistance] = useState( + GLOBE_WORLD_CAMERA_DISTANCE, + ); + /** Seeds the 3D canvas distance when entering globe mode (2D latitudeDelta sync). */ + const [globeEntryCameraDistance, setGlobeEntryCameraDistance] = useState( + GLOBE_WORLD_CAMERA_DISTANCE, + ); + const [globeViewCenter, setGlobeViewCenter] = useState({ + latitude: 0, + longitude: -30, + }); + const [lastMapRegion, setLastMapRegion] = useState(WORLD_INITIAL_REGION); + const is3d = isGlobeMapUi(mapMode, mapViewTransition); + + useEffect(() => { + const syncTransitionForMode = (mode: MapMode) => { + setMapViewTransition((current) => + syncMapViewTransitionForMode(mode, current), + ); + }; + + syncTransitionForMode(useMapStore.getState().mapMode); + + return useMapStore.persist.onFinishHydration(() => { + syncTransitionForMode(useMapStore.getState().mapMode); + }); + }, []); + + /** Single source of truth for zoom-driven UI + marker density (live camera). */ + const cameraZoomState = useMemo( + () => + deriveCameraZoomState({ + is3d, + latitudeDelta: flatLatitudeDelta, + globeDistance: globeCameraDistance, + }), + [is3d, flatLatitudeDelta, globeCameraDistance], + ); + const cameraTier = cameraZoomState.tier; + const isDetailZoom = cameraZoomState.isDetailZoom; + + useEffect(() => { + globeCameraDistanceRef.current = globeCameraDistance; + }, [globeCameraDistance]); + + const handleGlobeTransitionComplete = useCallback(() => { + setMapViewTransition("ready"); + }, []); + const featuredShortcut = useMapUiStore((s) => s.featuredShortcut); + + const clusters = useMemo(() => buildMapClusters(countries), [countries]); + + const allBoundaryPolygons = getCountryBoundaryPolygons(countriesGeoJson); + + const activeCountryName = activeCountry?.name ?? null; + const focalMarkerName = + activeCountryName ?? focusTransitionCountryName ?? null; + + const pinCountries = useMemo(() => { + if (exploreHandoffSuppressMarkers) { + if (!activeCountryName) return []; + const pin = countries.find((c) => c.name === activeCountryName) ?? null; + return pin ? [pin] : []; + } + + if (!focusedRegion) { + if (is3d) { + if (!activeCountryName) return []; + const pin = countries.find((c) => c.name === activeCountryName) ?? null; + return pin ? [pin] : []; + } + return []; + } + + const base = countries.filter((c) => c.region === focusedRegion); + const visible = filterMapCountriesByChip(base, activeChip); + + // Density follows the live camera zoom, not the selection or flight phase. + const showAllRegionMarkers = + isDetailZoom || (pulsing && !!activeCountryName); + + return showAllRegionMarkers + ? visible + : resolveRegionMarkerCountries(visible, false, focalMarkerName); + }, [ + activeChip, + activeCountryName, + countries, + exploreHandoffSuppressMarkers, + focalMarkerName, + focusedRegion, + is3d, + isDetailZoom, + pulsing, + ]); + + const markerViewportCenter = useMemo(() => { + if (focalMarkerName && !isDetailZoom) { + const focal = countries.find((c) => c.name === focalMarkerName) ?? null; + if (focal) { + const [lat, lng] = getMapDisplayLatLng(focal); + if (isValidLatLng([lat, lng])) { + return { latitude: lat, longitude: lng }; + } + } + } + + if (!is3d) { + return { + latitude: lastMapRegion.latitude, + longitude: lastMapRegion.longitude, + }; + } + + const cluster = focusedRegion + ? (clusters.find((c) => c.region === focusedRegion) ?? null) + : null; + + // Stable anchor at continent zoom — globe rotation must not reshuffle markers. + if (cluster && globeCameraDistance > GLOBE_DETAIL_CAMERA_DISTANCE) { + return { + latitude: cluster.center[0], + longitude: cluster.center[1], + }; + } + + return globeViewCenter; + }, [ + clusters, + countries, + focalMarkerName, + focusedRegion, + globeCameraDistance, + globeViewCenter, + is3d, + isDetailZoom, + lastMapRegion.latitude, + lastMapRegion.longitude, + ]); + + const markerReveal = useMapMarkerReveal({ + candidateCountries: pinCountries, + viewportCenter: markerViewportCenter, + focusedRegion, + focalCountryName: focalMarkerName, + isDetailZoom, + enabled: !!focusedRegion, + // Freeze marker mounts during flat flights — marker churn overlapping + // animateToRegion crashes react-native-maps on iOS. Also hold through the + // gap after flyTo settles when focusedRegion is still deferred (cross-region). + paused: !is3d && (isMapAnimating || holdRevealForCrossRegion), + prepareSwapToken: markerPrepareSwapToken, + regionClearDelayMs: markerRegionClearDelayMs, + }); + + useEffect(() => { + if (continentOverlayLagTimerRef.current) return; + setContinentOverlayRegion(focusedRegion); + }, [focusedRegion]); + + const mapMarkerCountries = useMemo(() => { + const withSelected = withRequiredMapMarker( + markerReveal.countriesToRender, + activeCountryName ?? focusTransitionCountryName, + countries, + ); + // Keep the just-deselected country visible (as a plain flag) until we leave + // its region, so pressing X doesn't make the focal pin disappear. + return withRequiredMapMarker( + withSelected, + lingeringDeselectedName, + countries, + ); + }, [ + activeCountryName, + countries, + focusTransitionCountryName, + lingeringDeselectedName, + markerReveal.countriesToRender, + ]); + + // Density adapts live during flights — no frozen snapshot. + const mapMarkersForCanvas = suppressMarkersForRegionSwap + ? [] + : mapMarkerCountries; + + // Drop the lingering deselected pin once the camera leaves its region + // (world reset or a different continent), so it doesn't stick around. + useEffect(() => { + if (!lingeringDeselectedName) return; + const country = + countries.find((c) => c.name === lingeringDeselectedName) ?? null; + if (!focusedRegion || (country && country.region !== focusedRegion)) { + setLingeringDeselectedName(null); + } + }, [countries, focusedRegion, lingeringDeselectedName]); + + const selectedMapName = activeCountryName ?? focusTransitionCountryName; + + const isPreviewOpen = isCountryPreviewOpen(presentationMode, activeCountry); + + useEffect(() => { + if (isPreviewOpen && !prevPreviewOpenRef.current) { + mapModeAtPreviewOpenRef.current = mapMode; + } else if (!isPreviewOpen && prevPreviewOpenRef.current) { + mapModeAtPreviewOpenRef.current = null; + } + prevPreviewOpenRef.current = isPreviewOpen; + }, [isPreviewOpen, mapMode]); + + useEffect(() => { + if (mapCountriesFullyLoaded || status === "loading") return; + void loadMapCountries(); + }, [mapCountriesFullyLoaded, status, loadMapCountries]); + + /** Navigate-first external entry: always fetch the full map list even if one country was injected. */ + useEffect(() => { + if (!pendingMapIntent || mapCountriesFullyLoaded || status === "loading") { + return; + } + void loadMapCountries(); + }, [pendingMapIntent, mapCountriesFullyLoaded, status, loadMapCountries]); + + /** Camera flight active-state — drives pulse end + per-marker snapshot smoothing. */ + const handleFlightActiveChange = useCallback( + (active: boolean) => { + isMapAnimatingRef.current = active; + setIsMapAnimating(active); + if (!active) { + setIsPreviewShufflePending(false); + setFocusTransitionCountryName(null); + endExperienceTransition(); + setMarkerRefreshToken((token) => token + 1); + + if (exploreHandoffSuppressMarkersRef.current) { + exploreHandoffSuppressMarkersRef.current = false; + setExploreHandoffSuppressMarkers(false); + const pending = pendingExploreRegionSyncRef.current; + pendingExploreRegionSyncRef.current = null; + if (pending) { + syncMapRegionFocusForCountry(pending, { explicitFocus: true }); + } + } + } + }, + [activeCountry?.name, endExperienceTransition, focusTransitionCountryName], + ); + + const flatAnimator = useCallback((region: Region, duration: number) => { + const summary = summarizeRegion(region); + if (!summary.finite) return; + mapRef.current?.animateToRegion(region, duration); + }, []); + + const flight = useMapFlight({ + animateToRegion: flatAnimator, + onActiveChange: handleFlightActiveChange, + }); + + /** Stops flight timers without clearing animating (for retargeting). */ + const cancelFlightPhases = useCallback(() => { + flight.cancel({ keepActive: true }); + if (globeSettleTimerRef.current) { + clearTimeout(globeSettleTimerRef.current); + globeSettleTimerRef.current = null; + } + }, [flight]); + + /** Stops any active flight (flat phases or globe settle) and clears the busy flag. */ + const cancelCameraFlight = useCallback( + (options?: { keepAnimating?: boolean }) => { + const keepAnimating = options?.keepAnimating === true; + if (keepAnimating) { + cancelFlightPhases(); + } else { + flight.cancel(); + if (globeSettleTimerRef.current) { + clearTimeout(globeSettleTimerRef.current); + globeSettleTimerRef.current = null; + } + if (globeRegionSettleTimerRef.current) { + clearTimeout(globeRegionSettleTimerRef.current); + globeRegionSettleTimerRef.current = null; + } + isMapAnimatingRef.current = false; + setIsMapAnimating(false); + } + }, + [cancelFlightPhases, flight], + ); + + const clearPendingRegionSwitch = useCallback(() => { + if (pendingRegionSwitchTimerRef.current) { + clearTimeout(pendingRegionSwitchTimerRef.current); + pendingRegionSwitchTimerRef.current = null; + } + pendingRegionCandidateRef.current = null; + }, []); + + const lockExplicitRegion = useCallback( + (region: string, anchor: [number, number]) => { + explicitRegionLockRef.current = { region, anchor }; + clearPendingRegionSwitch(); + }, + [clearPendingRegionSwitch], + ); + + const clearExplicitRegionLock = useCallback(() => { + explicitRegionLockRef.current = null; + clearPendingRegionSwitch(); + }, [clearPendingRegionSwitch]); + + const syncRegionFocusForCountry = useCallback( + ( + pick: MapCountry, + options?: { + explicitFocus?: boolean; + source?: Exclude; + }, + ) => { + const focusedRegion = useMapUiStore.getState().focusedRegion; + const shouldSync = + options?.explicitFocus !== undefined + ? shouldSyncFocusedRegionForCountry(pick, focusedRegion, { + explicitFocus: options.explicitFocus, + }) + : options?.source + ? shouldSyncFocusedRegionForSelectionSource( + pick, + focusedRegion, + options.source, + ) + : shouldSyncFocusedRegionForCountry(pick, focusedRegion); + const synced = shouldSync + ? syncMapRegionFocusForCountry(pick, { explicitFocus: true }) + : false; + + if (synced) { + lockExplicitRegion(pick.region, getMapDisplayLatLng(pick)); + } else if (focusedRegion) { + const cluster = + clusters.find((cluster) => cluster.region === focusedRegion) ?? null; + if (cluster) { + lockExplicitRegion(focusedRegion, cluster.center); + } + } + + suppressWorldResetRef.current = true; + }, + [clusters, lockExplicitRegion], + ); + + const clearFocusTransition = useCallback(() => { + setFocusTransitionCountryName(null); + }, []); + + const clearCountrySelection = useCallback(() => { + resetMapPresentation(); + clearFocusTransition(); + clearActiveCountry(); + resetExperience(); + setLingeringDeselectedName(null); + }, [clearActiveCountry, clearFocusTransition, resetExperience]); + + const resolveNearestRegionByLatLng = useCallback( + (centerLat: number, centerLng: number): string | null => { + if (clusters.length === 0) return null; + + let nearest: MapCluster | null = null; + let best = Number.POSITIVE_INFINITY; + + for (const cluster of clusters) { + const dLat = cluster.center[0] - centerLat; + const dLng = cluster.center[1] - centerLng; + const d = dLat * dLat + dLng * dLng; + if (d < best) { + best = d; + nearest = cluster; + } + } + + return nearest?.region ?? null; + }, + [clusters], + ); + + const resolveNearestRegionByCenter = useCallback( + (region: Region): string | null => + resolveNearestRegionByLatLng(region.latitude, region.longitude), + [resolveNearestRegionByLatLng], + ); + + const flightCancelRef = useRef(flight.cancel); + flightCancelRef.current = flight.cancel; + + /** Unmount cleanup only — do not depend on `flight` identity (it changes each render). */ + useEffect(() => { + return () => { + clearPendingRegionSwitch(); + if (continentNavSwapTimerRef.current) { + clearTimeout(continentNavSwapTimerRef.current); + continentNavSwapTimerRef.current = null; + } + pendingCrossRegionFocusRef.current = null; + if (continentOverlayLagTimerRef.current) { + clearTimeout(continentOverlayLagTimerRef.current); + continentOverlayLagTimerRef.current = null; + } + setSuppressMarkersForRegionSwap(false); + setHoldRevealForCrossRegion(false); + flightCancelRef.current(); + if (globeSettleTimerRef.current) { + clearTimeout(globeSettleTimerRef.current); + globeSettleTimerRef.current = null; + } + if (globeRegionSettleTimerRef.current) { + clearTimeout(globeRegionSettleTimerRef.current); + globeRegionSettleTimerRef.current = null; + } + isMapAnimatingRef.current = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional unmount-only cleanup + }, []); + + const onContinentCommitRef = useRef<(cluster: MapCluster) => void>(() => {}); + + const { + previewRegion, + requestContinentFocus: requestContinentFocusInner, + cancelIntent, + } = useContinentIntent({ + onCommit: (cluster) => onContinentCommitRef.current(cluster), + focusedRegion, + }); + + const boundaryFocusRegion = focusedRegion ?? previewRegion; + + const requestContinentFocus = useCallback( + (cluster: MapCluster) => { + if ( + !canRefocusContinentFromMapTap( + focusedRegion, + cluster.region, + cameraTier, + ) + ) { + return; + } + requestContinentFocusInner(cluster); + }, + [cameraTier, focusedRegion, requestContinentFocusInner], + ); + + type ContinentNavOptions = { + /** Default true. False for same-continent recenter. */ + updateFocusedRegion?: boolean; + /** Default true. False when caller already cleared (preview exit). */ + clearSelection?: boolean; + duration?: number; + /** Optional globe camera distance (preview exit uses region framing). */ + globeDistance?: number; + source?: string; + }; + + const commitContinentNavigation = useCallback( + (cluster: MapCluster, options: ContinentNavOptions = {}) => { + const { + updateFocusedRegion = true, + clearSelection = true, + duration = 650, + globeDistance, + source = "unknown", + } = options; + + navigationIntentIdRef.current += 1; + const intentId = navigationIntentIdRef.current; + pendingCrossRegionFocusRef.current = null; + setHoldRevealForCrossRegion(false); + setMarkerRegionClearDelayMs(MARKER_REGION_SWAP_CLEAR_DELAY_MS); + + cancelIntent(); + + if (continentNavSwapTimerRef.current) { + clearTimeout(continentNavSwapTimerRef.current); + continentNavSwapTimerRef.current = null; + } + + const previousRegion = useMapUiStore.getState().focusedRegion; + const isCrossRegion2d = + !is3d && + updateFocusedRegion && + previousRegion !== null && + previousRegion !== cluster.region; + + if (clearSelection) { + clearCountrySelection(); + setFeaturedShortcut(null); + } + setDisplayMode("explore"); + suppressWorldResetRef.current = true; + clearPendingRegionSwitch(); + + const focusRegion = flightRegionForClusterFocus(cluster); + + const commitRegionAndFly = () => { + if (navigationIntentIdRef.current !== intentId) { + return; + } + + isMapAnimatingRef.current = true; + setIsMapAnimating(true); + + const commitFocusedRegion = () => { + if (!updateFocusedRegion) return; + if (navigationIntentIdRef.current !== intentId) return; + const pending = pendingCrossRegionFocusRef.current; + if (pending && pending.intentId !== intentId) return; + const afterCrossRegionFlight = !!pending; + pendingCrossRegionFocusRef.current = null; + setSuppressMarkersForRegionSwap(false); + setHoldRevealForCrossRegion(false); + setFocusedRegion(cluster.region); + + if (afterCrossRegionFlight) { + setMarkerRegionClearDelayMs( + MARKER_REGION_SWAP_CLEAR_DELAY_MS + + CROSS_REGION_REVEAL_EXTRA_DELAY_MS, + ); + if (continentOverlayLagTimerRef.current) { + clearTimeout(continentOverlayLagTimerRef.current); + } + continentOverlayLagTimerRef.current = setTimeout(() => { + continentOverlayLagTimerRef.current = null; + setContinentOverlayRegion(cluster.region); + }, CROSS_REGION_OVERLAY_LAG_MS); + return; + } + + if (continentOverlayLagTimerRef.current) { + clearTimeout(continentOverlayLagTimerRef.current); + continentOverlayLagTimerRef.current = null; + } + setContinentOverlayRegion(cluster.region); + }; + + // Commit focusedRegion BEFORE the flight (transaction order: + // focusedRegion → reveal reset → flyTo), exactly like country nav. This + // remounts continent polygons in their own commit, never overlapping the + // animateToRegion that follows on the next interaction frame. Cross-region + // markers were already cleared by the prepare-swap token, so committing + // here cannot churn pins against the camera move. + if (updateFocusedRegion) { + commitFocusedRegion(); + } + + lockExplicitRegion(cluster.region, cluster.center); + setLastMapRegion(focusRegion); + + if (is3d) { + if (globeSettleTimerRef.current) { + clearTimeout(globeSettleTimerRef.current); + } + globeSettleTimerRef.current = setTimeout(() => { + globeSettleTimerRef.current = null; + handleFlightActiveChange(false); + }, duration + 300); + + const [lat, lng] = cluster.center; + if (Number.isFinite(lat) && Number.isFinite(lng)) { + const resolvedDistance = + globeDistance ?? GLOBE_REGION_CAMERA_DISTANCE; + focusLatLngOnGlobe(lat, lng, duration, resolvedDistance); + } + return; + } + + InteractionManager.runAfterInteractions(() => { + requestAnimationFrame(() => { + if (navigationIntentIdRef.current !== intentId) { + return; + } + flight.flyTo([{ region: focusRegion, duration }]); + }); + }); + }; + + if (isCrossRegion2d) { + pendingCrossRegionFocusRef.current = { cluster, intentId }; + setHoldRevealForCrossRegion(true); + setSuppressMarkersForRegionSwap(true); + // Continent retargets must keep animating=true — firing animating=false + // here unpauses the reveal mid-swap and resurrects the stale region's pins. + cancelCameraFlight({ keepAnimating: true }); + isMapAnimatingRef.current = true; + setIsMapAnimating(true); + setMarkerPrepareSwapToken((token) => token + 1); + + continentNavSwapTimerRef.current = setTimeout(() => { + continentNavSwapTimerRef.current = null; + commitRegionAndFly(); + }, MARKER_REGION_SWAP_CLEAR_DELAY_MS); + return; + } + + isMapAnimatingRef.current = true; + setIsMapAnimating(true); + cancelCameraFlight({ keepAnimating: true }); + commitRegionAndFly(); + }, + [ + cancelCameraFlight, + cancelIntent, + clearCountrySelection, + clearPendingRegionSwitch, + flight, + focusLatLngOnGlobe, + handleFlightActiveChange, + is3d, + lockExplicitRegion, + setDisplayMode, + setFeaturedShortcut, + setFocusedRegion, + ], + ); + + const commitClusterFocus = useCallback( + (cluster: MapCluster) => { + commitContinentNavigation(cluster, { source: "cluster" }); + }, + [commitContinentNavigation], + ); + + onContinentCommitRef.current = commitClusterFocus; + + // Migrate legacy overlay fill color when continent focus/preview is active. + useEffect(() => { + const { boundaryStyle } = useMapUiStore.getState(); + const nextStyle = syncFillEnabledToContinentOverlay( + focusedRegion, + previewRegion, + boundaryStyle, + ); + if (nextStyle) { + setBoundaryStyle(nextStyle); + } + }, [focusedRegion, previewRegion, setBoundaryStyle]); + + const resolveCountryFlightDuration = useCallback( + (source: Exclude, useGlobeCamera: boolean) => { + if (source === "explore" || source === "fab") { + return useGlobeCamera ? 1400 : 900; + } + const baseDuration = + source === "mapTap" ? (useGlobeCamera ? 450 : 500) : 650; + return useGlobeCamera ? Math.max(baseDuration, 1100) : baseDuration; + }, + [], + ); + + const armGlobeFlightAnimation = useCallback( + (durationMs: number) => { + isMapAnimatingRef.current = true; + setIsMapAnimating(true); + if (globeSettleTimerRef.current) { + clearTimeout(globeSettleTimerRef.current); + } + globeSettleTimerRef.current = setTimeout(() => { + globeSettleTimerRef.current = null; + handleFlightActiveChange(false); + }, durationMs + 300); + }, + [handleFlightActiveChange], + ); + + /** 3D: rotate world + zoom to a country or continent frame (fixed camera). */ + const flyGlobeToCountryFrame = useCallback( + (country: MapCountry, targetDistance: number, durationMs = 650) => { + if (mapMode !== "3d" || mapViewTransition !== "ready") { + return; + } + const [lat, lng] = getMapDisplayLatLng(country); + if (!Number.isFinite(lat) || !Number.isFinite(lng)) { + return; + } + armGlobeFlightAnimation(durationMs); + focusLatLngOnGlobe(lat, lng, durationMs, targetDistance); + }, + [armGlobeFlightAnimation, focusLatLngOnGlobe, mapMode, mapViewTransition], + ); + + const GLOBE_MODE_TOGGLE_FLIGHT_MS = 900; + + /** After 2D → 3D: country focus, continent focus (region distance), or flat zoom sync. */ + useEffect(() => { + if (mapMode !== "3d" || mapViewTransition !== "ready" || !globeCamera) { + return; + } + + const flatDelta = flatLatitudeDeltaRef.current; + + const focusName = pendingGlobeFocusNameRef.current; + if (focusName) { + const targetDistance = + pendingGlobeFocusDistanceRef.current ?? + resolveGlobeDistanceFromLatitudeDelta(flatDelta); + pendingGlobeFocusNameRef.current = null; + pendingGlobeFocusDistanceRef.current = undefined; + pendingGlobeRegionFocusRef.current = null; + pendingGlobeViewportSyncRef.current = false; + + const pick = countries.find((c) => c.name === focusName) ?? null; + if (pick) { + const [lat, lng] = getMapDisplayLatLng(pick); + if (Number.isFinite(lat) && Number.isFinite(lng)) { + armGlobeFlightAnimation(GLOBE_MODE_TOGGLE_FLIGHT_MS); + setGlobeCameraDistance(targetDistance); + focusLatLngOnGlobe( + lat, + lng, + GLOBE_MODE_TOGGLE_FLIGHT_MS, + targetDistance, + ); + } + } + return; + } + + const focusRegion = pendingGlobeRegionFocusRef.current; + if (focusRegion) { + const cluster = clusters.find((c) => c.region === focusRegion) ?? null; + pendingGlobeRegionFocusRef.current = null; + pendingGlobeViewportSyncRef.current = false; + + if (cluster) { + const [lat, lng] = cluster.center; + if (Number.isFinite(lat) && Number.isFinite(lng)) { + const targetDistance = GLOBE_REGION_CAMERA_DISTANCE; + armGlobeFlightAnimation(GLOBE_MODE_TOGGLE_FLIGHT_MS); + setGlobeCameraDistance(targetDistance); + focusLatLngOnGlobe( + lat, + lng, + GLOBE_MODE_TOGGLE_FLIGHT_MS, + targetDistance, + ); + } + } + return; + } + + if (!pendingGlobeViewportSyncRef.current) { + return; + } + pendingGlobeViewportSyncRef.current = false; + + const targetDistance = resolveGlobeDistanceFromLatitudeDelta(flatDelta); + const { latitude, longitude } = lastMapRegion; + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { + return; + } + + const viewportFlightMs = 600; + armGlobeFlightAnimation(viewportFlightMs); + setGlobeCameraDistance(targetDistance); + focusLatLngOnGlobe(latitude, longitude, viewportFlightMs, targetDistance); + }, [ + armGlobeFlightAnimation, + clusters, + countries, + focusLatLngOnGlobe, + globeCamera, + lastMapRegion, + mapMode, + mapViewTransition, + ]); + + /** 2D hybridFlyover / Android standard — animateToRegion framing. */ + const flyFlatToCountryFrame = useCallback( + ( + country: MapCountry, + framing: "continent" | "country", + durationMs = 650, + ) => { + if (is3d || !flatMapReadyRef.current) { + return; + } + isMapAnimatingRef.current = true; + setIsMapAnimating(true); + suppressWorldResetRef.current = true; + const focusedRegion = useMapUiStore.getState().focusedRegion; + if (shouldSyncFocusedRegionForCountry(country, focusedRegion)) { + syncMapRegionFocusForCountry(country); + } + + const region = + framing === "continent" + ? regionForMapCountry(country, REGION_FOCUS_INITIAL_DELTA) + : regionForMapCountry(country); + + InteractionManager.runAfterInteractions(() => { + requestAnimationFrame(() => { + flight.flyTo([{ region, duration: durationMs }]); + }); + }); + }, + [flight, is3d, syncMapRegionFocusForCountry], + ); + + /** Preview/detail vs continent framing — works in 2D (hybridFlyover) and 3D. */ + const flyMapToCountryFrame = useCallback( + ( + country: MapCountry, + framing: "continent" | "country", + durationMs = 650, + ) => { + if (is3d) { + const targetDistance = + framing === "country" + ? GLOBE_DETAIL_CAMERA_DISTANCE + : GLOBE_REGION_CAMERA_DISTANCE; + flyGlobeToCountryFrame(country, targetDistance, durationMs); + return; + } + flyFlatToCountryFrame(country, framing, durationMs); + }, + [flyFlatToCountryFrame, flyGlobeToCountryFrame, is3d], + ); + + const handleFlatMapReady = useCallback(() => { + if (flatMapReadyRef.current) return; + flatMapReadyRef.current = true; + setFlatMapReadyToken((token) => token + 1); + }, []); + + /** Single-flight 2D move to a country/continent frame (used post 3D→2D restore). */ + const focusCountryOnFlatMap = useCallback( + ( + pick: MapCountry, + duration = 650, + framing: "continent" | "country" = "country", + ) => { + setDisplayMode("explore"); + suppressWorldResetRef.current = true; + setFocusedRegion(pick.region); + lockExplicitRegion(pick.region, getMapDisplayLatLng(pick)); + + const region = + framing === "continent" + ? regionForMapCountry(pick, REGION_FOCUS_INITIAL_DELTA) + : regionForMapCountry(pick); + flight.flyTo([{ region, duration }]); + }, + [flight, lockExplicitRegion, setDisplayMode, setFocusedRegion], + ); + + /** + * Commits selection + presentation immediately (never waits on the camera), + * then runs the structural three-phase flight. Interruptible/retargetable: + * a new intent supersedes any in-flight sequence. + */ + const applyCountryIntent = useCallback( + ( + pick: MapCountry, + mode: "focus" | "preview", + source: Exclude, + ) => { + navigationIntentIdRef.current += 1; + const intentId = navigationIntentIdRef.current; + cancelIntent(); + isMapAnimatingRef.current = true; + setIsMapAnimating(true); + cancelCameraFlight({ keepAnimating: true }); + // A fresh focus supersedes any lingering deselected pin. + setLingeringDeselectedName(null); + + if (source === "fab") { + setFocusedRegion(null); + setContinentOverlayRegion(null); + setDisplayMode("globalPulse"); + clearExplicitRegionLock(); + } + + // "Back to continent" is offered only when we were already exploring a region. + setPreviewDismissToContinent( + source !== "explore" && + source !== "fab" && + !!useMapUiStore.getState().focusedRegion, + ); + if (source !== "explore" && source !== "fab") { + setDisplayMode("explore"); + } + + // Intent commits synchronously — focus pill / preview update right away. + commitMapPresentation({ + country: pick, + mode, + source, + }); + setFocusTransitionCountryName(pick.name); + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + + const useGlobeCamera = + mapMode === "3d" && mapViewTransition !== "enteringFlat"; + const deferExploreRegionMarkers = source === "explore" && !useGlobeCamera; + + if (deferExploreRegionMarkers) { + exploreHandoffSuppressMarkersRef.current = true; + setExploreHandoffSuppressMarkers(true); + pendingExploreRegionSyncRef.current = pick; + suppressWorldResetRef.current = true; + } else if (source !== "fab") { + syncRegionFocusForCountry(pick, { source }); + } + + if (useGlobeCamera) { + const globeDuration = resolveCountryFlightDuration(source, true); + // The flat flight controller doesn't drive the globe — track the + // settle window manually so pulse/transition end like a flat flight. + if (globeSettleTimerRef.current) { + clearTimeout(globeSettleTimerRef.current); + } + globeSettleTimerRef.current = setTimeout(() => { + globeSettleTimerRef.current = null; + handleFlightActiveChange(false); + }, globeDuration + 300); + + const targetDistance = resolveGlobeCountryTargetDistance( + mode, + source, + globeCameraDistance, + ); + + if ( + mapViewTransition !== "ready" || + !useMapStore.getState().globeCamera + ) { + pendingGlobeFocusNameRef.current = pick.name; + pendingGlobeFocusDistanceRef.current = targetDistance; + return; + } + const [lat, lng] = getMapDisplayLatLng(pick); + if (Number.isFinite(lat) && Number.isFinite(lng)) { + focusLatLngOnGlobe(lat, lng, globeDuration, targetDistance); + } + return; + } + + const cluster = clusters.find((c) => c.region === pick.region) ?? null; + const phases = buildDiscoveryPhases({ + pick, + cluster, + source, + includeWorld: source === "search", + mode, + }); + + // Defer the camera move until React has committed this intent's marker + // changes (paused reveal + new selection). Starting animateToRegion in the + // same frame as a marker mount crashes iOS maps. + InteractionManager.runAfterInteractions(() => { + requestAnimationFrame(() => { + if (navigationIntentIdRef.current !== intentId) { + return; + } + flight.flyTo(phases); + }); + }); + }, + [ + activeCountry, + cancelCameraFlight, + cancelIntent, + clearExplicitRegionLock, + clusters, + flight, + focusLatLngOnGlobe, + globeCameraDistance, + handleFlightActiveChange, + lockExplicitRegion, + mapMode, + mapViewTransition, + resolveCountryFlightDuration, + setDisplayMode, + setFocusedRegion, + syncRegionFocusForCountry, + ], + ); + + const focusCountryOnMap = useCallback( + (pick: MapCountry, source: Exclude = "mapTap") => { + applyCountryIntent(pick, "focus", source); + }, + [applyCountryIntent], + ); + + const openCountryPreview = useCallback(() => { + const country = useIdentityStore.getState().activeCountry; + setPresentationMode("preview"); + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + if (country) { + // Match 2D preview open — quick country framing, not the long globe flight. + flyMapToCountryFrame(country, "country", 520); + } + }, [flyMapToCountryFrame, setPresentationMode]); + + /** Preview sheet only — keeps the current viewport zoom and pan. */ + const openCountryPreviewAtViewport = useCallback(() => { + setPresentationMode("preview"); + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + }, [setPresentationMode]); + + /** 2D: select at current viewport. 3D: rotate globe to center the country (keep zoom). */ + const selectCountryAtViewport = useCallback( + (pick: MapCountry) => { + cancelIntent(); + cancelCameraFlight(); + setLingeringDeselectedName(null); + setPreviewDismissToContinent(!!useMapUiStore.getState().focusedRegion); + setDisplayMode("explore"); + commitMapPresentation({ + country: pick, + mode: "focus", + source: "mapTap", + }); + syncRegionFocusForCountry(pick); + clearFocusTransition(); + endExperienceTransition(); + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + + const useGlobeCamera = + mapMode === "3d" && mapViewTransition !== "enteringFlat"; + if (!useGlobeCamera) { + isMapAnimatingRef.current = false; + setIsMapAnimating(false); + return; + } + + const panDuration = resolveCountryFlightDuration("mapTap", true); + const [lat, lng] = getMapDisplayLatLng(pick); + if (!Number.isFinite(lat) || !Number.isFinite(lng)) { + isMapAnimatingRef.current = false; + setIsMapAnimating(false); + return; + } + + if ( + mapViewTransition !== "ready" || + !useMapStore.getState().globeCamera + ) { + pendingGlobeFocusNameRef.current = pick.name; + pendingGlobeFocusDistanceRef.current = globeCameraDistance; + return; + } + + armGlobeFlightAnimation(panDuration); + focusLatLngOnGlobe(lat, lng, panDuration, undefined); + }, + [ + armGlobeFlightAnimation, + cancelCameraFlight, + cancelIntent, + clearFocusTransition, + endExperienceTransition, + focusLatLngOnGlobe, + globeCameraDistance, + mapMode, + mapViewTransition, + resolveCountryFlightDuration, + setDisplayMode, + syncRegionFocusForCountry, + ], + ); + + const handleBoundaryCountryPress = useCallback( + (country: MapCountry) => { + const isSelected = activeCountry?.name === country.name; + + if (isSelected && isPreviewOpen) { + if (is3d) { + logGlobeTap({ + source: "controller", + stage: "skip", + outcome: "preview-already-open", + country: country.name, + region: country.region, + focusedRegion, + boundaryFocusRegion, + cameraTier, + globeDistance: globeCameraDistance, + }); + } + return; + } + if (isSelected) { + if (is3d) { + logGlobeTap({ + source: "controller", + stage: "action", + outcome: "open-preview", + country: country.name, + region: country.region, + focusedRegion, + boundaryFocusRegion, + cameraTier, + globeDistance: globeCameraDistance, + }); + } + openCountryPreviewAtViewport(); + return; + } + if (is3d) { + logGlobeTap({ + source: "controller", + stage: "action", + outcome: "select-country", + country: country.name, + region: country.region, + focusedRegion, + boundaryFocusRegion, + cameraTier, + globeDistance: globeCameraDistance, + }); + } + selectCountryAtViewport(country); + }, + [ + activeCountry, + boundaryFocusRegion, + cameraTier, + focusedRegion, + globeCameraDistance, + is3d, + isPreviewOpen, + openCountryPreviewAtViewport, + selectCountryAtViewport, + ], + ); + + const advanceToCountryPreview = useCallback( + (pick: MapCountry, source: Exclude = "shuffle") => { + applyCountryIntent(pick, "preview", source); + }, + [applyCountryIntent], + ); + + const clearCountryFocus = useCallback(() => { + // Exiting country focus but staying in the continent — keep the pin around + // as a normal flag so it doesn't blink out from under the camera. + const currentCountry = useIdentityStore.getState().activeCountry; + const deselectedName = currentCountry?.name ?? null; + + cancelCameraFlight(); + resetMapPresentation(); + clearFocusTransition(); + clearActiveCountry(); + resetExperience(); + setLingeringDeselectedName( + deselectedName && useMapUiStore.getState().focusedRegion + ? deselectedName + : null, + ); + }, [ + cancelCameraFlight, + clearActiveCountry, + clearFocusTransition, + resetExperience, + ]); + + const showTapRipple = useCallback((coordinate: MapPressCoordinate) => { + setTapRippleAt(coordinate); + setTapRippleToken((token) => token + 1); + }, []); + + const handleMapModeToggle = useCallback(() => { + cancelIntent(); + const pending = resolveMapModeTogglePending({ + currentMode: mapMode, + activeCountryName, + focusTransitionCountryName, + focusedRegion, + presentationMode, + }); + + pendingFlatFocusNameRef.current = pending.pendingFlatFocusName; + pendingFlatPresentationModeRef.current = + pending.pendingFlatPresentationMode; + pendingGlobeFocusNameRef.current = pending.pendingGlobeFocusName; + pendingGlobeRegionFocusRef.current = pending.pendingGlobeRegionFocus; + pendingGlobeViewportSyncRef.current = + mapMode === "2d" && + !pending.pendingGlobeFocusName && + !pending.pendingGlobeRegionFocus; + + if (mapMode === "2d") { + const entryDistance = resolveGlobeDistanceFromLatitudeDelta( + flatLatitudeDeltaRef.current, + ); + setGlobeEntryCameraDistance(entryDistance); + setGlobeCameraDistance(entryDistance); + } + + if (mapMode === "3d") { + setMapMode("2d"); + setMapViewTransition("enteringFlat"); + return; + } + + setMapMode("3d"); + setMapViewTransition("enteringGlobe"); + }, [ + activeCountryName, + cancelIntent, + focusTransitionCountryName, + focusedRegion, + mapMode, + presentationMode, + setMapMode, + ]); + + /** Shared flat/globe region settle — commits focusedRegion after explore-tier pan/zoom. */ + const applyRegionSettleFromViewport = useCallback( + (viewport: { + mapCenter: { latitude: number; longitude: number }; + latitudeDelta?: number; + globeDistance?: number; + useGlobeDistance?: boolean; + settleEnabled: boolean; + }) => { + const decision = resolveRegionSettleDecision({ + latitudeDelta: viewport.latitudeDelta ?? flatLatitudeDeltaRef.current, + globeDistance: viewport.globeDistance ?? globeCameraDistanceRef.current, + useGlobeDistance: viewport.useGlobeDistance ?? false, + mapCenter: viewport.mapCenter, + settleEnabled: viewport.settleEnabled, + isMapAnimating: isMapAnimatingRef.current, + suppressWorldReset: suppressWorldResetRef.current, + explicitLock: explicitRegionLockRef.current, + currentFocusedRegion: useMapUiStore.getState().focusedRegion, + nearestRegion: resolveNearestRegionByLatLng( + viewport.mapCenter.latitude, + viewport.mapCenter.longitude, + ), + pendingCandidate: pendingRegionCandidateRef.current, + }); + + if (decision.kind === "skip") { + return; + } + + if (decision.kind === "world_tier") { + clearPendingRegionSwitch(); + if (decision.resetWorld) { + clearExplicitRegionLock(); + resetGlobalPulse(); + clearCountrySelection(); + } + return; + } + + if (decision.clearSuppressWorldReset) { + suppressWorldResetRef.current = false; + } + + if (decision.setExploreMode) { + setDisplayMode("explore"); + } + + if (decision.releaseExplicitLock) { + explicitRegionLockRef.current = null; + } + + if (decision.holdExplicitLock || !decision.nearestRegion) { + if (decision.clearPending) { + clearPendingRegionSwitch(); + } + return; + } + + if (decision.keepPendingCandidate) { + return; + } + + if (decision.clearPending) { + clearPendingRegionSwitch(); + } + + if (!decision.scheduleRegionSwitch) { + return; + } + + const nearestRegion = decision.scheduleRegionSwitch; + pendingRegionCandidateRef.current = nearestRegion; + pendingRegionSwitchTimerRef.current = setTimeout(() => { + if ( + !shouldCommitScheduledRegionSwitch({ + latitudeDelta: flatLatitudeDeltaRef.current, + globeDistance: globeCameraDistanceRef.current, + useGlobeDistance: viewport.useGlobeDistance ?? false, + pendingCandidate: pendingRegionCandidateRef.current, + expectedRegion: nearestRegion, + }) + ) { + return; + } + setFocusedRegion(nearestRegion); + pendingRegionCandidateRef.current = null; + pendingRegionSwitchTimerRef.current = null; + }, REGION_SWITCH_HYSTERESIS_MS); + }, + [ + clearCountrySelection, + clearExplicitRegionLock, + clearPendingRegionSwitch, + resetGlobalPulse, + resolveNearestRegionByLatLng, + setDisplayMode, + setFocusedRegion, + ], + ); + + /** Globe camera updates zoom/detail only — continent exploration stays in UI store until explicit exit. */ + const handleGlobeCameraViewChange = useCallback( + (state: GlobeCameraViewState) => { + // Globe may still mount during crossfade — ignore camera ticks unless 3D is active. + if (mapMode !== "3d" || mapViewTransition !== "ready") { + return; + } + + globeCameraDistanceRef.current = state.distance; + setGlobeCameraDistance((prev) => { + if (Math.abs(prev - state.distance) < 0.06) return prev; + return state.distance; + }); + + setGlobeViewCenter((prev) => { + const dLat = Math.abs(prev.latitude - state.centerLat); + const dLng = Math.abs(prev.longitude - state.centerLng); + if (dLat < 0.25 && dLng < 0.25) return prev; + return { + latitude: state.centerLat, + longitude: state.centerLng, + }; + }); + + if (globeRegionSettleTimerRef.current) { + clearTimeout(globeRegionSettleTimerRef.current); + } + globeRegionSettleTimerRef.current = setTimeout(() => { + globeRegionSettleTimerRef.current = null; + applyRegionSettleFromViewport({ + mapCenter: { + latitude: state.centerLat, + longitude: state.centerLng, + }, + globeDistance: state.distance, + useGlobeDistance: true, + settleEnabled: true, + }); + }, REGION_SWITCH_HYSTERESIS_MS); + }, + [applyRegionSettleFromViewport, mapMode, mapViewTransition], + ); + + /** Live (throttled) flat viewport — feeds the camera zoom signal continuously. */ + const handleFlatRegionChange = useCallback((region: Region) => { + flatLatitudeDeltaRef.current = region.latitudeDelta; + setFlatLatitudeDelta(region.latitudeDelta); + setLastMapRegion(region); + }, []); + + const handleRegionChangeComplete = useCallback( + (region: Region) => { + flatLatitudeDeltaRef.current = region.latitudeDelta; + setFlatLatitudeDelta(region.latitudeDelta); + setLastMapRegion(region); + + applyRegionSettleFromViewport({ + mapCenter: { latitude: region.latitude, longitude: region.longitude }, + latitudeDelta: region.latitudeDelta, + settleEnabled: !is3d, + }); + }, + [applyRegionSettleFromViewport, is3d], + ); + + const handleFlatTransitionComplete = useCallback(() => { + const pendingFocusName = pendingFlatFocusNameRef.current; + const pendingPresentationMode = pendingFlatPresentationModeRef.current; + pendingFlatFocusNameRef.current = null; + pendingFlatPresentationModeRef.current = null; + + setMapMode("2d"); + setMapViewTransition("idle"); + + const restore = resolveFlatTransitionRestore({ + pendingFocusName, + pendingPresentationMode, + activeCountryName: + useIdentityStore.getState().activeCountry?.name ?? null, + }); + + if (!restore || countries.length === 0) { + return; + } + + const pick = countries.find((c) => c.name === restore.focusName) ?? null; + if (!pick) { + return; + } + + if (restore.restorePreview) { + setPresentationMode("preview"); + } + + focusCountryOnFlatMap(pick, 650, restore.framing); + }, [countries, focusCountryOnFlatMap, setMapMode, setPresentationMode]); + + const applyPendingExternalMapFocus = useCallback(() => { + const intent = useMapStore.getState().pendingMapIntent; + const mapState = useMapStore.getState(); + if (!intent || countries.length === 0) return; + + const pick = countries.find((c) => c.name === intent.countryName) ?? null; + const useGlobeCamera = + mapMode === "3d" && mapViewTransition !== "enteringFlat"; + + const eligibility = resolveExternalMapFocusEligibility({ + intent, + countriesFullyLoaded: mapState.mapCountriesFullyLoaded, + countryFound: !!pick, + alreadyAppliedCountryName: externalFocusAppliedRef.current, + useGlobeCamera, + flatMapReady: flatMapReadyRef.current, + globeReady: + mapViewTransition === "ready" && !!useMapStore.getState().globeCamera, + }); + + if (!eligibility.eligible) { + if (eligibility.deferReason) { + } + return; + } + + if (!pick) return; + + externalFocusAppliedRef.current = intent.countryName; + + setActiveChip("all"); + setFeaturedShortcut(null); + + applyCountryIntent(pick, intent.mode, intent.source); + clearPendingMapIntent(); + }, [ + applyCountryIntent, + clearPendingMapIntent, + countries, + mapMode, + mapViewTransition, + setActiveChip, + setFeaturedShortcut, + ]); + + useEffect(() => { + if (!pendingMapIntent) { + externalFocusAppliedRef.current = null; + } + }, [pendingMapIntent]); + + useFocusEffect( + useCallback(() => { + applyPendingExternalMapFocus(); + }, [applyPendingExternalMapFocus]), + ); + + useEffect(() => { + if (pendingMapIntent) { + applyPendingExternalMapFocus(); + } + }, [ + applyPendingExternalMapFocus, + flatMapReadyToken, + globeCamera, + mapCountriesFullyLoaded, + mapViewTransition, + pendingMapIntent, + ]); + + const handleCountryPress = useCallback( + (country: MapCountry) => { + const isSelected = activeCountry?.name === country.name; + + if (isSelected && isPreviewOpen) { + return; + } + if (isSelected) { + openCountryPreview(); + return; + } + focusCountryOnMap(country, "mapTap"); + }, + [activeCountry, focusCountryOnMap, isPreviewOpen, openCountryPreview], + ); + + const handleAllPress = useCallback(async () => { + if (countries.length === 0) return; + const generation = ++randomPickGenerationRef.current; + useRecentlyViewedStore.getState().seedIfEmpty(); + const entries = useRecentlyViewedStore.getState().entries; + const topName = entries[0]?.country?.name?.trim() ?? ""; + const direct = countries.find((c) => c.name === topName) ?? null; + + let pick = direct; + if (!pick) { + const feed = useCountryFeedStore.getState(); + if (feed.countries.length === 0 && feed.status === "idle") { + await feed.loadInitialFeed(); + } + const feedTop = feed.countries.slice(0, 3); + pick = + feedTop + .map((fc) => countries.find((c) => c.name === fc.name)) + .find(Boolean) ?? null; + } + + if (!pick) { + pick = countries[0] ?? null; + } + + if (!pick) { + return; + } + if ( + !isRandomPickGenerationCurrent( + generation, + randomPickGenerationRef.current, + ) + ) + return; + + setActiveChip("all"); + setFeaturedShortcut("all"); + focusCountryOnMap(pick, "shuffle"); + }, [countries, focusCountryOnMap, setActiveChip, setFeaturedShortcut]); + + const handleTerrainPress = useCallback(async () => { + if (countries.length === 0) return; + const generation = ++randomPickGenerationRef.current; + const feed = useCountryFeedStore.getState(); + if (feed.countries.length === 0 && feed.status === "idle") { + await feed.loadInitialFeed(); + } + + const feedTop = feed.countries.slice(0, 3); + const pick = + feedTop + .map((fc) => countries.find((c) => c.name === fc.name)) + .find(Boolean) ?? countries[0]; + + if (!pick) return; + if ( + !isRandomPickGenerationCurrent( + generation, + randomPickGenerationRef.current, + ) + ) + return; + + setActiveChip("nature"); + setFeaturedShortcut("terrain"); + focusCountryOnMap(pick, "shuffle"); + }, [countries, focusCountryOnMap, setActiveChip, setFeaturedShortcut]); + + const handleSavedPress = useCallback(async () => { + if (countries.length === 0) return; + const generation = ++randomPickGenerationRef.current; + const saved = useSavedCountriesStore.getState().savedCountries; + const pick = + saved + .map((sc) => countries.find((c) => c.name === sc.name)) + .find(Boolean) ?? null; + + if (!pick) return; + if ( + !isRandomPickGenerationCurrent( + generation, + randomPickGenerationRef.current, + ) + ) + return; + + setActiveChip("all"); + setFeaturedShortcut("saved"); + focusCountryOnMap(pick, "shuffle"); + }, [countries, focusCountryOnMap, setActiveChip, setFeaturedShortcut]); + + const handleRandomCountry = useCallback(async () => { + if ( + !shouldAcceptRandomFabTap({ + isMapAnimating: isMapAnimatingRef.current, + nowMs: Date.now(), + lastTapAtMs: lastRandomFabTapAtRef.current, + }) + ) { + return; + } + lastRandomFabTapAtRef.current = Date.now(); + + if (!mapCountriesFullyLoaded) { + await loadMapCountries(); + } + + const countriesSnapshot = useMapStore.getState().countries; + if (countriesSnapshot.length === 0) { + return; + } + + const generation = ++randomPickGenerationRef.current; + + // The FAB always draws from the global pool at world zoom — never continent mode. + const pool = await buildMapRandomPool({ + countries: countriesSnapshot, + activeChip, + featuredShortcut: null, + focusedRegion: null, + useWorldPool: true, + }); + if ( + !isRandomPickGenerationCurrent( + generation, + randomPickGenerationRef.current, + ) + ) { + return; + } + + const excludeName = activeCountry?.name ?? null; + const pick = pickRandomMapCountry(pool, excludeName); + if (!pick) { + return; + } + + const showHint = !hasSeenRandomCountryHint; + focusCountryOnMap(pick, "fab"); + if (showHint) { + setRandomCountryHint(pick); + dismissRandomCountryHint(); + } + }, [ + activeChip, + activeCountry, + dismissRandomCountryHint, + focusCountryOnMap, + hasSeenRandomCountryHint, + loadMapCountries, + mapCountriesFullyLoaded, + ]); + + const handleNextCountry = useCallback(async () => { + if (!activeCountry || countries.length === 0) return; + + if ( + !shouldAcceptRandomFabTap({ + isMapAnimating: isMapAnimatingRef.current, + nowMs: Date.now(), + lastTapAtMs: lastShuffleTapAtRef.current, + }) + ) { + return; + } + lastShuffleTapAtRef.current = Date.now(); + + const generation = ++shufflePickGenerationRef.current; + setIsPreviewShufflePending(true); + + try { + const pool = await buildMapRandomPool({ + countries, + activeChip, + featuredShortcut, + focusedRegion, + useWorldPool: resolveMapRandomUseWorldPool({ + focusedRegion, + mapMode, + flatLatitudeDelta: flatLatitudeDeltaRef.current, + contextualOnly: true, + }), + }); + if ( + !isRandomPickGenerationCurrent( + generation, + shufflePickGenerationRef.current, + ) + ) { + return; + } + + const pick = + pickRandomMapCountry(pool, activeCountry.name) ?? + pickRandomMapCountry(countries, activeCountry.name); + if (!pick) return; + + advanceToCountryPreview(pick); + } catch (err) { + isMapAnimatingRef.current = false; + setIsMapAnimating(false); + } finally { + if ( + generation === shufflePickGenerationRef.current && + !isMapAnimatingRef.current + ) { + setIsPreviewShufflePending(false); + } + } + }, [ + activeChip, + activeCountry, + advanceToCountryPreview, + countries, + featuredShortcut, + focusedRegion, + mapMode, + ]); + + const flyToWorldView = useCallback( + (options?: { preserveCamera?: boolean }) => { + const preserveCamera = options?.preserveCamera ?? false; + + cancelIntent(); + suppressWorldResetRef.current = false; + clearExplicitRegionLock(); + resetGlobalPulse(); + clearCountrySelection(); + + if (preserveCamera) { + return; + } + + if (is3d) { + cancelCameraFlight(); + mapRef.current?.resetWorldView(); + } else { + flight.flyTo([{ region: WORLD_INITIAL_REGION, duration: 600 }]); + } + }, + [ + cancelCameraFlight, + cancelIntent, + clearCountrySelection, + clearExplicitRegionLock, + flight, + globeCameraDistance, + is3d, + resetGlobalPulse, + ], + ); + + const handleReset = useCallback(() => { + flyToWorldView(); + setActiveChip("all"); + }, [flyToWorldView, setActiveChip]); + + const handleBackToWorld = useCallback(() => { + flyToWorldView({ preserveCamera: true }); + }, [flyToWorldView]); + + const recenterOnFocusedContinent = useCallback( + (cluster: MapCluster) => { + commitContinentNavigation(cluster, { + updateFocusedRegion: false, + clearSelection: false, + source: "recenter", + }); + }, + [commitContinentNavigation], + ); + + const handleBackToContinent = useCallback(() => { + if (!focusedRegion) return; + + const cluster = clusters.find((c) => c.region === focusedRegion); + if (!cluster) return; + + recenterOnFocusedContinent(cluster); + }, [clusters, focusedRegion, recenterOnFocusedContinent]); + + const zoomOutToContinentView = useCallback( + (region: string, duration = 650) => { + const cluster = clusters.find((c) => c.region === region); + if (!cluster) return; + + commitContinentNavigation(cluster, { + clearSelection: false, + duration, + globeDistance: GLOBE_REGION_CAMERA_DISTANCE, + source: "preview-exit", + }); + }, + [clusters, commitContinentNavigation], + ); + + /** Close preview sheet, keep country selected, return to the map mode in use when preview opened. */ + const dismissCountryPreview = useCallback(() => { + cancelIntent(); + const country = useIdentityStore.getState().activeCountry; + const savedMode = mapModeAtPreviewOpenRef.current; + mapModeAtPreviewOpenRef.current = null; + + dismissMapPreview(); + setPreviewDismissToContinent(false); + clearFocusTransition(); + + const currentMode = useMapStore.getState().mapMode; + if (savedMode && savedMode !== currentMode) { + handleMapModeToggle(); + return; + } + + if (country) { + flyMapToCountryFrame(country, "continent", 650); + } + }, [ + cancelIntent, + clearFocusTransition, + flyMapToCountryFrame, + handleMapModeToggle, + ]); + + /** Re-tap Map tab while country details are open → dismiss and restore 2D/3D mode. */ + useEffect(() => { + const unsubscribe = navigation.addListener("tabPress", () => { + if (!isPreviewOpen) return; + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + dismissCountryPreview(); + }); + return unsubscribe; + }, [navigation, isPreviewOpen, dismissCountryPreview]); + + /** Preview "back to continent" — clears country selection and zooms to region. */ + const exitCountryPreviewToContinent = useCallback(() => { + cancelIntent(); + const region = useMapUiStore.getState().focusedRegion; + + resetMapPresentation(); + setPreviewDismissToContinent(false); + clearFocusTransition(); + clearActiveCountry(); + resetExperience(); + + if (region) { + zoomOutToContinentView(region, 650); + } + }, [ + cancelIntent, + clearActiveCountry, + clearFocusTransition, + resetExperience, + zoomOutToContinentView, + ]); + + const handleMapPress = useCallback( + (coordinate?: MapPressCoordinate) => { + if (presentationMode === "preview") { + if (is3d) { + logGlobeTap({ + source: "controller", + stage: "action", + outcome: "dismiss-preview", + coordinate, + focusedRegion, + boundaryFocusRegion, + cameraTier, + globeDistance: globeCameraDistance, + }); + } + dismissCountryPreview(); + return; + } + + if (!coordinate) { + if (activeCountry) { + if (is3d) { + logGlobeTap({ + source: "controller", + stage: "action", + outcome: "clear-country-no-coordinate", + focusedRegion, + boundaryFocusRegion, + cameraTier, + globeDistance: globeCameraDistance, + }); + } + clearCountryFocus(); + } + return; + } + + const tappedCountry = is3d + ? resolveGlobeSurfaceTapCountry( + allBoundaryPolygons, + countries, + coordinate, + ) + : resolveMapCountryAtCoordinate( + allBoundaryPolygons, + countries, + coordinate, + ); + + const continentContext = focusedRegion ?? previewRegion; + + if (tappedCountry) { + const selectInFocusedContinent = + shouldSelectCountryInFocusedContinentFromMapTap({ + focusedRegion: continentContext, + tappedCountry, + cameraTier, + }); + const selectAcrossFocusedContinent = + shouldSelectCountryAcrossFocusedContinentFromMapTap({ + focusedRegion: continentContext, + tappedCountry, + }); + const delegateCountrySelection = + selectInFocusedContinent || + shouldDelegateMapTapToCountrySelection({ + presentationMode, + activeCountry, + focusedRegion, + tappedCountry, + }); + const willFocusContinent = canFocusContinentFromMapTap( + is3d, + focusedRegion, + cameraTier, + ); + + if (is3d) { + logGlobeTap({ + source: "controller", + stage: "routing", + outcome: delegateCountrySelection + ? "land-delegate-country" + : selectAcrossFocusedContinent + ? "land-cross-continent" + : willFocusContinent + ? "land-focus-continent" + : "land-select-fallback", + coordinate, + country: tappedCountry.name, + region: tappedCountry.region, + focusedRegion, + boundaryFocusRegion, + cameraTier, + globeDistance: globeCameraDistance, + flags: { + selectInFocusedContinent, + selectAcrossFocusedContinent, + delegateCountrySelection, + willFocusContinent, + }, + }); + } + + showTapRipple(coordinate); + + if (delegateCountrySelection) { + handleBoundaryCountryPress(tappedCountry); + return; + } + + if (selectAcrossFocusedContinent) { + if (is3d) { + logGlobeTap({ + source: "controller", + stage: "action", + outcome: "focus-country-cross-continent", + coordinate, + country: tappedCountry.name, + region: tappedCountry.region, + focusedRegion, + boundaryFocusRegion, + cameraTier, + globeDistance: globeCameraDistance, + }); + } + focusCountryOnMap(tappedCountry, "mapTap"); + return; + } + + if (willFocusContinent) { + const cluster = + clusters.find((c) => c.region === tappedCountry.region) ?? null; + if (cluster) { + if (is3d) { + logGlobeTap({ + source: "controller", + stage: "action", + outcome: "focus-continent-from-land", + coordinate, + country: tappedCountry.name, + region: cluster.region, + focusedRegion, + boundaryFocusRegion, + cameraTier, + globeDistance: globeCameraDistance, + }); + } + requestContinentFocus(cluster); + } else if (is3d) { + logGlobeTap({ + source: "controller", + stage: "skip", + outcome: "focus-continent-no-cluster", + coordinate, + country: tappedCountry.name, + region: tappedCountry.region, + focusedRegion, + boundaryFocusRegion, + cameraTier, + globeDistance: globeCameraDistance, + }); + } + return; + } + + if ( + cameraTier !== "world" && + continentContext && + tappedCountry.region !== continentContext + ) { + return; + } + + handleBoundaryCountryPress(tappedCountry); + return; + } + + // No boundary hit — ocean, rivers, empty map, etc. + showTapRipple(coordinate); + + if (activeCountry) { + if (is3d) { + logGlobeTap({ + source: "controller", + stage: "action", + outcome: "clear-country-ocean-tap", + coordinate, + focusedRegion, + boundaryFocusRegion, + cameraTier, + globeDistance: globeCameraDistance, + }); + } + clearCountryFocus(); + return; + } + + const cluster = findClusterAtWorldCoordinate( + allBoundaryPolygons, + countries, + clusters, + coordinate, + ); + const willFocusContinentFromOcean = + canFocusContinentFromMapTap(is3d, focusedRegion, cameraTier) && + canRefocusContinentFromMapTap( + focusedRegion, + cluster?.region ?? null, + cameraTier, + ) && + !!cluster; + + if (is3d) { + logGlobeTap({ + source: "controller", + stage: "routing", + outcome: willFocusContinentFromOcean + ? "ocean-focus-continent" + : "ocean-noop", + coordinate, + region: cluster?.region ?? null, + focusedRegion, + boundaryFocusRegion, + cameraTier, + globeDistance: globeCameraDistance, + flags: { + willFocusContinentFromOcean, + hasCluster: !!cluster, + }, + }); + } + + if (willFocusContinentFromOcean && cluster) { + requestContinentFocus(cluster); + } + }, + [ + activeCountry, + allBoundaryPolygons, + boundaryFocusRegion, + cameraTier, + clearCountryFocus, + clusters, + countries, + dismissCountryPreview, + focusCountryOnMap, + focusedRegion, + globeCameraDistance, + handleBoundaryCountryPress, + is3d, + presentationMode, + previewRegion, + requestContinentFocus, + showTapRipple, + ], + ); + + const regionChromeVisible = showRegionChrome( + focusedRegion, + presentationMode, + activeCountry, + ); + const countryFocusPillVisible = showCountryFocusPill( + presentationMode, + activeCountry, + ); + const showFlagToggle = mapMarkerCountries.length > 0; + const showOnboarding = + !hasSeenMapOnboarding && + shouldShowMapOnboarding({ + is3d, + cameraTier, + status, + countryCount: countries.length, + hasActiveCountry: activeCountry !== null, + hasFocusTransition: focusTransitionCountryName !== null, + }); + + return { + status, + error, + loadMapCountries, + countries, + clusters, + allBoundaryPolygons, + pinCountries, + mapMarkerCountries, + mapMarkersForCanvas, + selectedMapName, + isPreviewOpen, + is3d, + mapMode, + mapViewTransition, + cameraTier, + cameraZoomState, + globeEntryCameraDistance, + isMapAnimating, + isPreviewShufflePending, + activeCountry, + activeChip, + focusedRegion, + continentOverlayRegion, + boundaryFocusRegion, + previewRegion, + previewDismissToContinent, + focusTransitionCountryName, + tapRippleAt, + tapRippleToken, + randomCountryHint, + setRandomCountryHint, + countryMarkerMode, + markerReveal, + markerRefreshToken, + showRegionChrome: regionChromeVisible, + showCountryFocusPill: countryFocusPillVisible, + shouldShowFeaturedChips: shouldShowFeaturedChipsInMapChrome( + is3d, + cameraTier, + focusedRegion, + ), + showFlagToggle, + showOnboarding, + dismissMapOnboarding, + handleGlobeTransitionComplete, + handleFlatTransitionComplete, + handleFlatMapReady, + handleGlobeCameraViewChange, + handleFlatRegionChange, + handleRegionChangeComplete, + handleMapModeToggle, + handleCountryPress, + handleBoundaryCountryPress, + handleMapPress, + handleRandomCountry, + handleNextCountry, + handleReset, + handleBackToWorld, + handleBackToContinent, + handleAllPress, + handleTerrainPress, + handleSavedPress, + dismissCountryPreview, + exitCountryPreviewToContinent, + clearCountryFocus, + openCountryPreview, + requestContinentFocus, + }; +} diff --git a/hooks/use-map-marker-reveal.ts b/hooks/use-map-marker-reveal.ts index 37b2cc5..f60cf46 100644 --- a/hooks/use-map-marker-reveal.ts +++ b/hooks/use-map-marker-reveal.ts @@ -1,6 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { logMapDebug } from "@/lib/map-debug"; import { MARKER_REVEAL_BATCH_INTERVAL_MS, MARKER_REVEAL_BATCH_SIZE, @@ -29,6 +28,14 @@ type UseMapMarkerRevealParams = { * which can crash react-native-maps on iOS. */ paused?: boolean; + /** + * Bumped by continent navigation before focusedRegion changes so marker + * removal commits while the previous region is still active (avoids a frame + * where stale pins overlap animateToRegion). + */ + prepareSwapToken?: number; + /** Override clear delay between pin removal and first batch mount (default 90ms). */ + regionClearDelayMs?: number; }; type UseMapMarkerRevealResult = { @@ -43,7 +50,14 @@ type UseMapMarkerRevealResult = { * transaction churns react-native-maps and crashes iOS, so we split the swap * into a removal-only commit followed (one tick later) by addition-only commits. */ -const REGION_SWAP_CLEAR_DELAY_MS = 90; +export const MARKER_REGION_SWAP_CLEAR_DELAY_MS = 90; +/** Extra settle time before mounting pins after a 2D cross-region flight. */ +export const CROSS_REGION_REVEAL_EXTRA_DELAY_MS = 150; +/** Lag continent focus polygons after focusedRegion commits post cross-region flight. */ +export const CROSS_REGION_OVERLAY_LAG_MS = 200; + +const clampCount = (count: number, total: number) => + Math.min(Math.max(0, count), total); export function useMapMarkerReveal({ candidateCountries, @@ -53,6 +67,8 @@ export function useMapMarkerReveal({ isDetailZoom, enabled, paused = false, + prepareSwapToken = 0, + regionClearDelayMs = MARKER_REGION_SWAP_CLEAR_DELAY_MS, }: UseMapMarkerRevealParams): UseMapMarkerRevealResult { /** * The single source of truth for which markers are mounted. Kept in state (not @@ -61,12 +77,30 @@ export function useMapMarkerReveal({ */ const [renderedCountries, setRenderedCountries] = useState([]); const [revealGeneration, setRevealGeneration] = useState(0); + /** + * Synchronous mirror of revealGeneration. Every reset bumps this immediately + * so batch timers scheduled under an older generation can detect they are + * stale and bail before mounting pins for a region the reveal has left. + */ + const generationRef = useRef(0); const intervalRef = useRef | null>(null); const swapTimerRef = useRef | null>(null); /** Region the current reveal belongs to — reset only when this changes. */ const revealRegionRef = useRef(null); + /** Pending region when focusedRegion changes during a paused flight. */ + const pendingRegionRef = useRef(null); /** How many candidates are currently revealed (synchronous reads in timers). */ const revealedCountRef = useRef(0); + /** Detects viewport pool changes while a flight holds the rendered snapshot. */ + const prevCandidateSetKeyRef = useRef(null); + + /** Single place to advance the generation so the ref and state never drift. */ + const bumpGeneration = () => { + generationRef.current += 1; + const next = generationRef.current; + setRevealGeneration(next); + return next; + }; const sortedCandidates = useMemo(() => { if (!enabled || candidateCountries.length === 0) return []; @@ -108,6 +142,21 @@ export function useMapMarkerReveal({ } }; + const prepareSwapTokenRef = useRef(prepareSwapToken); + + useEffect(() => { + if (prepareSwapToken === prepareSwapTokenRef.current) return; + prepareSwapTokenRef.current = prepareSwapToken; + + clearRevealInterval(); + clearSwapTimer(); + revealedCountRef.current = 0; + pendingRegionRef.current = null; + setRenderedCountries([]); + bumpGeneration(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- stable ref-based clears + }, [prepareSwapToken]); + useEffect(() => { clearRevealInterval(); clearSwapTimer(); @@ -115,17 +164,25 @@ export function useMapMarkerReveal({ // While a flight animates, stop mounting/unmounting marker batches — the // rendered snapshot keeps the current pins on screen until the camera settles. if (paused) { - logMapDebug("reveal", "paused — holding markers", { - focusedRegion, - candidateCount: sortedCandidates.length, - revealed: revealedCountRef.current, - }); + const regionChanged = + focusedRegion !== null && revealRegionRef.current !== focusedRegion; + + if (regionChanged) { + revealedCountRef.current = 0; + pendingRegionRef.current = focusedRegion; + // Do not clear renderedCountries here — unmounting during a camera + // flight crashes react-native-maps. Cross-region continent navigation + // uses prepareSwapToken to clear markers before focusedRegion updates. + } return; } + pendingRegionRef.current = null; + if (!enabled || sortedCandidates.length === 0) { revealRegionRef.current = null; revealedCountRef.current = 0; + prevCandidateSetKeyRef.current = null; setRenderedCountries([]); return; } @@ -134,19 +191,20 @@ export function useMapMarkerReveal({ const priorityCount = Math.min(MARKER_REVEAL_PRIORITY_BATCH, total); // Grows the revealed window one batch at a time (addition-only commits). - const startBatchInterval = () => { + // Captures the generation it was scheduled under so a batch queued before a + // region swap cannot mount stale pins after the reveal has moved on. + const startBatchInterval = (generationAtStart: number) => { intervalRef.current = setInterval(() => { - const next = Math.min( + if (generationRef.current !== generationAtStart) { + clearRevealInterval(); + return; + } + const next = clampCount( revealedCountRef.current + MARKER_REVEAL_BATCH_SIZE, total, ); revealedCountRef.current = next; setRenderedCountries(sortedCandidates.slice(0, next)); - logMapDebug("reveal", "batch mounted", { - focusedRegion, - to: next, - total, - }); if (next >= total) { clearRevealInterval(); } @@ -164,20 +222,16 @@ export function useMapMarkerReveal({ // Phase 1 (this commit): remove the previous region's pins only. Phase 2 // (after the clear delay): mount the new region's pins. Splitting the // remove/add across commits avoids the simultaneous churn that crashes iOS. + // A region change is always a generation change — counters reset together. revealedCountRef.current = 0; + prevCandidateSetKeyRef.current = null; setRenderedCountries([]); - setRevealGeneration((g) => g + 1); - logMapDebug("reveal", "start batched reveal (new region)", { - focusedRegion, - priorityCount, - total, - batchSize: MARKER_REVEAL_BATCH_SIZE, - intervalMs: MARKER_REVEAL_BATCH_INTERVAL_MS, - clearDelayMs: REGION_SWAP_CLEAR_DELAY_MS, - }); + const generationAtStart = bumpGeneration(); swapTimerRef.current = setTimeout(() => { swapTimerRef.current = null; + // A swap that started before another region change must not paint pins. + if (generationRef.current !== generationAtStart) return; if (isDetailZoom) { revealedCountRef.current = total; setRenderedCountries(sortedCandidates); @@ -186,35 +240,50 @@ export function useMapMarkerReveal({ revealedCountRef.current = priorityCount; setRenderedCountries(sortedCandidates.slice(0, priorityCount)); if (priorityCount < total) { - startBatchInterval(); + startBatchInterval(generationAtStart); } - }, REGION_SWAP_CLEAR_DELAY_MS); + }, regionClearDelayMs); return; } // Same region — never tear down; only ever grow the revealed window. if (isDetailZoom) { revealedCountRef.current = total; + prevCandidateSetKeyRef.current = candidateSetKey; setRenderedCountries(sortedCandidates); return; } - const startCount = Math.min( + const candidateSetChanged = + prevCandidateSetKeyRef.current !== null && + prevCandidateSetKeyRef.current !== candidateSetKey; + const revealedExceedsPool = revealedCountRef.current > total; + prevCandidateSetKeyRef.current = candidateSetKey; + + // After a paused flight the camera may have moved, shrinking the viewport + // pool (e.g. 59 → 16) while revealedCount still reflects the old snapshot. + if (revealedExceedsPool || candidateSetChanged) { + revealedCountRef.current = clampCount(revealedCountRef.current, total); + const startCount = clampCount( + Math.max(revealedCountRef.current, priorityCount), + total, + ); + revealedCountRef.current = startCount; + setRenderedCountries(sortedCandidates.slice(0, startCount)); + if (startCount < total) { + startBatchInterval(generationRef.current); + } + return; + } + + const startCount = clampCount( Math.max(revealedCountRef.current, priorityCount), total, ); - if (startCount !== revealedCountRef.current) { - logMapDebug("reveal", "resume reveal (same region)", { - focusedRegion, - from: revealedCountRef.current, - startCount, - total, - }); - } revealedCountRef.current = startCount; setRenderedCountries(sortedCandidates.slice(0, startCount)); if (startCount < total) { - startBatchInterval(); + startBatchInterval(generationRef.current); } }, [ enabled, @@ -223,6 +292,7 @@ export function useMapMarkerReveal({ candidateSetKey, sortedCandidates.length, paused, + regionClearDelayMs, ]); // Unmount-only cleanup — every effect run clears timers at its top, but early diff --git a/lib/app-region.ts b/lib/app-region.ts index 08e3c31..7feeb29 100644 --- a/lib/app-region.ts +++ b/lib/app-region.ts @@ -2,6 +2,10 @@ export const NORTH_AMERICA = "North America" as const; export const SOUTH_AMERICA = "South America" as const; +export function isSplitAmericasRegion(region: string): boolean { + return region === NORTH_AMERICA || region === SOUTH_AMERICA; +} + /** Fallback when cached payloads still use REST Countries `Americas` without subregion. */ const SOUTH_AMERICA_COUNTRY_NAMES = new Set([ "Argentina", @@ -68,7 +72,9 @@ export function countryMatchesExploreRegion( country: { name: string; region: string }, targetRegion: string, ): boolean { - return normalizeAppRegion(country.region, undefined, country.name) === targetRegion; + return ( + normalizeAppRegion(country.region, undefined, country.name) === targetRegion + ); } export function filterCountriesForExploreRegion< diff --git a/lib/cca2-to-cca3.ts b/lib/cca2-to-cca3.ts new file mode 100644 index 0000000..cba0501 --- /dev/null +++ b/lib/cca2-to-cca3.ts @@ -0,0 +1,257 @@ +/** ISO 3166-1 alpha-2 → alpha-3 (REST Countries / flagcdn coverage). */ +const CCA2_TO_CCA3: Record = { + AD: "AND", + AE: "ARE", + AF: "AFG", + AG: "ATG", + AI: "AIA", + AL: "ALB", + AM: "ARM", + AO: "AGO", + AQ: "ATA", + AR: "ARG", + AS: "ASM", + AT: "AUT", + AU: "AUS", + AW: "ABW", + AX: "ALA", + AZ: "AZE", + BA: "BIH", + BB: "BRB", + BD: "BGD", + BE: "BEL", + BF: "BFA", + BG: "BGR", + BH: "BHR", + BI: "BDI", + BJ: "BEN", + BL: "BLM", + BM: "BMU", + BN: "BRN", + BO: "BOL", + BQ: "BES", + BR: "BRA", + BS: "BHS", + BT: "BTN", + BV: "BVT", + BW: "BWA", + BY: "BLR", + BZ: "BLZ", + CA: "CAN", + CC: "CCK", + CD: "COD", + CF: "CAF", + CG: "COG", + CH: "CHE", + CI: "CIV", + CK: "COK", + CL: "CHL", + CM: "CMR", + CN: "CHN", + CO: "COL", + CR: "CRI", + CU: "CUB", + CV: "CPV", + CW: "CUW", + CX: "CXR", + CY: "CYP", + CZ: "CZE", + DE: "DEU", + DJ: "DJI", + DK: "DNK", + DM: "DMA", + DO: "DOM", + DZ: "DZA", + EC: "ECU", + EE: "EST", + EG: "EGY", + EH: "ESH", + ER: "ERI", + ES: "ESP", + ET: "ETH", + FI: "FIN", + FJ: "FJI", + FK: "FLK", + FM: "FSM", + FO: "FRO", + FR: "FRA", + GA: "GAB", + GB: "GBR", + GD: "GRD", + GE: "GEO", + GF: "GUF", + GG: "GGY", + GH: "GHA", + GI: "GIB", + GL: "GRL", + GM: "GMB", + GN: "GIN", + GP: "GLP", + GQ: "GNQ", + GR: "GRC", + GS: "SGS", + GT: "GTM", + GU: "GUM", + GW: "GNB", + GY: "GUY", + HK: "HKG", + HM: "HMD", + HN: "HND", + HR: "HRV", + HT: "HTI", + HU: "HUN", + ID: "IDN", + IE: "IRL", + IL: "ISR", + IM: "IMN", + IN: "IND", + IO: "IOT", + IQ: "IRQ", + IR: "IRN", + IS: "ISL", + IT: "ITA", + JE: "JEY", + JM: "JAM", + JO: "JOR", + JP: "JPN", + KE: "KEN", + KG: "KGZ", + KH: "KHM", + KI: "KIR", + KM: "COM", + KN: "KNA", + KP: "PRK", + KR: "KOR", + KW: "KWT", + KY: "CYM", + KZ: "KAZ", + LA: "LAO", + LB: "LBN", + LC: "LCA", + LI: "LIE", + LK: "LKA", + LR: "LBR", + LS: "LSO", + LT: "LTU", + LU: "LUX", + LV: "LVA", + LY: "LBY", + MA: "MAR", + MC: "MCO", + MD: "MDA", + ME: "MNE", + MF: "MAF", + MG: "MDG", + MH: "MHL", + MK: "MKD", + ML: "MLI", + MM: "MMR", + MN: "MNG", + MO: "MAC", + MP: "MNP", + MQ: "MTQ", + MR: "MRT", + MS: "MSR", + MT: "MLT", + MU: "MUS", + MV: "MDV", + MW: "MWI", + MX: "MEX", + MY: "MYS", + MZ: "MOZ", + NA: "NAM", + NC: "NCL", + NE: "NER", + NF: "NFK", + NG: "NGA", + NI: "NIC", + NL: "NLD", + NO: "NOR", + NP: "NPL", + NR: "NRU", + NU: "NIU", + NZ: "NZL", + OM: "OMN", + PA: "PAN", + PE: "PER", + PF: "PYF", + PG: "PNG", + PH: "PHL", + PK: "PAK", + PL: "POL", + PM: "SPM", + PN: "PCN", + PR: "PRI", + PS: "PSE", + PT: "PRT", + PW: "PLW", + PY: "PRY", + QA: "QAT", + RE: "REU", + RO: "ROU", + RS: "SRB", + RU: "RUS", + RW: "RWA", + SA: "SAU", + SB: "SLB", + SC: "SYC", + SD: "SDN", + SE: "SWE", + SG: "SGP", + SH: "SHN", + SI: "SVN", + SJ: "SJM", + SK: "SVK", + SL: "SLE", + SM: "SMR", + SN: "SEN", + SO: "SOM", + SR: "SUR", + SS: "SSD", + ST: "STP", + SV: "SLV", + SX: "SXM", + SY: "SYR", + SZ: "SWZ", + TC: "TCA", + TD: "TCD", + TF: "ATF", + TG: "TGO", + TH: "THA", + TJ: "TJK", + TK: "TKL", + TL: "TLS", + TM: "TKM", + TN: "TUN", + TO: "TON", + TR: "TUR", + TT: "TTO", + TV: "TUV", + TW: "TWN", + TZ: "TZA", + UA: "UKR", + UG: "UGA", + UM: "UMI", + US: "USA", + UY: "URY", + UZ: "UZB", + VA: "VAT", + VC: "VCT", + VE: "VEN", + VG: "VGB", + VI: "VIR", + VN: "VNM", + VU: "VUT", + WF: "WLF", + WS: "WSM", + XK: "XKX", + YE: "YEM", + YT: "MYT", + ZA: "ZAF", + ZM: "ZMB", + ZW: "ZWE", +}; + +export function cca3FromCca2(cca2: string): string { + return CCA2_TO_CCA3[cca2.trim().toUpperCase()] ?? ""; +} diff --git a/lib/client-cache.ts b/lib/client-cache.ts new file mode 100644 index 0000000..7201733 --- /dev/null +++ b/lib/client-cache.ts @@ -0,0 +1,105 @@ +import { CLIENT_CACHE_SCHEMA_VERSION } from "@/constants/client-cache"; +import { + deleteCacheKey, + getAllCacheKeys, + readCacheString, + writeCacheString, +} from "@/lib/client-storage"; + +type CacheEnvelope = { + v: number; + savedAt: number; + ttlSeconds: number; + data: T; +}; + +export async function getClientCache(key: string): Promise<{ + data: T | null; + isFresh: boolean; + isStale: boolean; + savedAt: number | null; +}> { + try { + const raw = await readCacheString(key); + if (!raw) { + return { data: null, isFresh: false, isStale: false, savedAt: null }; + } + + const envelope = JSON.parse(raw) as CacheEnvelope; + + if (envelope.v !== CLIENT_CACHE_SCHEMA_VERSION) { + await deleteCacheKey(key); + return { data: null, isFresh: false, isStale: false, savedAt: null }; + } + + const now = Date.now(); + const expiresAt = envelope.savedAt + envelope.ttlSeconds * 1000; + const isFresh = expiresAt > now; + const isStale = !isFresh; + + return { + data: envelope.data, + isFresh, + isStale, + savedAt: envelope.savedAt, + }; + } catch { + await deleteCacheKey(key); + return { data: null, isFresh: false, isStale: false, savedAt: null }; + } +} + +export async function setClientCache( + key: string, + data: T, + ttlSeconds: number, +): Promise { + const envelope: CacheEnvelope = { + v: CLIENT_CACHE_SCHEMA_VERSION, + savedAt: Date.now(), + ttlSeconds, + data, + }; + + await writeCacheString(key, JSON.stringify(envelope)); +} + +export async function removeClientCache(key: string): Promise { + await deleteCacheKey(key); +} + +/** + * Removes every AsyncStorage entry under the `cache:` prefix. + * Bookmarks, map UI prefs, and other Zustand persist keys are untouched. + * + * Dev: use the "Clear local cache" button on `/dev` or call this from the console. + */ +export async function clearAllClientCache(): Promise { + const keys = await getAllCacheKeys(); + await Promise.all(keys.map((key) => deleteCacheKey(key))); +} + +export async function staleWhileRevalidate(options: { + key: string; + ttlSeconds: number; + fetcher: () => Promise; + force?: boolean; + onCached?: (data: T, meta: { isFresh: boolean }) => void; + onFetched?: (data: T) => void; +}): Promise { + const { key, ttlSeconds, fetcher, force, onCached, onFetched } = options; + const cached = await getClientCache(key); + + if (cached.data !== null) { + onCached?.(cached.data, { isFresh: cached.isFresh }); + } + + if (cached.isFresh && !force) { + return cached.data as T; + } + + const fresh = await fetcher(); + await setClientCache(key, fresh, ttlSeconds); + onFetched?.(fresh); + return fresh; +} diff --git a/lib/client-storage.ts b/lib/client-storage.ts new file mode 100644 index 0000000..fb66c92 --- /dev/null +++ b/lib/client-storage.ts @@ -0,0 +1,21 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; + +export async function readCacheString(key: string): Promise { + return AsyncStorage.getItem(key); +} + +export async function writeCacheString( + key: string, + value: string, +): Promise { + await AsyncStorage.setItem(key, value); +} + +export async function deleteCacheKey(key: string): Promise { + await AsyncStorage.removeItem(key); +} + +export async function getAllCacheKeys(): Promise { + const keys = await AsyncStorage.getAllKeys(); + return keys.filter((k) => k.startsWith("cache:")); +} diff --git a/lib/explore-region-countries.ts b/lib/explore-region-countries.ts index 5cd4f02..b82ad06 100644 --- a/lib/explore-region-countries.ts +++ b/lib/explore-region-countries.ts @@ -1,18 +1,13 @@ import { fetchFeedCountries, fetchSearchCountries } from "@/lib/api"; import { filterCountriesForExploreRegion, - NORTH_AMERICA, - SOUTH_AMERICA, + isSplitAmericasRegion, } from "@/lib/app-region"; import type { Country } from "@/types/country"; const FEED_PAGE_LIMIT = 30; const MAX_FEED_PAGES = 15; -function isSplitAmericasRegion(region: string): boolean { - return region === NORTH_AMERICA || region === SOUTH_AMERICA; -} - async function fetchAllFeedCountries(): Promise { const all: Country[] = []; let cursor: string | undefined; diff --git a/lib/globe-boundary-fills.test.ts b/lib/globe-boundary-fills.test.ts new file mode 100644 index 0000000..38753fe --- /dev/null +++ b/lib/globe-boundary-fills.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; + +import { CONTINENTS } from "@/constants/regions"; +import { buildGlobeBoundaryFills } from "@/lib/globe-boundary-fills"; +import { splitRingAtAntimeridian } from "@/lib/globe-polygon-triangulation"; +import { + filterBoundaryPolygonsByMapContext, + getCountryBoundaryPolygons, +} from "@/lib/map-country-boundaries"; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const countriesGeoJson = require("../assets/geo/ne_50m_admin_0_countries/ne_50m_admin_0_countries.json"); + +const allPolygons = getCountryBoundaryPolygons(countriesGeoJson); + +function polygonsWithFailedFills( + polygons: ReturnType, +) { + const failed: { id: string; country: string | null; verts: number }[] = []; + + for (const polygon of polygons) { + const fills = buildGlobeBoundaryFills([polygon]); + if (fills.length === 0) { + failed.push({ + id: polygon.id, + country: polygon.countryName, + verts: polygon.coordinates.length, + }); + } + } + + return failed; +} + +describe("buildGlobeBoundaryFills coverage", () => { + it("triangulates every Natural Earth country polygon", () => { + const failed = polygonsWithFailedFills(allPolygons); + expect(failed).toEqual([]); + }); + + it("splits dateline-spanning rings into multiple fill meshes when needed", () => { + const dateline = allPolygons.filter((polygon) => { + const lngs = polygon.coordinates.map((point) => point.longitude); + return Math.max(...lngs) - Math.min(...lngs) > 180; + }); + + expect(dateline.length).toBeGreaterThan(0); + + for (const polygon of dateline) { + const chains = splitRingAtAntimeridian(polygon.coordinates); + const fills = buildGlobeBoundaryFills([polygon]); + expect(fills.length, polygon.countryName ?? polygon.id).toBeGreaterThan( + 0, + ); + if (chains.length > 1) { + expect( + fills.length, + `${polygon.countryName} should emit one mesh per chain`, + ).toBeGreaterThanOrEqual(chains.length); + } + } + }); + + it("triangulates every polygon in each continent filter (continent fallback only)", () => { + for (const region of CONTINENTS) { + const filtered = filterBoundaryPolygonsByMapContext(allPolygons, { + selectedCountryName: null, + focusedRegion: region, + countries: [], + }); + const failed = polygonsWithFailedFills(filtered); + expect(failed, `region=${region}`).toEqual([]); + } + }); + + it("triangulates selected-country highlight meshes (e.g. United States)", () => { + const usPolygons = filterBoundaryPolygonsByMapContext(allPolygons, { + selectedCountryName: "United States of America", + focusedRegion: null, + countries: [], + }); + expect(usPolygons.length).toBeGreaterThan(0); + + const fills = buildGlobeBoundaryFills(usPolygons); + expect(fills.length).toBeGreaterThan(0); + for (const fill of fills) { + const index = fill.geometry.getIndex(); + expect(index?.count ?? 0).toBeGreaterThan(0); + } + }); +}); diff --git a/lib/globe-boundary-fills.ts b/lib/globe-boundary-fills.ts new file mode 100644 index 0000000..baabb6a --- /dev/null +++ b/lib/globe-boundary-fills.ts @@ -0,0 +1,196 @@ +import * as THREE from "three"; + +import { + canonicalLatLng, + polygonRingsToFlat, + ringSphericalCentroid, + ringToProjectionFlat, + splitRingAtAntimeridian, + triangulatePolygonFlat, + triangulatePolygonWithHolesFlat, +} from "@/lib/globe-polygon-triangulation"; +import { latLngToVector3 } from "@/lib/latlng-to-sphere"; +import type { CountryBoundaryPolygon } from "@/lib/map-country-boundaries"; +import type { LatLng } from "react-native-maps"; + +export const GLOBE_FILL_RADIUS = 1.003; +/** Slightly above the globe surface tap shell so boundary hits win raycasts. */ +export const GLOBE_BOUNDARY_HIT_RADIUS = 1.012; + +function dedupeClosingPoint(ring: LatLng[]): LatLng[] { + if (ring.length < 2) return ring; + const first = ring[0]!; + const last = ring[ring.length - 1]!; + if (first.latitude === last.latitude && first.longitude === last.longitude) { + return ring.slice(0, -1); + } + return ring; +} + +function pointsToSphereGeometry( + points: LatLng[], + indices: number[], + radius: number, +): THREE.BufferGeometry | null { + if (points.length < 3 || indices.length < 3) return null; + + const positions = new Float32Array(points.length * 3); + points.forEach((point, index) => { + const [x, y, z] = latLngToVector3(point.latitude, point.longitude, radius); + const offset = index * 3; + positions[offset] = x; + positions[offset + 1] = y; + positions[offset + 2] = z; + }); + + const geometry = new THREE.BufferGeometry(); + geometry.setIndex(indices); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geometry.computeVertexNormals(); + return geometry; +} + +function ringToSphereGeometry( + ring: LatLng[], + radius: number, +): THREE.BufferGeometry | null { + const points = dedupeClosingPoint(ring); + if (points.length < 3) return null; + + const { flat, points: planePoints } = ringToProjectionFlat(points); + if (flat.length < 6) return null; + + const indices = triangulatePolygonFlat(flat); + if (indices.length < 3) return null; + + const spherePoints = planePoints.map(canonicalLatLng); + return pointsToSphereGeometry(spherePoints, indices, radius); +} + +function holesForChain(chain: LatLng[], holes: LatLng[][]): LatLng[][] { + if (holes.length === 0) return []; + + const chainCentroid = ringSphericalCentroid(chain); + const chainLats = chain.map((point) => point.latitude); + const chainLngs = chain.map((point) => point.longitude); + const minLat = Math.min(...chainLats); + const maxLat = Math.max(...chainLats); + const minLng = Math.min(...chainLngs); + const maxLng = Math.max(...chainLngs); + + return holes.filter((hole) => { + const holeCentroid = ringSphericalCentroid(hole); + const latInRange = + holeCentroid.lat >= minLat - 2 && holeCentroid.lat <= maxLat + 2; + const lngInRange = + holeCentroid.lng >= minLng - 2 && holeCentroid.lng <= maxLng + 2; + + if (latInRange && lngInRange) return true; + + const latDelta = Math.abs(holeCentroid.lat - chainCentroid.lat); + const lngDelta = Math.abs(holeCentroid.lng - chainCentroid.lng); + return latDelta < 25 && lngDelta < 40; + }); +} + +export type GlobeBoundaryFillOptions = { + /** Solid fill — skip lake/bay holes (used when continent overlay sits below). */ + omitHoles?: boolean; +}; + +function polygonToSphereGeometries( + polygon: CountryBoundaryPolygon, + radius: number, + omitHoles = false, +): THREE.BufferGeometry[] { + const outer = dedupeClosingPoint(polygon.coordinates); + if (outer.length < 3) return []; + + const holes = omitHoles + ? [] + : (polygon.holes ?? []) + .map(dedupeClosingPoint) + .filter((hole) => hole.length >= 3); + + const chains = splitRingAtAntimeridian(outer); + const geometries: THREE.BufferGeometry[] = []; + + for (const chain of chains) { + const chainHoles = holesForChain(chain, holes); + + if (chainHoles.length > 0) { + const { flat, holeIndices, points } = polygonRingsToFlat( + chain, + chainHoles, + ); + const indices = + holeIndices.length > 0 + ? triangulatePolygonWithHolesFlat(flat, holeIndices) + : triangulatePolygonFlat(flat); + const geometry = pointsToSphereGeometry( + points.map(canonicalLatLng), + indices, + radius, + ); + if (geometry) geometries.push(geometry); + continue; + } + + const geometry = ringToSphereGeometry(chain, radius); + if (geometry) geometries.push(geometry); + } + + return geometries; +} + +export type GlobeBoundaryFill = { + id: string; + geometry: THREE.BufferGeometry; +}; + +export type GlobeBoundaryHitTarget = { + id: string; + countryName: string | null; + geometry: THREE.BufferGeometry; +}; + +export function buildGlobeBoundaryFills( + polygons: CountryBoundaryPolygon[], + radius = GLOBE_FILL_RADIUS, + options: GlobeBoundaryFillOptions = {}, +): GlobeBoundaryFill[] { + const fills: GlobeBoundaryFill[] = []; + const omitHoles = options.omitHoles ?? false; + + for (const polygon of polygons) { + const geometries = polygonToSphereGeometries(polygon, radius, omitHoles); + geometries.forEach((geometry, partIndex) => { + fills.push({ + id: `${polygon.id}-fill-${partIndex}`, + geometry, + }); + }); + } + + return fills; +} + +export function buildGlobeBoundaryHitTargets( + polygons: CountryBoundaryPolygon[], + radius = GLOBE_BOUNDARY_HIT_RADIUS, +): GlobeBoundaryHitTarget[] { + const targets: GlobeBoundaryHitTarget[] = []; + + for (const polygon of polygons) { + const geometries = polygonToSphereGeometries(polygon, radius); + geometries.forEach((geometry, partIndex) => { + targets.push({ + id: `${polygon.id}-hit-${partIndex}`, + countryName: polygon.countryName, + geometry, + }); + }); + } + + return targets; +} diff --git a/lib/globe-camera-debug.ts b/lib/globe-camera-debug.ts new file mode 100644 index 0000000..f31d6a2 --- /dev/null +++ b/lib/globe-camera-debug.ts @@ -0,0 +1,40 @@ +/** Disabled — use `lib/globe-tap-debug.ts` for 3D tap routing instead. */ +export const GLOBE_CAMERA_DEBUG = false; + +export type GlobeCameraLogPayload = { + reason: string; + from?: number; + to?: number; + target?: number; + latitudeDelta?: number; + tier?: string; + source?: string; + mode?: string; + durationMs?: number; + [key: string]: unknown; +}; + +function describeZoom( + from?: number, + to?: number, +): "in" | "out" | "hold" | undefined { + if (from === undefined || to === undefined) return undefined; + if (to < from - 0.001) return "in"; + if (to > from + 0.001) return "out"; + return "hold"; +} + +export function logGlobeCamera(payload: GlobeCameraLogPayload): void { + if (!GLOBE_CAMERA_DEBUG) return; + + const { reason, from, to, target, ...rest } = payload; + const zoom = describeZoom(from, to); + + console.log("[globe-camera]", reason, { + ...(from !== undefined ? { from: +from.toFixed(3) } : {}), + ...(to !== undefined ? { to: +to.toFixed(3) } : {}), + ...(target !== undefined ? { target: +target.toFixed(3) } : {}), + ...(zoom ? { zoom } : {}), + ...rest, + }); +} diff --git a/lib/globe-continent-filter.test.ts b/lib/globe-continent-filter.test.ts new file mode 100644 index 0000000..9706511 --- /dev/null +++ b/lib/globe-continent-filter.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { CONTINENTS } from "@/constants/regions"; +import { + filterBoundaryPolygonsByMapContext, + getCountryBoundaryPolygons, +} from "@/lib/map-country-boundaries"; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const countriesGeoJson = require("../assets/geo/ne_50m_admin_0_countries/ne_50m_admin_0_countries.json"); + +const allPolygons = getCountryBoundaryPolygons(countriesGeoJson); + +function continentOnlyFilter(region: string) { + return filterBoundaryPolygonsByMapContext(allPolygons, { + selectedCountryName: null, + focusedRegion: region, + countries: [], + }); +} + +describe("continent overlay polygon filter coverage", () => { + for (const region of CONTINENTS) { + it(`includes all Natural Earth ${region} polygons via continent fallback`, () => { + const byContinent = allPolygons.filter((p) => { + const allowed: Record = { + Africa: ["Africa"], + "North America": ["North America"], + "South America": ["South America"], + Antarctic: ["Antarctica"], + Asia: ["Asia"], + Europe: ["Europe"], + Oceania: ["Oceania"], + }; + return p.continent && allowed[region]?.includes(p.continent); + }); + + const filtered = continentOnlyFilter(region); + const filteredIds = new Set(filtered.map((p) => p.id)); + + const missing = byContinent.filter((p) => !filteredIds.has(p.id)); + expect(missing.map((p) => p.countryName)).toEqual([]); + }); + } +}); diff --git a/lib/globe-fill-edge-cases.test.ts b/lib/globe-fill-edge-cases.test.ts new file mode 100644 index 0000000..dab4e9b --- /dev/null +++ b/lib/globe-fill-edge-cases.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import { buildGlobeBoundaryFills } from "@/lib/globe-boundary-fills"; +import { + polygonRingsToFlat, + splitRingAtAntimeridian, + triangulatePolygonFlat, + triangulatePolygonWithHolesFlat, +} from "@/lib/globe-polygon-triangulation"; +import { getCountryBoundaryPolygons } from "@/lib/map-country-boundaries"; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const countriesGeoJson = require("../assets/geo/ne_50m_admin_0_countries/ne_50m_admin_0_countries.json"); + +const allPolygons = getCountryBoundaryPolygons(countriesGeoJson); + +function lngSpan(ring: { longitude: number }[]): number { + const lngs = ring.map((point) => point.longitude); + return Math.max(...lngs) - Math.min(...lngs); +} + +describe("globe fill edge cases", () => { + it("flags dateline rings that never split into multiple chains", () => { + const unsplit = allPolygons.filter((polygon) => { + if (polygon.countryName === "Antarctica") return false; + if (lngSpan(polygon.coordinates) <= 180) return false; + const chains = splitRingAtAntimeridian(polygon.coordinates); + return chains.length === 1; + }); + + expect( + unsplit.map((polygon) => polygon.countryName), + "dateline-spanning polygons should split before triangulation", + ).toEqual([]); + }); + + it("triangulates polygons with holes on every chain path", () => { + const withHoles = allPolygons.filter( + (polygon) => (polygon.holes?.length ?? 0) > 0, + ); + + expect(withHoles.length).toBeGreaterThan(0); + + for (const polygon of withHoles) { + const fills = buildGlobeBoundaryFills([polygon]); + expect(fills.length, polygon.countryName ?? polygon.id).toBeGreaterThan( + 0, + ); + + const chains = splitRingAtAntimeridian(polygon.coordinates); + if (chains.length === 1) { + const { flat, holeIndices } = polygonRingsToFlat( + chains[0]!, + polygon.holes ?? [], + ); + const indices = + holeIndices.length > 0 + ? triangulatePolygonWithHolesFlat(flat, holeIndices) + : triangulatePolygonFlat(flat); + expect( + indices.length, + polygon.countryName ?? polygon.id, + ).toBeGreaterThan(0); + } + } + }); +}); diff --git a/lib/globe-orbit-controls.ts b/lib/globe-orbit-controls.ts index 625cbda..5e5f994 100644 --- a/lib/globe-orbit-controls.ts +++ b/lib/globe-orbit-controls.ts @@ -4,6 +4,7 @@ * pointer events on the GL canvas and breaks RN touch tracking. */ import { invalidate } from "@react-three/fiber/native"; +import type { GestureResponderEvent, LayoutChangeEvent } from "react-native"; import { Matrix4, OrthographicCamera, @@ -13,7 +14,6 @@ import { Vector2, Vector3, } from "three"; -import type { GestureResponderEvent, LayoutChangeEvent } from "react-native"; import { MAP_TAP_DRAG_THRESHOLD_PX } from "@/constants/map-continent-focus"; @@ -116,6 +116,8 @@ export function createGlobeOrbitControls() { const dy = touch.pageY - touchStartY; if (Math.hypot(dx, dy) > MAP_TAP_DRAG_THRESHOLD_PX) { interactionExceededTapThreshold = true; + // Mark immediately so R3F pointer-up can skip before RN release runs. + suppressNextTap = true; } }; @@ -262,9 +264,14 @@ export function createGlobeOrbitControls() { const distance = Math.sqrt(dx * dx + dy * dy); internals.dollyEnd = distance; - this.dollyOut( - Math.pow(internals.dollyEnd / internals.dollyStart, scope.zoomSpeed), - ); + + if (internals.dollyStart > 0) { + const ratio = internals.dollyEnd / internals.dollyStart; + if (Number.isFinite(ratio) && ratio > 0) { + this.dollyOut(Math.pow(ratio, scope.zoomSpeed)); + } + } + internals.dollyStart = internals.dollyEnd; }, @@ -357,6 +364,61 @@ export function createGlobeOrbitControls() { }, }; + const MOMENTUM_EPS = 0.00001; + + const clearRotationMomentum = () => { + internals.sphericalDelta.set(0, 0, 0); + internals.panOffset.set(0, 0, 0); + }; + + /** Reposition the fixed-view camera and sync spherical coords — keeps drag momentum. */ + const snapCameraToFixedView = ( + viewDirection: Vector3, + target: Vector3, + distance: number, + ) => { + if (!scope.camera) return; + + internals.scale = 1; + + scope.camera.position.copy(viewDirection).multiplyScalar(distance); + scope.camera.lookAt(target); + + const offset = new Vector3(); + const quat = new Quaternion().setFromUnitVectors( + scope.camera.up, + new Vector3(0, 1, 0), + ); + const quatInverse = quat.clone().invert(); + offset.copy(scope.camera.position).sub(target); + offset.applyQuaternion(quat); + internals.spherical.setFromVector3(offset); + }; + + /** Full orbit reset after programmatic moves — clears stale deltas that cause zoom drift. */ + const resetOrbitToFixedView = ( + viewDirection: Vector3, + target: Vector3, + distance: number, + ) => { + clearRotationMomentum(); + snapCameraToFixedView(viewDirection, target, distance); + }; + + /** @deprecated Use snapCameraToFixedView or resetOrbitToFixedView. */ + const syncFromFixedView = resetOrbitToFixedView; + + const hasActiveMomentum = () => + internals.state !== STATE.NONE || + Math.abs(internals.sphericalDelta.theta) > MOMENTUM_EPS || + Math.abs(internals.sphericalDelta.phi) > MOMENTUM_EPS || + internals.panOffset.lengthSq() > MOMENTUM_EPS || + Math.abs(internals.scale - 1) > MOMENTUM_EPS; + + const isZoomInteraction = () => internals.state === STATE.DOLLY; + + const isActiveInteraction = () => internals.state !== STATE.NONE; + const update = (() => { const offset = new Vector3(); const lastPosition = new Vector3(); @@ -456,13 +518,22 @@ export function createGlobeOrbitControls() { }; const endInteraction = () => { + // Carry drag intent into the R3F pointer-up that may fire after RN release. suppressNextTap = interactionExceededTapThreshold; + interactionExceededTapThreshold = false; resetTouchState(); scope.onEnd(); }; + /** R3F pointer-down sync — RN responder may not run on tap-only touches. */ + const beginPointerTap = () => { + interactionExceededTapThreshold = false; + suppressNextTap = false; + }; + const consumeTapThresholdExceeded = () => { - const exceeded = suppressNextTap; + const exceeded = interactionExceededTapThreshold || suppressNextTap; + interactionExceededTapThreshold = false; suppressNextTap = false; return exceeded; }; @@ -473,7 +544,15 @@ export function createGlobeOrbitControls() { ...functions, update, resetTouchState, + beginPointerTap, consumeTapThresholdExceeded, + syncFromFixedView, + snapCameraToFixedView, + resetOrbitToFixedView, + clearRotationMomentum, + hasActiveMomentum, + isZoomInteraction, + isActiveInteraction, }, events: { onLayout(event: LayoutChangeEvent) { @@ -500,15 +579,9 @@ export function createGlobeOrbitControls() { onResponderMove(event: GestureResponderEvent) { trackTouchMove(event); const touchCount = event.nativeEvent.touches.length; - if ( - internals.state === STATE.ROTATE && - touchCount >= 2 - ) { + if (internals.state === STATE.ROTATE && touchCount >= 2) { functions.onTouchStart(event); - } else if ( - internals.state === STATE.DOLLY && - touchCount === 1 - ) { + } else if (internals.state === STATE.DOLLY && touchCount === 1) { functions.onTouchStart(event); } diff --git a/lib/globe-polygon-triangulation.test.ts b/lib/globe-polygon-triangulation.test.ts new file mode 100644 index 0000000..27e2469 --- /dev/null +++ b/lib/globe-polygon-triangulation.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import { + ringToProjectionFlat, + splitRingAtAntimeridian, + triangulatePolygonFlat, + unwrapRingLongitudes, +} from "@/lib/globe-polygon-triangulation"; + +describe("triangulatePolygonFlat", () => { + it("triangulates a square", () => { + const flat = [0, 0, 1, 0, 1, 1, 0, 1]; + const indices = triangulatePolygonFlat(flat); + expect(indices).toHaveLength(6); + }); + + it("triangulates a concave L-shape", () => { + const flat = [0, 0, 2, 0, 2, 1, 1, 1, 1, 2, 0, 2]; + const indices = triangulatePolygonFlat(flat); + expect(indices.length).toBeGreaterThanOrEqual(6); + expect(indices.length % 3).toBe(0); + }); +}); + +describe("splitRingAtAntimeridian", () => { + it("returns the original ring when it does not cross the dateline", () => { + const ring = [ + { latitude: 0, longitude: -50 }, + { latitude: 0, longitude: -40 }, + { latitude: -5, longitude: -45 }, + ]; + expect(splitRingAtAntimeridian(ring)).toEqual([ring]); + }); + + it("splits a ring that crosses the dateline into valid chains", () => { + const ring = [ + { latitude: 10, longitude: 170 }, + { latitude: 10, longitude: -170 }, + { latitude: -10, longitude: -170 }, + { latitude: -10, longitude: 170 }, + ]; + const chains = splitRingAtAntimeridian(ring); + expect(chains.length).toBeGreaterThanOrEqual(1); + for (const chain of chains) { + expect(chain.length).toBeGreaterThanOrEqual(3); + } + }); +}); + +describe("ringToProjectionFlat", () => { + it("projects ring points relative to centroid", () => { + const { flat } = ringToProjectionFlat([ + { latitude: 0, longitude: 0 }, + { latitude: 0, longitude: 1 }, + { latitude: 1, longitude: 1 }, + ]); + expect(flat).toHaveLength(6); + }); + + it("unwraps longitudes before projection", () => { + const unwrapped = unwrapRingLongitudes([ + { latitude: 0, longitude: 179 }, + { latitude: 0, longitude: -179 }, + ]); + expect(unwrapped[1]!.longitude).toBe(181); + }); +}); diff --git a/lib/globe-polygon-triangulation.ts b/lib/globe-polygon-triangulation.ts new file mode 100644 index 0000000..922b022 --- /dev/null +++ b/lib/globe-polygon-triangulation.ts @@ -0,0 +1,226 @@ +import earcut from "earcut"; +import type { LatLng } from "react-native-maps"; + +const DEG2RAD = Math.PI / 180; +const COORD_EPSILON = 1e-6; + +export function canonicalLongitude(lng: number): number { + return ((lng + 540) % 360) - 180; +} + +export function canonicalLatLng(point: LatLng): LatLng { + return { + latitude: point.latitude, + longitude: canonicalLongitude(point.longitude), + }; +} + +function coordsNear(a: number, b: number): boolean { + return Math.abs(a - b) < COORD_EPSILON; +} + +/** Unwrap consecutive longitudes so adjacent edges never jump more than 180°. */ +export function unwrapRingLongitudes(ring: LatLng[]): LatLng[] { + if (ring.length === 0) return ring; + + const result: LatLng[] = [{ ...ring[0]! }]; + for (let i = 1; i < ring.length; i += 1) { + const prev = result[result.length - 1]!; + let lng = ring[i]!.longitude; + while (lng - prev.longitude > 180) lng -= 360; + while (lng - prev.longitude < -180) lng += 360; + result.push({ latitude: ring[i]!.latitude, longitude: lng }); + } + + return result; +} + +/** Spherical centroid for projection anchor. */ +export function ringSphericalCentroid(ring: LatLng[]): { + lat: number; + lng: number; +} { + let x = 0; + let y = 0; + let z = 0; + + for (const point of ring) { + const phi = (90 - point.latitude) * DEG2RAD; + const theta = (point.longitude + 180) * DEG2RAD; + x += -Math.sin(phi) * Math.cos(theta); + y += Math.cos(phi); + z += Math.sin(phi) * Math.sin(theta); + } + + const len = Math.hypot(x, y, z); + if (len < 1e-8) { + return { lat: ring[0]?.latitude ?? 0, lng: ring[0]?.longitude ?? 0 }; + } + + x /= len; + y /= len; + z /= len; + + const lat = (Math.asin(Math.max(-1, Math.min(1, y))) * 180) / Math.PI; + const lng = (((Math.atan2(z, -x) * 180) / Math.PI + 540) % 360) - 180; + return { lat, lng }; +} + +/** Stereographic projection — stable for large country polygons on the sphere. */ +export function projectToStereographicFlat( + ring: LatLng[], + center: { lat: number; lng: number }, +): number[] { + const phi1 = center.lat * DEG2RAD; + const lam0 = center.lng * DEG2RAD; + const cosPhi1 = Math.cos(phi1); + const sinPhi1 = Math.sin(phi1); + const flat: number[] = []; + + for (const point of ring) { + const phi = point.latitude * DEG2RAD; + const lam = point.longitude * DEG2RAD; + const cosPhi = Math.cos(phi); + const sinPhi = Math.sin(phi); + const cosLamDiff = Math.cos(lam - lam0); + const sinLamDiff = Math.sin(lam - lam0); + const denom = 1 + sinPhi1 * sinPhi + cosPhi1 * cosPhi * cosLamDiff; + + if (denom < 1e-6) { + flat.push( + (point.longitude - center.lng) * cosPhi1, + point.latitude - center.lat, + ); + continue; + } + + const k = 2 / denom; + flat.push( + k * cosPhi * sinLamDiff, + k * (cosPhi1 * sinPhi - sinPhi1 * cosPhi * cosLamDiff), + ); + } + + return flat; +} + +/** Triangulate a simple polygon ring (flat [x,y,...]). */ +export function triangulatePolygonFlat(flat: number[]): number[] { + if (flat.length < 6) return []; + return earcut(flat); +} + +/** Triangulate polygon with holes using earcut hole indices. */ +export function triangulatePolygonWithHolesFlat( + flat: number[], + holeIndices: number[], +): number[] { + if (flat.length < 6) return []; + return earcut(flat, holeIndices); +} + +/** Build flat coords + hole indices for outer ring and holes (same projection). */ +export function polygonRingsToFlat( + outer: LatLng[], + holes: LatLng[][], +): { flat: number[]; holeIndices: number[]; points: LatLng[] } { + const unwrappedOuter = unwrapRingLongitudes(outer); + const center = ringSphericalCentroid(unwrappedOuter); + const points: LatLng[] = [...unwrappedOuter]; + const flat = projectToStereographicFlat(unwrappedOuter, center); + const holeIndices: number[] = []; + + for (const hole of holes) { + holeIndices.push(points.length); + const unwrappedHole = unwrapRingLongitudes(hole); + points.push(...unwrappedHole); + flat.push(...projectToStereographicFlat(unwrappedHole, center)); + } + + return { flat, holeIndices, points }; +} + +/** Project a single ring for simple fills (no holes). */ +export function ringToProjectionFlat(ring: LatLng[]): { + flat: number[]; + points: LatLng[]; +} { + const points = unwrapRingLongitudes(ring); + const center = ringSphericalCentroid(points); + return { + points, + flat: projectToStereographicFlat(points, center), + }; +} + +/** @deprecated Use triangulatePolygonFlat (earcut). */ +export function triangulateEarClip(flat: number[]): number[] { + return triangulatePolygonFlat(flat); +} + +/** @deprecated Use ringToProjectionFlat. */ +export function ringToTangentPlaneFlat(ring: LatLng[]): number[] { + return ringToProjectionFlat(ring).flat; +} + +/** + * Split rings that cross the antimeridian into hemispheric chains with seam vertices. + * Falls back to the original ring when no seam is inserted. + */ +export function splitRingAtAntimeridian(ring: LatLng[]): LatLng[][] { + if (ring.length < 3) return []; + + type SeamSplit = { exit: LatLng; enter: LatLng }; + const expanded: LatLng[] = []; + const seamSplits: SeamSplit[] = []; + + for (let i = 0; i < ring.length; i += 1) { + const curr = ring[i]!; + const next = ring[(i + 1) % ring.length]!; + expanded.push(curr); + + let dLng = next.longitude - curr.longitude; + if (Math.abs(dLng) <= 180) continue; + if (dLng > 180) dLng -= 360; + if (dLng < -180) dLng += 360; + + const goingEast = dLng > 0; + const seamExitLng = goingEast ? 180 : -180; + const seamEnterLng = -seamExitLng; + const t = (seamExitLng - curr.longitude) / dLng; + const lat = curr.latitude + t * (next.latitude - curr.latitude); + const exit = { latitude: lat, longitude: seamExitLng }; + const enter = { latitude: lat, longitude: seamEnterLng }; + expanded.push(exit, enter); + seamSplits.push({ exit, enter }); + } + + if (seamSplits.length === 0) return [ring]; + + const chains: LatLng[][] = []; + let current: LatLng[] = []; + + for (const point of expanded) { + const prev = current[current.length - 1]; + const isSeamEnter = + prev && + seamSplits.some( + (seam) => + coordsNear(seam.enter.latitude, point.latitude) && + coordsNear(seam.enter.longitude, point.longitude) && + coordsNear(seam.exit.latitude, prev.latitude) && + coordsNear(seam.exit.longitude, prev.longitude), + ); + + if (isSeamEnter) { + if (current.length >= 3) chains.push(current); + current = [point]; + continue; + } + + current.push(point); + } + + if (current.length >= 3) chains.push(current); + return chains.length > 0 ? chains : [ring]; +} diff --git a/lib/globe-rotation.test.ts b/lib/globe-rotation.test.ts new file mode 100644 index 0000000..e14cb5d --- /dev/null +++ b/lib/globe-rotation.test.ts @@ -0,0 +1,41 @@ +import * as THREE from "three"; +import { describe, expect, it } from "vitest"; + +import { + GLOBE_CAMERA_VIEW_DIRECTION, + latLngFromWorldNormal, + quaternionForLatLngFacingCamera, + viewCenterLatLngFromGlobeQuaternion, + worldPointFromLatLng, +} from "@/lib/globe-rotation"; +import { latLngToVector3 } from "@/lib/latlng-to-sphere"; + +describe("globe-rotation", () => { + it("rotates a target lat/lng under the fixed camera view axis", () => { + const quat = quaternionForLatLngFacingCamera(35, 139); + const world = worldPointFromLatLng(35, 139, 1, quat); + + expect(world.angleTo(GLOBE_CAMERA_VIEW_DIRECTION)).toBeLessThan(0.02); + }); + + it("reports the centered lat/lng from the globe quaternion", () => { + const quat = quaternionForLatLngFacingCamera(-33, 151); + const [lat, lng] = viewCenterLatLngFromGlobeQuaternion(quat); + + expect(lat).toBeCloseTo(-33, 0); + expect(lng).toBeCloseTo(151, 0); + }); + + it("maps a world surface tap back to globe-local lat/lng", () => { + const quat = new THREE.Quaternion().setFromAxisAngle( + new THREE.Vector3(0, 1, 0), + Math.PI / 4, + ); + const local = new THREE.Vector3(...latLngToVector3(10, 20, 1)); + const world = local.clone().applyQuaternion(quat); + const [lat, lng] = latLngFromWorldNormal(world, quat); + + expect(lat).toBeCloseTo(10, 0); + expect(lng).toBeCloseTo(20, 0); + }); +}); diff --git a/lib/globe-rotation.ts b/lib/globe-rotation.ts new file mode 100644 index 0000000..c9f98b5 --- /dev/null +++ b/lib/globe-rotation.ts @@ -0,0 +1,86 @@ +import * as THREE from "three"; + +import { latLngToVector3, vector3ToLatLng } from "@/lib/latlng-to-sphere"; + +/** Atlantic-centered view — must match `INITIAL_CAMERA_POSITION` in globe-view. */ +const INITIAL_VIEW_LAT = 4; +const INITIAL_VIEW_LNG = -36; + +const scratchVector = new THREE.Vector3(); +const scratchInverse = new THREE.Quaternion(); + +/** Unit direction from globe center toward the fixed camera (view axis). */ +export const GLOBE_CAMERA_VIEW_DIRECTION = new THREE.Vector3( + ...latLngToVector3(INITIAL_VIEW_LAT, INITIAL_VIEW_LNG, 1), +).normalize(); + +export function latLngToUnitSphereVector( + lat: number, + lng: number, +): THREE.Vector3 { + return scratchVector.set(...latLngToVector3(lat, lng, 1)).normalize(); +} + +/** + * Globe quaternion that places `lat/lng` on the hemisphere facing the fixed camera. + * Applies as `quaternion * localPoint` in world space. + */ +export function quaternionForLatLngFacingCamera( + lat: number, + lng: number, + viewDir: THREE.Vector3 = GLOBE_CAMERA_VIEW_DIRECTION, +): THREE.Quaternion { + const point = latLngToUnitSphereVector(lat, lng); + return new THREE.Quaternion().setFromUnitVectors(point, viewDir); +} + +/** Lat/lng currently centered under the fixed camera for a globe orientation. */ +export function viewCenterLatLngFromGlobeQuaternion( + globeQuaternion: THREE.Quaternion, + viewDir: THREE.Vector3 = GLOBE_CAMERA_VIEW_DIRECTION, +): [lat: number, lng: number] { + const local = scratchVector + .copy(viewDir) + .applyQuaternion(scratchInverse.copy(globeQuaternion).invert()); + return vector3ToLatLng(local.x, local.y, local.z); +} + +/** World-space surface point for a lat/lng after globe rotation. */ +export function worldPointFromLatLng( + lat: number, + lng: number, + radius: number, + globeQuaternion: THREE.Quaternion, + target = new THREE.Vector3(), +): THREE.Vector3 { + return target + .set(...latLngToVector3(lat, lng, radius)) + .applyQuaternion(globeQuaternion); +} + +/** World-space unit normal → lat/lng in globe-local coordinates. */ +export function latLngFromWorldNormal( + worldNormal: THREE.Vector3, + globeQuaternion: THREE.Quaternion, +): [lat: number, lng: number] { + const local = scratchVector + .copy(worldNormal) + .normalize() + .applyQuaternion(scratchInverse.copy(globeQuaternion).invert()); + return vector3ToLatLng(local.x, local.y, local.z); +} + +/** + * Orbit-control camera move `prevDir → nextDir` re-expressed as globe rotation + * (fixed camera, world spins underneath). + */ +export function globeQuaternionDeltaForCameraOrbit( + prevDir: THREE.Vector3, + nextDir: THREE.Vector3, +): THREE.Quaternion { + // Camera moved prev → next; spin the globe so content under `next` lands on `prev`. + return new THREE.Quaternion().setFromUnitVectors( + nextDir.clone().normalize(), + prevDir.clone().normalize(), + ); +} diff --git a/lib/globe-screen-project.ts b/lib/globe-screen-project.ts index 8ca8901..6870cd3 100644 --- a/lib/globe-screen-project.ts +++ b/lib/globe-screen-project.ts @@ -33,12 +33,14 @@ export function projectLatLngToScreen( camera: Camera, size: { width: number; height: number }, radius = GLOBE_SURFACE_RADIUS, + globeQuaternion?: THREE.Quaternion, ): { x: number; y: number; visible: boolean } { const surface = new THREE.Vector3(...latLngToVector3(lat, lng, radius)); + if (globeQuaternion) { + surface.applyQuaternion(globeQuaternion); + } const normal = surface.clone().normalize(); - const cameraDirection = new THREE.Vector3() - .copy(camera.position) - .normalize(); + const cameraDirection = new THREE.Vector3().copy(camera.position).normalize(); const onVisibleHemisphere = normal.dot(cameraDirection) > HEMISPHERE_DOT_THRESHOLD; diff --git a/lib/globe-tap-debug.ts b/lib/globe-tap-debug.ts new file mode 100644 index 0000000..1ffc0a9 --- /dev/null +++ b/lib/globe-tap-debug.ts @@ -0,0 +1,43 @@ +import type { CameraZoomTier } from "@/lib/map-camera-zoom"; +import type { MapPressCoordinate } from "@/lib/map-map-tap-hit"; + +/** Dev-only traces for 3D globe tap routing (country vs continent). */ +export const GLOBE_TAP_DEBUG = __DEV__; + +export type GlobeTapSource = "surface" | "boundary-mesh" | "controller"; + +export type GlobeTapLogPayload = { + source: GlobeTapSource; + /** Pipeline stage — raw input, routing decision, or executed action. */ + stage: "input" | "routing" | "action" | "skip"; + /** Short outcome label for grep-friendly logs. */ + outcome: string; + coordinate?: MapPressCoordinate; + country?: string | null; + region?: string | null; + focusedRegion?: string | null; + boundaryFocusRegion?: string | null; + cameraTier?: CameraZoomTier; + globeDistance?: number; + /** Routing booleans and other debug context. */ + flags?: Record; +}; + +export function logGlobeTap(payload: GlobeTapLogPayload): void { + if (!GLOBE_TAP_DEBUG) return; + + const { source, stage, outcome, coordinate, flags, ...rest } = payload; + + console.log("[globe-tap]", outcome, { + source, + stage, + ...(coordinate + ? { + lat: +coordinate.latitude.toFixed(2), + lng: +coordinate.longitude.toFixed(2), + } + : {}), + ...rest, + ...(flags && Object.keys(flags).length > 0 ? { flags } : {}), + }); +} diff --git a/lib/map-camera-zoom.test.ts b/lib/map-camera-zoom.test.ts new file mode 100644 index 0000000..c8de21e --- /dev/null +++ b/lib/map-camera-zoom.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { + resolveGlobeContinentTargetDistance, + resolveGlobeDistanceFromLatitudeDelta, +} from "@/lib/map-camera-zoom"; +import { + GLOBE_DETAIL_CAMERA_DISTANCE, + GLOBE_REGION_CAMERA_DISTANCE, + GLOBE_WORLD_CAMERA_DISTANCE, +} from "@/lib/map-region-markers"; + +describe("resolveGlobeDistanceFromLatitudeDelta", () => { + it("maps world-scale flat zoom to world globe distance", () => { + expect(resolveGlobeDistanceFromLatitudeDelta(120)).toBe( + GLOBE_WORLD_CAMERA_DISTANCE, + ); + }); + + it("maps region-scale flat zoom to region globe distance", () => { + expect(resolveGlobeDistanceFromLatitudeDelta(45)).toBe( + GLOBE_REGION_CAMERA_DISTANCE, + ); + }); + + it("maps country-scale flat zoom to detail globe distance", () => { + expect(resolveGlobeDistanceFromLatitudeDelta(18)).toBe( + GLOBE_DETAIL_CAMERA_DISTANCE, + ); + }); +}); + +describe("resolveGlobeContinentTargetDistance", () => { + it("caps continent framing at region distance when flat map is at world zoom", () => { + expect(resolveGlobeContinentTargetDistance(120)).toBe( + GLOBE_REGION_CAMERA_DISTANCE, + ); + }); + + it("preserves closer flat zoom when already inside region framing", () => { + expect(resolveGlobeContinentTargetDistance(18)).toBe( + GLOBE_DETAIL_CAMERA_DISTANCE, + ); + }); +}); diff --git a/lib/map-camera-zoom.ts b/lib/map-camera-zoom.ts index 9d7e83a..646615e 100644 --- a/lib/map-camera-zoom.ts +++ b/lib/map-camera-zoom.ts @@ -1,5 +1,7 @@ import { GLOBE_DETAIL_CAMERA_DISTANCE, + GLOBE_REGION_CAMERA_DISTANCE, + GLOBE_WORLD_CAMERA_DISTANCE, MAP_COUNTRY_ZOOM_LATITUDE_DELTA, resolveGlobeZoomTier, } from "@/lib/map-region-markers"; @@ -16,6 +18,32 @@ export function resolveFlatZoomTier(latitudeDelta: number): CameraZoomTier { return "country"; } +/** Match 2D zoom tier to the nearest globe camera distance constant. */ +export function resolveGlobeDistanceFromLatitudeDelta( + latitudeDelta: number, +): number { + if (!Number.isFinite(latitudeDelta) || latitudeDelta <= 0) { + return GLOBE_WORLD_CAMERA_DISTANCE; + } + + const tier = resolveFlatZoomTier(latitudeDelta); + if (tier === "world") return GLOBE_WORLD_CAMERA_DISTANCE; + if (tier === "region") return GLOBE_REGION_CAMERA_DISTANCE; + return GLOBE_DETAIL_CAMERA_DISTANCE; +} + +/** + * Continent focus on the globe — never looser than region framing; keep closer 2D zoom. + */ +export function resolveGlobeContinentTargetDistance( + latitudeDelta: number, +): number { + return Math.min( + resolveGlobeDistanceFromLatitudeDelta(latitudeDelta), + GLOBE_REGION_CAMERA_DISTANCE, + ); +} + export type CameraZoomState = { tier: CameraZoomTier; isDetailZoom: boolean; diff --git a/lib/map-clusters.ts b/lib/map-clusters.ts index b0bdd40..9fd88bf 100644 --- a/lib/map-clusters.ts +++ b/lib/map-clusters.ts @@ -1,5 +1,5 @@ -import { CONTINENTS } from "@/constants/regions"; import { getClusterActivity } from "@/constants/map-activity"; +import { CONTINENTS } from "@/constants/regions"; import { isValidLatLng } from "@/lib/map-country"; import type { MapCountry } from "@/types/country"; @@ -19,7 +19,8 @@ function weightedCenter(countries: MapCountry[]): [number, number] { for (const c of countries) { if (!isValidLatLng(c.latlng)) continue; const [lat, lng] = c.latlng; - const w = Number.isFinite(c.population) && c.population > 0 ? c.population : 1; + const w = + Number.isFinite(c.population) && c.population > 0 ? c.population : 1; totalWeight += w; sumLat += lat * w; sumLng += lng * w; @@ -47,7 +48,7 @@ export function buildMapClusters(countries: MapCountry[]): MapCluster[] { return orderedRegions.map((region) => { const clusterCountries = byRegion.get(region) ?? []; const center = weightedCenter(clusterCountries); - const activity = getClusterActivity(clusterCountries); + const activity = getClusterActivity(clusterCountries, countries); return { id: `cluster:${region}`, @@ -58,4 +59,3 @@ export function buildMapClusters(countries: MapCountry[]): MapCluster[] { }; }); } - diff --git a/lib/map-country-boundaries.ts b/lib/map-country-boundaries.ts index 5b64d42..b0f41ec 100644 --- a/lib/map-country-boundaries.ts +++ b/lib/map-country-boundaries.ts @@ -47,7 +47,7 @@ export type BoundaryMapContext = { /** REST Countries name → alternate Natural Earth `ADMIN` labels. */ const GEO_ADMIN_ALIASES_BY_API_NAME: Record = { "United States": ["United States of America"], - "Czechia": ["Czechia", "Czech Republic"], + Czechia: ["Czechia", "Czech Republic"], "Cape Verde": ["Cabo Verde"], "Ivory Coast": ["Côte d'Ivoire", "Cote d'Ivoire"], Eswatini: ["eSwatini", "Swaziland"], @@ -60,6 +60,8 @@ const GEO_ADMIN_ALIASES_BY_API_NAME: Record = { "Dem. Rep. Congo", "Democratic Republic of the Congo", ], + /** REST Countries `name.common` — geo uses full ADMIN label. */ + "DR Congo": ["Dem. Rep. Congo", "Democratic Republic of the Congo"], "South Georgia": ["South Georgia and the Islands"], }; @@ -89,9 +91,7 @@ function toLatLng([longitude, latitude]: number[]): LatLng { } function toRingPoints(ring: number[][]): LatLng[] { - return ring - .filter((point) => point.length >= 2) - .map(toLatLng); + return ring.filter((point) => point.length >= 2).map(toLatLng); } /** @@ -200,6 +200,17 @@ export function parseCountryBoundaryPolygons( return polygons; } +let parsedCountryBoundaries: CountryBoundaryPolygon[] | null = null; + +export function getCountryBoundaryPolygons( + geoJson: GeoJsonFeatureCollection, +): CountryBoundaryPolygon[] { + if (!parsedCountryBoundaries) { + parsedCountryBoundaries = parseCountryBoundaryPolygons(geoJson); + } + return parsedCountryBoundaries; +} + export function countryNamesMatch( apiCountryName: string, geoAdminName: string | null, @@ -230,8 +241,8 @@ function naturalEarthContinentMatchesRegion( /** * Scope boundaries to map context (option 1 — implicit, no extra UI): - * - Selected country (no continent focus) → that country only - * - Focused continent → countries in that region (even when a country is selected) + * - Selected country → that country only (even when a continent is focused) + * - Focused continent (no country selected) → countries in that region * - No focus → hidden unless `showWorldBoundaries` (grid toggle on world view) */ export function filterBoundaryPolygonsByMapContext( @@ -245,6 +256,12 @@ export function filterBoundaryPolygonsByMapContext( return showWorldBoundaries ? polygons : []; } + if (selectedCountryName) { + return polygons.filter((polygon) => + countryNamesMatch(selectedCountryName, polygon.countryName), + ); + } + if (focusedRegion) { const apiCountryNames = countries .filter((country) => country.region === focusedRegion) @@ -267,11 +284,5 @@ export function filterBoundaryPolygonsByMapContext( }); } - if (selectedCountryName) { - return polygons.filter((polygon) => - countryNamesMatch(selectedCountryName, polygon.countryName), - ); - } - return []; } diff --git a/lib/map-country-focus-polygons.test.ts b/lib/map-country-focus-polygons.test.ts new file mode 100644 index 0000000..272131d --- /dev/null +++ b/lib/map-country-focus-polygons.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import type { CountryBoundaryPolygon } from "@/lib/map-country-boundaries"; +import { + resolveCountryFocusRenderPolygons, + shouldFillCountryHighlightGaps, +} from "@/lib/map-country-focus-polygons"; + +const samplePolygon: CountryBoundaryPolygon = { + id: "test", + countryName: "Testland", + continent: "Europe", + coordinates: [ + { latitude: 0, longitude: 0 }, + { latitude: 1, longitude: 0 }, + { latitude: 1, longitude: 1 }, + ], + holes: [ + [ + { latitude: 0.2, longitude: 0.2 }, + { latitude: 0.8, longitude: 0.2 }, + { latitude: 0.8, longitude: 0.8 }, + ], + ], +}; + +describe("map-country-focus-polygons", () => { + it("keeps holes when continent overlay is not active", () => { + const result = resolveCountryFocusRenderPolygons([samplePolygon], false); + expect(result[0]?.holes).toHaveLength(1); + }); + + it("drops holes when continent overlay is active below the highlight", () => { + const result = resolveCountryFocusRenderPolygons([samplePolygon], true); + expect(result[0]?.holes).toBeUndefined(); + }); + + it("detects visible continent overlay from committed or preview region", () => { + expect(shouldFillCountryHighlightGaps(null, null)).toBe(false); + expect(shouldFillCountryHighlightGaps("Europe", null)).toBe(true); + expect(shouldFillCountryHighlightGaps(null, "Asia")).toBe(true); + expect(shouldFillCountryHighlightGaps("Europe", "Asia")).toBe(true); + }); +}); diff --git a/lib/map-country-focus-polygons.ts b/lib/map-country-focus-polygons.ts new file mode 100644 index 0000000..f52ab73 --- /dev/null +++ b/lib/map-country-focus-polygons.ts @@ -0,0 +1,22 @@ +import type { CountryBoundaryPolygon } from "@/lib/map-country-boundaries"; + +/** + * When a continent overlay sits under the country highlight, polygon holes + * (lakes, bays) read as empty gaps. Drop holes so the highlight reads solid. + */ +export function resolveCountryFocusRenderPolygons( + polygons: CountryBoundaryPolygon[], + fillGapsWhenContinentOverlay: boolean, +): CountryBoundaryPolygon[] { + if (!fillGapsWhenContinentOverlay) return polygons; + + return polygons.map(({ holes: _holes, ...polygon }) => polygon); +} + +/** True when committed or preview continent fills are on screen. */ +export function shouldFillCountryHighlightGaps( + continentOverlayRegion: string | null | undefined, + previewRegion: string | null | undefined, +): boolean { + return !!(continentOverlayRegion || previewRegion); +} diff --git a/lib/map-country-selection.ts b/lib/map-country-selection.ts index 134e3df..a045467 100644 --- a/lib/map-country-selection.ts +++ b/lib/map-country-selection.ts @@ -1,4 +1,3 @@ -import { logMapDebug, summarizeCountry } from "@/lib/map-debug"; import { useExperienceStore } from "@/store/use-experience-store"; import { useIdentityStore, @@ -11,10 +10,6 @@ export function selectCountryOnMap( country: MapCountry, source: Exclude, ): void { - logMapDebug("selection", "selectCountryOnMap", { - source, - country: summarizeCountry(country), - }); useIdentityStore.getState().setActiveCountry(country, source); useExperienceStore.getState().startTransition(source); } diff --git a/lib/map-country.ts b/lib/map-country.ts index 597a716..28d5229 100644 --- a/lib/map-country.ts +++ b/lib/map-country.ts @@ -1,3 +1,4 @@ +import { cca3FromCca2 } from "@/lib/cca2-to-cca3"; import type { Country, MapCountry } from "@/types/country"; /** Extract ISO alpha-2 from a flagcdn URL when present. */ @@ -6,6 +7,11 @@ export function cca2FromFlagUrl(flag: string): string { return match?.[1]?.toUpperCase() ?? ""; } +/** Resolve ISO alpha-3 from a flagcdn URL (via alpha-2 lookup). */ +export function cca3FromFlagUrl(flag: string): string { + return cca3FromCca2(cca2FromFlagUrl(flag)); +} + /** Minimal `Country` for save / Explore when full detail is not loaded yet. */ export function mapCountryToCountry( map: MapCountry, diff --git a/lib/map-debug.ts b/lib/map-debug.ts index 8ff86bf..2feb044 100644 --- a/lib/map-debug.ts +++ b/lib/map-debug.ts @@ -1,21 +1,5 @@ import type { Region } from "react-native-maps"; -import { getMapDisplayLatLng, isValidLatLng } from "@/lib/map-country"; -import type { MapCountry } from "@/types/country"; - -/** Toggle to `true` to log map FAB / flight / selection diagnostics (dev only by default). */ -export const MAP_DEBUG_ENABLED = __DEV__; - -export type MapDebugScope = - | "fab" - | "flight" - | "selection" - | "intent" - | "camera" - | "marker" - | "reveal" - | "fatal"; - export function summarizeRegion(region: Region) { const finite = Number.isFinite(region.latitude) && @@ -31,60 +15,3 @@ export function summarizeRegion(region: Region) { finite, }; } - -export function summarizeCountry(country: MapCountry | null | undefined) { - if (!country) return null; - const latlng = getMapDisplayLatLng(country); - return { - name: country.name, - region: country.region, - latlng, - coordsValid: isValidLatLng(latlng), - }; -} - -export function logMapDebug( - scope: MapDebugScope, - event: string, - data?: Record, -): void { - if (!MAP_DEBUG_ENABLED) return; - if (data) { - console.log(`[map:${scope}] ${event}`, data); - } else { - console.log(`[map:${scope}] ${event}`); - } -} - -type ErrorUtilsLike = { - getGlobalHandler?: () => ((error: unknown, isFatal?: boolean) => void) | undefined; - setGlobalHandler?: (handler: (error: unknown, isFatal?: boolean) => void) => void; -}; - -let mapDebugErrorHandlerInstalled = false; - -/** - * Installs a global JS error handler that logs the error + stack before the app - * dies. Helps capture the cause of map crashes that otherwise show no JS trace. - * Chains to the previous handler so default red-box / crash behavior is preserved. - */ -export function installMapDebugErrorHandler(): void { - if (!MAP_DEBUG_ENABLED || mapDebugErrorHandlerInstalled) return; - - const errorUtils = (globalThis as { ErrorUtils?: ErrorUtilsLike }).ErrorUtils; - if (!errorUtils?.setGlobalHandler) return; - - mapDebugErrorHandlerInstalled = true; - const previous = errorUtils.getGlobalHandler?.(); - - errorUtils.setGlobalHandler((error, isFatal) => { - const err = error as { name?: string; message?: string; stack?: string }; - console.log("[map:fatal] global JS error", { - isFatal, - name: err?.name, - message: err?.message, - stack: err?.stack, - }); - previous?.(error, isFatal); - }); -} diff --git a/lib/map-discovery-flight.test.ts b/lib/map-discovery-flight.test.ts new file mode 100644 index 0000000..9115a94 --- /dev/null +++ b/lib/map-discovery-flight.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; + +import { WORLD_INITIAL_REGION } from "@/constants/map-regions"; +import { buildDiscoveryPhases } from "@/lib/map-discovery-flight"; +import type { MapCountry } from "@/types/country"; + +const japan: MapCountry = { + name: "Japan", + population: 125_000_000, + region: "Asia", + capital: "Tokyo", + flag: "https://flagcdn.com/w320/jp.png", + image: null, + latlng: [36, 138], +}; + +describe("buildDiscoveryPhases", () => { + it.each(["explore", "fab"] as const)( + "uses a single world-view pan for %s", + (source) => { + const phases = buildDiscoveryPhases({ + pick: japan, + cluster: null, + source, + includeWorld: false, + }); + + expect(phases).toHaveLength(1); + expect(phases[0]?.duration).toBe(900); + expect(phases[0]?.region).toMatchObject({ + latitude: 36, + longitude: 138, + latitudeDelta: WORLD_INITIAL_REGION.latitudeDelta, + longitudeDelta: WORLD_INITIAL_REGION.longitudeDelta, + }); + }, + ); + + it("runs continent-only for search focus; adds country phase for preview", () => { + const focusPhases = buildDiscoveryPhases({ + pick: japan, + cluster: null, + source: "search", + includeWorld: false, + mode: "focus", + }); + expect(focusPhases).toHaveLength(1); + expect(focusPhases[0]?.region.latitudeDelta).toBeGreaterThan(28); + + const previewPhases = buildDiscoveryPhases({ + pick: japan, + cluster: null, + source: "search", + includeWorld: false, + mode: "preview", + }); + expect(previewPhases).toHaveLength(2); + expect(previewPhases[1]?.region.latitudeDelta).toBeLessThan(28); + }); + + it("keeps continent framing for focus map taps on 2D", () => { + const phases = buildDiscoveryPhases({ + pick: japan, + cluster: null, + source: "mapTap", + includeWorld: false, + mode: "focus", + }); + + expect(phases).toHaveLength(1); + expect(phases[0]?.region.latitudeDelta).toBeGreaterThan(28); + }); + + it("zooms to country detail for preview shuffle on 2D", () => { + const phases = buildDiscoveryPhases({ + pick: japan, + cluster: null, + source: "shuffle", + includeWorld: false, + mode: "preview", + }); + + expect(phases).toHaveLength(1); + expect(phases[0]?.region.latitudeDelta).toBeLessThan(28); + }); +}); diff --git a/lib/map-discovery-flight.ts b/lib/map-discovery-flight.ts index e35aeaf..7edf1e0 100644 --- a/lib/map-discovery-flight.ts +++ b/lib/map-discovery-flight.ts @@ -1,25 +1,26 @@ import type { Region } from "react-native-maps"; -import { - regionForClusterFocus, - regionForMapCountry, - WORLD_INITIAL_REGION, -} from "@/constants/map-regions"; +import { WORLD_INITIAL_REGION } from "@/constants/map-regions"; import type { FlightPhase } from "@/hooks/use-map-flight"; import type { MapCluster } from "@/lib/map-clusters"; import { REGION_FOCUS_INITIAL_DELTA } from "@/lib/map-region-markers"; +import { + flightRegionForClusterFocus, + flightRegionForCountry, + flightRegionForWorldViewCountry, +} from "@/lib/map-signal-sources"; import type { SelectionSource } from "@/store/use-identity-store"; import type { MapCountry } from "@/types/country"; const WORLD_PHASE_MS = 700; const CONTINENT_PHASE_MS = 600; const COUNTRY_PHASE_MS = 750; +/** Explore → Map and random FAB — one pan at world zoom to the country. */ +const WORLD_VIEW_PAN_MS = 900; /** Direct map taps are already near the target — snap in faster. */ const COUNTRY_TAP_MS = 520; /** Preview shuffle — one continuous retarget from the current camera. */ const COUNTRY_RETARGET_MS = 680; -/** Random FAB — same single-flight pattern, but framed at continent zoom. */ -const FAB_CONTINENT_RETARGET_MS = 680; /** One continuous camera move — avoids stacked animateToRegion crashes on iOS. */ const COUNTRY_RETARGET_SOURCES = new Set>([ @@ -34,50 +35,61 @@ export type DiscoveryFlightParams = { source: Exclude; /** Opening world pan — included for cinematic programmatic discovery only. */ includeWorld: boolean; + /** Focus keeps continent framing; preview zooms to country detail. */ + mode?: "focus" | "preview"; }; +const WORLD_VIEW_PAN_SOURCES = new Set>([ + "explore", + "fab", +]); + /** * Builds camera phases for country navigation. * Marker density/UI derive separately from live zoom — phases only move the camera. * * Map tap and preview shuffle retarget in one continuous country-zoom flight from - * the current viewport. Random FAB uses the same pattern at continent zoom. - * - * Explore runs continent → country; search may prepend a world pan when `includeWorld`. + * the current viewport. Explore and the random FAB pan at world zoom. */ export function buildDiscoveryPhases({ pick, cluster, source, includeWorld, + mode = "focus", }: DiscoveryFlightParams): FlightPhase[] { - const countryRegion = regionForMapCountry(pick); + const countryRegion = flightRegionForCountry(pick); + const continentOnCountry = flightRegionForCountry( + pick, + REGION_FOCUS_INITIAL_DELTA, + ); - if (source === "fab") { - // Continent zoom, centered on the picked country (not the cluster centroid). + if (WORLD_VIEW_PAN_SOURCES.has(source)) { return [ { - region: regionForMapCountry(pick, REGION_FOCUS_INITIAL_DELTA), - duration: FAB_CONTINENT_RETARGET_MS, + region: flightRegionForWorldViewCountry(pick), + duration: WORLD_VIEW_PAN_MS, }, ]; } if (COUNTRY_RETARGET_SOURCES.has(source)) { - const duration = - source === "mapTap" ? COUNTRY_TAP_MS : COUNTRY_RETARGET_MS; - return [{ region: countryRegion, duration }]; + const duration = source === "mapTap" ? COUNTRY_TAP_MS : COUNTRY_RETARGET_MS; + const region = mode === "preview" ? countryRegion : continentOnCountry; + return [{ region, duration }]; } const continentRegion: Region = cluster - ? regionForClusterFocus(cluster) - : regionForMapCountry(pick, REGION_FOCUS_INITIAL_DELTA); + ? flightRegionForClusterFocus(cluster) + : flightRegionForCountry(pick, REGION_FOCUS_INITIAL_DELTA); const phases: FlightPhase[] = []; if (includeWorld) { phases.push({ region: WORLD_INITIAL_REGION, duration: WORLD_PHASE_MS }); } phases.push({ region: continentRegion, duration: CONTINENT_PHASE_MS }); - phases.push({ region: countryRegion, duration: COUNTRY_PHASE_MS }); + if (mode === "preview") { + phases.push({ region: countryRegion, duration: COUNTRY_PHASE_MS }); + } return phases; } diff --git a/lib/map-external-focus.ts b/lib/map-external-focus.ts new file mode 100644 index 0000000..b0e0414 --- /dev/null +++ b/lib/map-external-focus.ts @@ -0,0 +1,51 @@ +import type { MapPresentationIntent } from "@/types/map-presentation"; + +export type ExternalMapFocusIntent = MapPresentationIntent; + +export type ExternalMapFocusDeferReason = + | "countries_loading" + | "country_not_in_list" + | "flat_map_not_ready" + | "globe_not_ready"; + +export type ExternalMapFocusEligibility = { + eligible: boolean; + deferReason?: ExternalMapFocusDeferReason; +}; + +/** Pure gate for cross-screen map focus handoffs (Explore → Map, etc.). */ +export function resolveExternalMapFocusEligibility(input: { + intent: ExternalMapFocusIntent | null; + countriesFullyLoaded: boolean; + countryFound: boolean; + alreadyAppliedCountryName: string | null; + useGlobeCamera: boolean; + flatMapReady: boolean; + globeReady: boolean; +}): ExternalMapFocusEligibility { + if (!input.intent) { + return { eligible: false }; + } + + if (input.alreadyAppliedCountryName === input.intent.countryName) { + return { eligible: false }; + } + + if (!input.countriesFullyLoaded) { + return { eligible: false, deferReason: "countries_loading" }; + } + + if (!input.countryFound) { + return { eligible: false, deferReason: "country_not_in_list" }; + } + + if (!input.useGlobeCamera && !input.flatMapReady) { + return { eligible: false, deferReason: "flat_map_not_ready" }; + } + + if (input.useGlobeCamera && !input.globeReady) { + return { eligible: false, deferReason: "globe_not_ready" }; + } + + return { eligible: true }; +} diff --git a/lib/map-logic.test.ts b/lib/map-logic.test.ts new file mode 100644 index 0000000..0b7311c --- /dev/null +++ b/lib/map-logic.test.ts @@ -0,0 +1,354 @@ +import { describe, expect, it } from "vitest"; + +import { resolveExternalMapFocusEligibility } from "@/lib/map-external-focus"; +import { + resolveFlatTransitionRestore, + resolveMapModeTogglePending, +} from "@/lib/map-mode-transition"; +import { + isRandomPickGenerationCurrent, + RANDOM_FAB_TAP_COOLDOWN_MS, + shouldAcceptRandomFabTap, +} from "@/lib/map-random-fab"; +import { + EXPLICIT_REGION_RELEASE_DISTANCE_DEGREES, + resolveRegionSettleDecision, + shouldCommitScheduledRegionSwitch, +} from "@/lib/map-region-settle"; + +const intent = { + countryName: "Japan", + mode: "focus" as const, + source: "explore" as const, +}; + +describe("resolveRegionSettleDecision", () => { + it("skips while the flat map is animating", () => { + expect( + resolveRegionSettleDecision({ + latitudeDelta: 24, + mapCenter: { latitude: 35, longitude: 139 }, + settleEnabled: true, + isMapAnimating: true, + suppressWorldReset: false, + explicitLock: null, + currentFocusedRegion: "Asia", + nearestRegion: "Asia", + pendingCandidate: null, + }), + ).toEqual({ kind: "skip", reason: "animating" }); + }); + + it("resets world state when zooming out unless suppressed", () => { + expect( + resolveRegionSettleDecision({ + latitudeDelta: 120, + mapCenter: { latitude: 20, longitude: 0 }, + settleEnabled: true, + isMapAnimating: false, + suppressWorldReset: false, + explicitLock: { region: "Asia", anchor: [35, 105] }, + currentFocusedRegion: "Asia", + nearestRegion: "Asia", + pendingCandidate: null, + }), + ).toEqual({ + kind: "world_tier", + clearPending: true, + resetWorld: true, + clearSuppressWorldReset: false, + }); + }); + + it("skips when region settle is disabled (e.g. flat map under 3D)", () => { + expect( + resolveRegionSettleDecision({ + latitudeDelta: 24, + mapCenter: { latitude: 35, longitude: 139 }, + settleEnabled: false, + isMapAnimating: false, + suppressWorldReset: false, + explicitLock: null, + currentFocusedRegion: "Asia", + nearestRegion: "Asia", + pendingCandidate: null, + }), + ).toEqual({ kind: "skip", reason: "disabled" }); + }); + + it("uses globe distance for explore tier on 3D viewport", () => { + expect( + resolveRegionSettleDecision({ + latitudeDelta: 120, + globeDistance: 2.75, + useGlobeDistance: true, + mapCenter: { latitude: -10, longitude: -55 }, + settleEnabled: true, + isMapAnimating: false, + suppressWorldReset: false, + explicitLock: null, + currentFocusedRegion: null, + nearestRegion: "South America", + pendingCandidate: null, + }), + ).toMatchObject({ + kind: "explore_tier", + scheduleRegionSwitch: "South America", + }); + }); + + it("holds an explicit region lock until the camera moves far enough", () => { + const decision = resolveRegionSettleDecision({ + latitudeDelta: 24, + mapCenter: { latitude: 35, longitude: 120 }, + is3d: false, + isMapAnimating: false, + suppressWorldReset: true, + explicitLock: { region: "Asia", anchor: [35, 105] }, + currentFocusedRegion: "Asia", + nearestRegion: "Europe", + pendingCandidate: null, + }); + + expect(decision).toMatchObject({ + kind: "explore_tier", + holdExplicitLock: true, + scheduleRegionSwitch: null, + }); + }); + + it("schedules a hysteresis region switch for a new nearest continent", () => { + expect( + resolveRegionSettleDecision({ + latitudeDelta: 24, + mapCenter: { latitude: 48, longitude: 2 }, + settleEnabled: true, + isMapAnimating: false, + suppressWorldReset: false, + explicitLock: null, + currentFocusedRegion: "Asia", + nearestRegion: "Europe", + pendingCandidate: null, + }), + ).toMatchObject({ + kind: "explore_tier", + scheduleRegionSwitch: "Europe", + releaseExplicitLock: false, + }); + }); + + it("releases an explicit lock once the camera exceeds the distance threshold", () => { + const anchorLat = 35; + const anchorLng = 105; + const offset = EXPLICIT_REGION_RELEASE_DISTANCE_DEGREES + 1; + + expect( + resolveRegionSettleDecision({ + latitudeDelta: 24, + mapCenter: { latitude: anchorLat + offset, longitude: anchorLng }, + settleEnabled: true, + isMapAnimating: false, + suppressWorldReset: false, + explicitLock: { region: "Asia", anchor: [anchorLat, anchorLng] }, + currentFocusedRegion: "Asia", + nearestRegion: "Europe", + pendingCandidate: null, + }), + ).toMatchObject({ + kind: "explore_tier", + releaseExplicitLock: true, + scheduleRegionSwitch: "Europe", + }); + }); +}); + +describe("shouldCommitScheduledRegionSwitch", () => { + it("aborts when the viewport returns to world zoom", () => { + expect( + shouldCommitScheduledRegionSwitch({ + latitudeDelta: 120, + pendingCandidate: "Europe", + expectedRegion: "Europe", + }), + ).toBe(false); + }); + + it("commits globe explore zoom from distance tier", () => { + expect( + shouldCommitScheduledRegionSwitch({ + globeDistance: 2.75, + useGlobeDistance: true, + pendingCandidate: "South America", + expectedRegion: "South America", + }), + ).toBe(true); + }); + + it("commits when the pending candidate still matches", () => { + expect( + shouldCommitScheduledRegionSwitch({ + latitudeDelta: 24, + pendingCandidate: "Europe", + expectedRegion: "Europe", + }), + ).toBe(true); + }); +}); + +describe("resolveExternalMapFocusEligibility", () => { + it("defers until countries are fully loaded", () => { + expect( + resolveExternalMapFocusEligibility({ + intent, + countriesFullyLoaded: false, + countryFound: true, + alreadyAppliedCountryName: null, + useGlobeCamera: false, + flatMapReady: true, + globeReady: false, + }), + ).toEqual({ eligible: false, deferReason: "countries_loading" }); + }); + + it("defers flat-map focus until MapView is ready", () => { + expect( + resolveExternalMapFocusEligibility({ + intent, + countriesFullyLoaded: true, + countryFound: true, + alreadyAppliedCountryName: null, + useGlobeCamera: false, + flatMapReady: false, + globeReady: false, + }), + ).toEqual({ eligible: false, deferReason: "flat_map_not_ready" }); + }); + + it("applies when flat prerequisites are satisfied", () => { + expect( + resolveExternalMapFocusEligibility({ + intent, + countriesFullyLoaded: true, + countryFound: true, + alreadyAppliedCountryName: null, + useGlobeCamera: false, + flatMapReady: true, + globeReady: false, + }), + ).toEqual({ eligible: true }); + }); + + it("ignores duplicate applications for the same country", () => { + expect( + resolveExternalMapFocusEligibility({ + intent, + countriesFullyLoaded: true, + countryFound: true, + alreadyAppliedCountryName: "Japan", + useGlobeCamera: false, + flatMapReady: true, + globeReady: false, + }), + ).toEqual({ eligible: false }); + }); +}); + +describe("random FAB helpers", () => { + it("rejects taps during animation or cooldown", () => { + expect( + shouldAcceptRandomFabTap({ + isMapAnimating: true, + nowMs: 10_000, + lastTapAtMs: 0, + }), + ).toBe(false); + + expect( + shouldAcceptRandomFabTap({ + isMapAnimating: false, + nowMs: 500, + lastTapAtMs: 0, + cooldownMs: RANDOM_FAB_TAP_COOLDOWN_MS, + }), + ).toBe(false); + }); + + it("accepts taps after the cooldown window", () => { + expect( + shouldAcceptRandomFabTap({ + isMapAnimating: false, + nowMs: RANDOM_FAB_TAP_COOLDOWN_MS, + lastTapAtMs: 0, + }), + ).toBe(true); + }); + + it("tracks stale async random pick generations", () => { + expect(isRandomPickGenerationCurrent(2, 3)).toBe(false); + expect(isRandomPickGenerationCurrent(3, 3)).toBe(true); + }); +}); + +describe("map mode transition helpers", () => { + it("queues flat restore state when leaving 3D", () => { + expect( + resolveMapModeTogglePending({ + currentMode: "3d", + activeCountryName: "Japan", + focusTransitionCountryName: null, + focusedRegion: "Asia", + presentationMode: "preview", + }), + ).toEqual({ + pendingFlatFocusName: "Japan", + pendingFlatPresentationMode: "preview", + pendingGlobeFocusName: null, + pendingGlobeRegionFocus: null, + }); + }); + + it("queues globe continent focus when entering 3D without a country", () => { + expect( + resolveMapModeTogglePending({ + currentMode: "2d", + activeCountryName: null, + focusTransitionCountryName: null, + focusedRegion: "Europe", + presentationMode: "idle", + }), + ).toEqual({ + pendingGlobeFocusName: null, + pendingGlobeRegionFocus: "Europe", + pendingFlatFocusName: null, + pendingFlatPresentationMode: null, + }); + }); + + it("restores country framing after 3D to 2D when preview was open", () => { + expect( + resolveFlatTransitionRestore({ + pendingFocusName: "Japan", + pendingPresentationMode: "preview", + activeCountryName: "Japan", + }), + ).toEqual({ + focusName: "Japan", + restorePreview: true, + framing: "country", + }); + }); + + it("uses continent framing for passive 3D to 2D handoffs", () => { + expect( + resolveFlatTransitionRestore({ + pendingFocusName: "Japan", + pendingPresentationMode: null, + activeCountryName: null, + }), + ).toEqual({ + focusName: "Japan", + restorePreview: false, + framing: "continent", + }); + }); +}); diff --git a/lib/map-map-tap-hit.test.ts b/lib/map-map-tap-hit.test.ts new file mode 100644 index 0000000..8036487 --- /dev/null +++ b/lib/map-map-tap-hit.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import { + countryNamesMatch, + getCountryBoundaryPolygons, +} from "@/lib/map-country-boundaries"; +import { + resolveGlobeSurfaceTapCountry, + resolveMapCountryAtCoordinate, +} from "@/lib/map-map-tap-hit"; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const countriesGeoJson = require("../assets/geo/ne_50m_admin_0_countries/ne_50m_admin_0_countries.json"); + +const allPolygons = getCountryBoundaryPolygons(countriesGeoJson); +const drcCoord = { latitude: -5.88, longitude: 26.97 }; + +describe("globe surface tap country resolution", () => { + it("matches REST Countries common name DR Congo to geo ADMIN label", () => { + expect( + countryNamesMatch("DR Congo", "Democratic Republic of the Congo"), + ).toBe(true); + }); + + it("resolves DRC from a globe surface tap coordinate", () => { + const countries = [ + { + name: "DR Congo", + region: "Africa", + latlng: [-4, 21] as [number, number], + population: 0, + capital: "", + flag: "", + image: "", + funFact: "", + }, + ]; + + expect( + resolveMapCountryAtCoordinate(allPolygons, countries, drcCoord)?.name, + ).toBe("DR Congo"); + expect( + resolveGlobeSurfaceTapCountry(allPolygons, countries, drcCoord)?.name, + ).toBe("DR Congo"); + }); +}); diff --git a/lib/map-map-tap-hit.ts b/lib/map-map-tap-hit.ts index dc9ef0b..2498ee8 100644 --- a/lib/map-map-tap-hit.ts +++ b/lib/map-map-tap-hit.ts @@ -70,7 +70,9 @@ function pointInRing(point: MapPressCoordinate, ring: LatLng[]): boolean { } /** Prefer nearest land when tap is just outside a country bbox (coasts). */ -const COAST_BIAS_DEGREES = 1; +export const MAP_TAP_COAST_BIAS_DEGREES = 1; +/** Looser bias for 3D globe surface taps (raycast lat/lng vs GeoJSON). */ +export const GLOBE_SURFACE_TAP_COAST_BIAS_DEGREES = 3; function distanceToBBox(point: MapPressCoordinate, bbox: RingBBox): number { const latDist = @@ -118,8 +120,9 @@ export function findMapCountryByBoundaryName( ): MapCountry | null { if (!geoAdminName) return null; return ( - countries.find((country) => countryNamesMatch(country.name, geoAdminName)) ?? - null + countries.find((country) => + countryNamesMatch(country.name, geoAdminName), + ) ?? null ); } @@ -140,11 +143,7 @@ export function findMapCountryAtCoordinate( const bbox = ringBBox(polygon.coordinates); if (!pointInBBox(coordinate, bbox)) continue; if ( - !pointInPolygonWithHoles( - coordinate, - polygon.coordinates, - polygon.holes, - ) + !pointInPolygonWithHoles(coordinate, polygon.coordinates, polygon.holes) ) { continue; } @@ -168,6 +167,7 @@ function findCountryNearCoast( polygons: CountryBoundaryPolygon[], countries: MapCountry[], coordinate: MapPressCoordinate, + coastBiasDegrees = MAP_TAP_COAST_BIAS_DEGREES, ): MapCountry | null { let best: { country: MapCountry; distance: number } | null = null; @@ -176,7 +176,7 @@ function findCountryNearCoast( const bbox = ringBBox(polygon.coordinates); const distance = distanceToBBox(coordinate, bbox); - if (distance > COAST_BIAS_DEGREES) continue; + if (distance > coastBiasDegrees) continue; const country = findMapCountryByBoundaryName( countries, @@ -192,6 +192,31 @@ function findCountryNearCoast( return best?.country ?? null; } +/** Resolves a map tap to a country (polygon hit, then coast bias). */ +export function resolveMapCountryAtCoordinate( + polygons: CountryBoundaryPolygon[], + countries: MapCountry[], + coordinate: MapPressCoordinate, + options?: { coastBiasDegrees?: number }, +): MapCountry | null { + const coastBias = options?.coastBiasDegrees ?? MAP_TAP_COAST_BIAS_DEGREES; + return ( + findMapCountryAtCoordinate(polygons, countries, coordinate) ?? + findCountryNearCoast(polygons, countries, coordinate, coastBias) + ); +} + +/** 3D surface taps — polygon hit with expanded coast bias for raycast error. */ +export function resolveGlobeSurfaceTapCountry( + polygons: CountryBoundaryPolygon[], + countries: MapCountry[], + coordinate: MapPressCoordinate, +): MapCountry | null { + return resolveMapCountryAtCoordinate(polygons, countries, coordinate, { + coastBiasDegrees: GLOBE_SURFACE_TAP_COAST_BIAS_DEGREES, + }); +} + /** World-view tap: land hit → app's continent cluster for that country's region. */ export function findClusterAtWorldCoordinate( polygons: CountryBoundaryPolygon[], diff --git a/lib/map-mode-transition.ts b/lib/map-mode-transition.ts new file mode 100644 index 0000000..7886b98 --- /dev/null +++ b/lib/map-mode-transition.ts @@ -0,0 +1,66 @@ +import type { MapMode } from "@/store/use-map-store"; +import type { MapPresentationMode } from "@/types/map-presentation"; + +export type MapModeTogglePending = { + pendingGlobeFocusName: string | null; + pendingGlobeRegionFocus: string | null; + pendingFlatFocusName: string | null; + pendingFlatPresentationMode: MapPresentationMode | null; +}; + +/** Pending focus refs to apply after a 2D ↔ 3D crossfade completes. */ +export function resolveMapModeTogglePending(input: { + currentMode: MapMode; + activeCountryName: string | null; + focusTransitionCountryName: string | null; + focusedRegion: string | null; + presentationMode: MapPresentationMode; +}): MapModeTogglePending { + if (input.currentMode === "3d") { + const focusName = + input.activeCountryName ?? input.focusTransitionCountryName ?? null; + return { + pendingFlatFocusName: focusName, + pendingFlatPresentationMode: input.presentationMode, + pendingGlobeFocusName: null, + pendingGlobeRegionFocus: null, + }; + } + + const countryFocus = + input.activeCountryName ?? input.focusTransitionCountryName ?? null; + + return { + pendingGlobeFocusName: countryFocus, + pendingGlobeRegionFocus: + countryFocus || !input.focusedRegion ? null : input.focusedRegion, + pendingFlatFocusName: null, + pendingFlatPresentationMode: null, + }; +} + +export type FlatTransitionRestore = { + focusName: string | null; + restorePreview: boolean; + framing: "continent" | "country"; +}; + +/** How to restore camera framing after 3D → 2D crossfade. */ +export function resolveFlatTransitionRestore(input: { + pendingFocusName: string | null; + pendingPresentationMode: MapPresentationMode | null; + activeCountryName: string | null; +}): FlatTransitionRestore | null { + if (!input.pendingFocusName) { + return null; + } + + const wasActive = input.activeCountryName === input.pendingFocusName; + const restorePreview = input.pendingPresentationMode === "preview"; + + return { + focusName: input.pendingFocusName, + restorePreview, + framing: wasActive || restorePreview ? "country" : "continent", + }; +} diff --git a/lib/map-presentation-transition.ts b/lib/map-presentation-transition.ts index 99df8d3..ae7cd16 100644 --- a/lib/map-presentation-transition.ts +++ b/lib/map-presentation-transition.ts @@ -1,15 +1,33 @@ -import { syncMapRegionFocusForCountry } from "@/lib/map-region-focus"; import { selectCountryOnMap } from "@/lib/map-country-selection"; -import { useMapPresentationStore } from "@/store/use-map-presentation-store"; +import { + isExplicitCountryFocusSource, + shouldSyncFocusedRegionForSelectionSource, + syncMapRegionFocusForCountry, +} from "@/lib/map-region-focus"; import type { SelectionSource } from "@/store/use-identity-store"; +import { useMapPresentationStore } from "@/store/use-map-presentation-store"; +import { useMapUiStore } from "@/store/use-map-ui-store"; import type { MapCountry } from "@/types/country"; import type { MapPresentationMode } from "@/types/map-presentation"; -type CountryPresentationMode = Extract; +type CountryPresentationMode = Extract< + MapPresentationMode, + "focus" | "preview" +>; /** Region + UI prep before camera flight — identity commits later via `commitMapPresentation`. */ -export function stageMapPresentationForFlight(country: MapCountry): void { - syncMapRegionFocusForCountry(country); +export function stageMapPresentationForFlight( + country: MapCountry, + source: Exclude, +): void { + const focusedRegion = useMapUiStore.getState().focusedRegion; + if ( + shouldSyncFocusedRegionForSelectionSource(country, focusedRegion, source) + ) { + syncMapRegionFocusForCountry(country, { + explicitFocus: isExplicitCountryFocusSource(source), + }); + } } /** After camera acknowledges the flight — commit identity and presentation mode. */ @@ -24,7 +42,14 @@ export function commitMapPresentation({ }): void { useMapPresentationStore.getState().setMode(mode); selectCountryOnMap(country, source); - syncMapRegionFocusForCountry(country); + const focusedRegion = useMapUiStore.getState().focusedRegion; + if ( + shouldSyncFocusedRegionForSelectionSource(country, focusedRegion, source) + ) { + syncMapRegionFocusForCountry(country, { + explicitFocus: isExplicitCountryFocusSource(source), + }); + } } /** Immediate transition (no camera deferral) — e.g. preview on already-focused country. */ diff --git a/lib/map-presentation.ts b/lib/map-presentation.ts index 48248ec..8132000 100644 --- a/lib/map-presentation.ts +++ b/lib/map-presentation.ts @@ -22,3 +22,11 @@ export function showRegionChrome( ): boolean { return !!focusedRegion && !activeCountry && mode !== "preview"; } + +/** Presentation chrome — country focus pill (not preview). */ +export function showCountryFocusPill( + mode: MapPresentationMode, + activeCountry: MapCountry | null, +): boolean { + return !!activeCountry && mode !== "preview"; +} diff --git a/lib/map-random-fab.ts b/lib/map-random-fab.ts new file mode 100644 index 0000000..3a9d040 --- /dev/null +++ b/lib/map-random-fab.ts @@ -0,0 +1,24 @@ +/** Minimum gap between accepted random-FAB taps (see map screen FAB handler). */ +export const RANDOM_FAB_TAP_COOLDOWN_MS = 650; + +export function shouldAcceptRandomFabTap(input: { + isMapAnimating: boolean; + nowMs: number; + lastTapAtMs: number; + cooldownMs?: number; +}): boolean { + if (input.isMapAnimating) { + return false; + } + + const cooldown = input.cooldownMs ?? RANDOM_FAB_TAP_COOLDOWN_MS; + return input.nowMs - input.lastTapAtMs >= cooldown; +} + +/** Ignore stale async pool resolutions when taps overlap. */ +export function isRandomPickGenerationCurrent( + generation: number, + currentGeneration: number, +): boolean { + return generation === currentGeneration; +} diff --git a/lib/map-random-pick.ts b/lib/map-random-pick.ts index 283404b..3af1c1b 100644 --- a/lib/map-random-pick.ts +++ b/lib/map-random-pick.ts @@ -1,14 +1,13 @@ import { resolveFlatZoomTier } from "@/lib/map-camera-zoom"; +import { getMapDisplayLatLng, isValidLatLng } from "@/lib/map-country"; +import { useCountryFeedStore } from "@/store/use-country-feed-store"; import { filterMapCountriesByChip, type MapFilterChip, type MapMode, } from "@/store/use-map-store"; -import { useCountryFeedStore } from "@/store/use-country-feed-store"; import type { FeaturedShortcut } from "@/store/use-map-ui-store"; -import { useRecentlyViewedStore } from "@/store/use-recently-viewed-store"; import { useSavedCountriesStore } from "@/store/use-saved-countries-store"; -import { getMapDisplayLatLng, isValidLatLng } from "@/lib/map-country"; import type { MapCountry } from "@/types/country"; type BuildMapRandomPoolOptions = { @@ -33,12 +32,7 @@ export async function buildMapRandomPool({ if (useWorldPool) { if (featuredShortcut === "all") { - useRecentlyViewedStore.getState().seedIfEmpty(); - const names = useRecentlyViewedStore - .getState() - .entries.slice(0, 3) - .map((e) => e.country.name); - pool = countries.filter((c) => names.includes(c.name)); + pool = countries; } else if (featuredShortcut === "terrain") { const feed = useCountryFeedStore.getState(); if (feed.countries.length === 0 && feed.status === "idle") { @@ -65,8 +59,8 @@ export async function buildMapRandomPool({ // Only keep countries we can actually frame on the map. Picking one with an // invalid coordinate would feed NaN to the native MapView and crash the app. - const filtered = filterMapCountriesByChip(pool, activeChip).filter((country) => - isValidLatLng(getMapDisplayLatLng(country)), + const filtered = filterMapCountriesByChip(pool, activeChip).filter( + (country) => isValidLatLng(getMapDisplayLatLng(country)), ); if (filtered.length > 0) return filtered; @@ -92,9 +86,7 @@ export function resolveMapRandomUseWorldPool({ contextualOnly?: boolean; }): boolean { if (contextualOnly || focusedRegion) return false; - return ( - mapMode === "3d" || resolveFlatZoomTier(flatLatitudeDelta) === "world" - ); + return mapMode === "3d" || resolveFlatZoomTier(flatLatitudeDelta) === "world"; } /** Picks a random country, optionally avoiding `excludeName` when the pool allows. */ diff --git a/lib/map-region-focus.test.ts b/lib/map-region-focus.test.ts new file mode 100644 index 0000000..2ffe541 --- /dev/null +++ b/lib/map-region-focus.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; + +import { + isExplicitCountryFocusSource, + shouldSyncFocusedRegionForCountry, + shouldSyncFocusedRegionForSelectionSource, +} from "@/lib/map-region-focus"; + +const brazil = { region: "Americas" }; +const france = { region: "Europe" }; +const unitedStates = { region: "North America" }; + +describe("map region focus", () => { + it("adopts country continent when no exploration intent exists", () => { + expect(shouldSyncFocusedRegionForCountry(brazil, null)).toBe(true); + }); + + it("stays aligned when country matches focused exploration", () => { + expect(shouldSyncFocusedRegionForCountry(france, "Europe")).toBe(true); + }); + + it("does not overwrite focused exploration on incidental cross-region selection", () => { + expect(shouldSyncFocusedRegionForCountry(brazil, "Europe")).toBe(false); + expect( + shouldSyncFocusedRegionForCountry(brazil, "Europe", { + explicitFocus: false, + }), + ).toBe(false); + }); + + it("allows cross-region overwrite on explicit navigation", () => { + expect( + shouldSyncFocusedRegionForCountry(brazil, "Europe", { + explicitFocus: true, + }), + ).toBe(true); + }); + + it("maps selection sources to explicit vs incidental sync", () => { + expect( + shouldSyncFocusedRegionForSelectionSource(brazil, "Europe", "mapTap"), + ).toBe(true); + expect( + shouldSyncFocusedRegionForSelectionSource(brazil, "Europe", "search"), + ).toBe(true); + expect( + shouldSyncFocusedRegionForSelectionSource(brazil, null, "mapTap"), + ).toBe(true); + expect(shouldSyncFocusedRegionForSelectionSource(brazil, null, "fab")).toBe( + false, + ); + }); + + it("retargets continent intent on cross-continent map tap", () => { + expect( + shouldSyncFocusedRegionForSelectionSource( + unitedStates, + "South America", + "mapTap", + ), + ).toBe(true); + expect( + shouldSyncFocusedRegionForSelectionSource(france, "Europe", "mapTap"), + ).toBe(true); + }); + + it("flags external entry sources as explicit focus", () => { + expect(isExplicitCountryFocusSource("search")).toBe(true); + expect(isExplicitCountryFocusSource("explore")).toBe(true); + expect(isExplicitCountryFocusSource("shuffle")).toBe(true); + expect(isExplicitCountryFocusSource("mapTap")).toBe(false); + expect(isExplicitCountryFocusSource("fab")).toBe(false); + }); +}); diff --git a/lib/map-region-focus.ts b/lib/map-region-focus.ts index 0663c11..cacb760 100644 --- a/lib/map-region-focus.ts +++ b/lib/map-region-focus.ts @@ -1,12 +1,80 @@ import { normalizeCountryRegion } from "@/lib/app-region"; +import type { SelectionSource } from "@/store/use-identity-store"; import { useMapUiStore } from "@/store/use-map-ui-store"; import type { MapCountry } from "@/types/country"; -/** Keep continent focus aligned with the active country. */ +export type SyncRegionFocusOptions = { + /** + * User explicitly navigated to this country as the focus target (search, + * explore handoff, shuffle, etc.). When false, an active `focusedRegion` in + * another continent is preserved. + */ + explicitFocus?: boolean; +}; + +/** + * Rule 1 — selection defines geography (`country.region` is always truth). + * Rule 2 — `focusedRegion` is UI exploration intent (explicit nav only). + * Rule 3 — geography does not overwrite intent unless allowed here. + */ +export function shouldSyncFocusedRegionForCountry( + country: Pick, + focusedRegion: string | null, + options?: SyncRegionFocusOptions, +): boolean { + const countryRegion = normalizeCountryRegion(country).region; + + if (!focusedRegion) { + return true; + } + + if (focusedRegion === countryRegion) { + return true; + } + + return options?.explicitFocus === true; +} + +/** Sources that always retarget continent focus when selecting a country. */ +export function isExplicitCountryFocusSource( + source: Exclude, +): boolean { + return source === "search" || source === "explore" || source === "shuffle"; +} + +export function shouldSyncFocusedRegionForSelectionSource( + country: Pick, + focusedRegion: string | null, + source: Exclude, +): boolean { + if (source === "fab") { + return false; + } + + const countryRegion = normalizeCountryRegion(country).region; + const crossContinentMapTap = + source === "mapTap" && !!focusedRegion && focusedRegion !== countryRegion; + + return shouldSyncFocusedRegionForCountry(country, focusedRegion, { + explicitFocus: crossContinentMapTap || isExplicitCountryFocusSource(source), + }); +} + +/** + * Align continent focus with a country when rules allow. + * Returns whether `focusedRegion` was updated. + */ export function syncMapRegionFocusForCountry( country: Pick, -): void { + options?: SyncRegionFocusOptions, +): boolean { + const focusedRegion = useMapUiStore.getState().focusedRegion; + if (!shouldSyncFocusedRegionForCountry(country, focusedRegion, options)) { + return false; + } + useMapUiStore .getState() .setFocusedRegion(normalizeCountryRegion(country).region); + return true; } diff --git a/lib/map-region-markers.test.ts b/lib/map-region-markers.test.ts new file mode 100644 index 0000000..6f4d95a --- /dev/null +++ b/lib/map-region-markers.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { + GLOBE_DETAIL_CAMERA_DISTANCE, + GLOBE_REGION_CAMERA_DISTANCE, + GLOBE_WORLD_CAMERA_DISTANCE, + resolveGlobeCountryTargetDistance, +} from "@/lib/map-region-markers"; + +describe("resolveGlobeCountryTargetDistance", () => { + it("uses detail distance for preview", () => { + expect(resolveGlobeCountryTargetDistance("preview", "mapTap", 4)).toBe( + GLOBE_DETAIL_CAMERA_DISTANCE, + ); + }); + + it("uses world distance for fab and explore", () => { + expect(resolveGlobeCountryTargetDistance("focus", "fab", 2)).toBe( + GLOBE_WORLD_CAMERA_DISTANCE, + ); + expect(resolveGlobeCountryTargetDistance("focus", "explore", 2)).toBe( + GLOBE_WORLD_CAMERA_DISTANCE, + ); + }); + + it("preserves pinch zoom on mapTap when already closer than region framing", () => { + expect( + resolveGlobeCountryTargetDistance("focus", "mapTap", 1.8), + ).toBeUndefined(); + }); + + it("frames region on mapTap when zoomed out past region distance", () => { + expect(resolveGlobeCountryTargetDistance("focus", "mapTap", 3.5)).toBe( + GLOBE_REGION_CAMERA_DISTANCE, + ); + }); +}); diff --git a/lib/map-region-markers.ts b/lib/map-region-markers.ts index 9bc1d58..e056b27 100644 --- a/lib/map-region-markers.ts +++ b/lib/map-region-markers.ts @@ -1,4 +1,5 @@ import { getMapDisplayLatLng } from "@/lib/map-country"; +import type { SelectionSource } from "@/store/use-identity-store"; import type { MapCountry } from "@/types/country"; /** Flag pins shown at continent/region zoom — zoom in to reveal the rest. */ @@ -18,6 +19,32 @@ export const GLOBE_REGION_CAMERA_DISTANCE = 2.75; /** Above this distance the globe is in world view (no continent selected). */ export const GLOBE_WORLD_ZOOM_DISTANCE = 3.45; +/** Default globe camera distance — world view framing (see globe-view). */ +export const GLOBE_WORLD_CAMERA_DISTANCE = 3.88; + +export type GlobeCountryFlightMode = "focus" | "preview"; + +/** + * Target camera distance for a country flight on the 3D globe. + * Returns `undefined` when the flight should keep the current distance (pinch zoom). + */ +export function resolveGlobeCountryTargetDistance( + mode: GlobeCountryFlightMode, + source: Exclude, + currentDistance: number, +): number | undefined { + if (mode === "preview") { + return GLOBE_DETAIL_CAMERA_DISTANCE; + } + if (source === "explore" || source === "fab") { + return GLOBE_WORLD_CAMERA_DISTANCE; + } + if (source === "mapTap" && currentDistance < GLOBE_REGION_CAMERA_DISTANCE) { + return undefined; + } + return GLOBE_REGION_CAMERA_DISTANCE; +} + /** Opacity for sibling flags when one country stays softly highlighted at continent zoom. */ export const MARKER_DEEMPHASIZED_OPACITY = 0.34; diff --git a/lib/map-region-settle.ts b/lib/map-region-settle.ts new file mode 100644 index 0000000..b899749 --- /dev/null +++ b/lib/map-region-settle.ts @@ -0,0 +1,178 @@ +import { resolveFlatZoomTier } from "@/lib/map-camera-zoom"; +import { resolveGlobeZoomTier } from "@/lib/map-region-markers"; + +export const REGION_SWITCH_HYSTERESIS_MS = 200; +export const EXPLICIT_REGION_RELEASE_DISTANCE_DEGREES = 22; + +export type ExplicitRegionLock = { + region: string; + anchor: [number, number]; +}; + +export type RegionSettleDecision = + | { kind: "skip"; reason: "disabled" | "animating" } + | { + kind: "world_tier"; + clearPending: true; + resetWorld: boolean; + clearSuppressWorldReset: boolean; + } + | { + kind: "explore_tier"; + clearSuppressWorldReset: true; + setExploreMode: true; + nearestRegion: string; + releaseExplicitLock: boolean; + holdExplicitLock: boolean; + clearPending: boolean; + scheduleRegionSwitch: string | null; + keepPendingCandidate: boolean; + } + | { + kind: "explore_tier"; + clearSuppressWorldReset: true; + setExploreMode: true; + nearestRegion: null; + releaseExplicitLock: false; + holdExplicitLock: false; + clearPending: true; + scheduleRegionSwitch: null; + keepPendingCandidate: false; + }; + +/** Pure decision for map region settle after the camera stops moving. */ +export function resolveRegionSettleDecision(input: { + latitudeDelta: number; + globeDistance?: number; + /** When true, tier comes from globe distance instead of flat latitudeDelta. */ + useGlobeDistance?: boolean; + mapCenter: { latitude: number; longitude: number }; + /** False when this viewport is not driving region settle (e.g. flat map under 3D). */ + settleEnabled?: boolean; + isMapAnimating: boolean; + suppressWorldReset: boolean; + explicitLock: ExplicitRegionLock | null; + currentFocusedRegion: string | null; + nearestRegion: string | null; + pendingCandidate: string | null; +}): RegionSettleDecision { + if (input.settleEnabled === false) { + return { kind: "skip", reason: "disabled" }; + } + if (input.isMapAnimating) { + return { kind: "skip", reason: "animating" }; + } + + const nextTier = input.useGlobeDistance + ? resolveGlobeZoomTier(input.globeDistance ?? Number.POSITIVE_INFINITY) + : resolveFlatZoomTier(input.latitudeDelta); + let suppressWorldReset = input.suppressWorldReset; + + if (suppressWorldReset && nextTier !== "world") { + suppressWorldReset = false; + } + + if (nextTier === "world") { + return { + kind: "world_tier", + clearPending: true, + resetWorld: !suppressWorldReset, + clearSuppressWorldReset: suppressWorldReset !== input.suppressWorldReset, + }; + } + + const nearestRegion = input.nearestRegion; + if (!nearestRegion) { + return { + kind: "explore_tier", + clearSuppressWorldReset: true, + setExploreMode: true, + nearestRegion: null, + releaseExplicitLock: false, + holdExplicitLock: false, + clearPending: true, + scheduleRegionSwitch: null, + keepPendingCandidate: false, + }; + } + + const explicitLock = input.explicitLock; + if (explicitLock && nearestRegion !== explicitLock.region) { + const dLat = input.mapCenter.latitude - explicitLock.anchor[0]; + const dLng = input.mapCenter.longitude - explicitLock.anchor[1]; + const distance = Math.sqrt(dLat * dLat + dLng * dLng); + if (distance < EXPLICIT_REGION_RELEASE_DISTANCE_DEGREES) { + return { + kind: "explore_tier", + clearSuppressWorldReset: true, + setExploreMode: true, + nearestRegion, + releaseExplicitLock: false, + holdExplicitLock: true, + clearPending: true, + scheduleRegionSwitch: null, + keepPendingCandidate: false, + }; + } + } + + const releaseExplicitLock = + !!explicitLock && nearestRegion !== explicitLock.region; + + if (nearestRegion === input.currentFocusedRegion) { + return { + kind: "explore_tier", + clearSuppressWorldReset: true, + setExploreMode: true, + nearestRegion, + releaseExplicitLock, + holdExplicitLock: false, + clearPending: true, + scheduleRegionSwitch: null, + keepPendingCandidate: false, + }; + } + + if (input.pendingCandidate === nearestRegion) { + return { + kind: "explore_tier", + clearSuppressWorldReset: true, + setExploreMode: true, + nearestRegion, + releaseExplicitLock, + holdExplicitLock: false, + clearPending: false, + scheduleRegionSwitch: null, + keepPendingCandidate: true, + }; + } + + return { + kind: "explore_tier", + clearSuppressWorldReset: true, + setExploreMode: true, + nearestRegion, + releaseExplicitLock, + holdExplicitLock: false, + clearPending: true, + scheduleRegionSwitch: nearestRegion, + keepPendingCandidate: false, + }; +} + +/** Whether a hysteresis timer should commit the pending region switch. */ +export function shouldCommitScheduledRegionSwitch(input: { + latitudeDelta?: number; + globeDistance?: number; + useGlobeDistance?: boolean; + pendingCandidate: string | null; + expectedRegion: string; +}): boolean { + const tier = input.useGlobeDistance + ? resolveGlobeZoomTier(input.globeDistance ?? Number.POSITIVE_INFINITY) + : resolveFlatZoomTier(input.latitudeDelta ?? Number.POSITIVE_INFINITY); + if (tier === "world") { + return false; + } + return input.pendingCandidate === input.expectedRegion; +} diff --git a/lib/map-signal-sources.test.ts b/lib/map-signal-sources.test.ts new file mode 100644 index 0000000..3a2993b --- /dev/null +++ b/lib/map-signal-sources.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, it } from "vitest"; + +import type { MapCluster } from "@/lib/map-clusters"; +import { + areRegionBoundariesTappable, + canFocusContinentFromMapTap, + canRefocusContinentFromMapTap, + isExploreMapZoom, + resolveEffectiveBoundaryRegion, + resolveNearestRegionFromCenter, + shouldDelegateMapTapToCountrySelection, + shouldSelectCountryAcrossFocusedContinentFromMapTap, + shouldSelectCountryInFocusedContinentFromMapTap, + shouldShowFeaturedChipsInMapChrome, + shouldShowMapOnboarding, +} from "@/lib/map-signal-sources"; +import type { MapCountry } from "@/types/country"; + +const africaCluster: MapCluster = { + id: "africa", + region: "Africa", + center: [0, 20], + countryCount: 54, + activity: "quiet", +}; + +const europeCluster: MapCluster = { + id: "europe", + region: "Europe", + center: [50, 10], + countryCount: 44, + activity: "quiet", +}; + +const france: MapCountry = { + name: "France", + region: "Europe", + capital: "Paris", + population: 0, + flag: "", + latlng: [46, 2], + images: [], + funFact: "", +}; + +const spain: MapCountry = { + name: "Spain", + region: "Europe", + capital: "Madrid", + population: 0, + flag: "", + latlng: [40, -4], + images: [], + funFact: "", +}; + +const brazil: MapCountry = { + name: "Brazil", + region: "Americas", + capital: "Brasília", + population: 0, + flag: "", + latlng: [-10, -55], + images: [], + funFact: "", +}; + +describe("map signal sources", () => { + it("isExploreMapZoom uses intent + live camera", () => { + expect(isExploreMapZoom("Asia", "region")).toBe(true); + expect(isExploreMapZoom("Asia", "world")).toBe(false); + expect(isExploreMapZoom(null, "region")).toBe(false); + }); + + it("shouldDelegateMapTapToCountrySelection uses focus intent, not camera", () => { + expect( + shouldDelegateMapTapToCountrySelection({ + presentationMode: "focus", + activeCountry: spain, + focusedRegion: "Europe", + tappedCountry: france, + }), + ).toBe(true); + expect( + shouldDelegateMapTapToCountrySelection({ + presentationMode: "focus", + activeCountry: spain, + focusedRegion: null, + tappedCountry: france, + }), + ).toBe(true); + expect( + shouldDelegateMapTapToCountrySelection({ + presentationMode: "idle", + activeCountry: spain, + focusedRegion: "Europe", + tappedCountry: france, + }), + ).toBe(false); + expect( + shouldDelegateMapTapToCountrySelection({ + presentationMode: "focus", + activeCountry: null, + focusedRegion: "Europe", + tappedCountry: france, + }), + ).toBe(false); + expect( + shouldDelegateMapTapToCountrySelection({ + presentationMode: "focus", + activeCountry: spain, + focusedRegion: "Europe", + tappedCountry: brazil, + }), + ).toBe(false); + }); + + it("canFocusContinentFromMapTap only allows continent pick at world zoom", () => { + expect(canFocusContinentFromMapTap(true, "Asia", "region")).toBe(false); + expect(canFocusContinentFromMapTap(true, null, "world")).toBe(true); + expect(canFocusContinentFromMapTap(false, null, "world")).toBe(true); + expect(canFocusContinentFromMapTap(false, "Asia", "region")).toBe(false); + }); + + it("shouldSelectCountryAcrossFocusedContinentFromMapTap when continents differ", () => { + expect( + shouldSelectCountryAcrossFocusedContinentFromMapTap({ + focusedRegion: "Europe", + tappedCountry: brazil, + }), + ).toBe(true); + expect( + shouldSelectCountryAcrossFocusedContinentFromMapTap({ + focusedRegion: "Europe", + tappedCountry: france, + }), + ).toBe(false); + expect( + shouldSelectCountryAcrossFocusedContinentFromMapTap({ + focusedRegion: null, + tappedCountry: brazil, + }), + ).toBe(false); + }); + + it("shouldSelectCountryInFocusedContinentFromMapTap at explore or world zoom", () => { + expect( + shouldSelectCountryInFocusedContinentFromMapTap({ + focusedRegion: "Europe", + tappedCountry: france, + cameraTier: "region", + }), + ).toBe(true); + expect( + shouldSelectCountryInFocusedContinentFromMapTap({ + focusedRegion: "Europe", + tappedCountry: france, + cameraTier: "world", + }), + ).toBe(true); + expect( + shouldSelectCountryInFocusedContinentFromMapTap({ + focusedRegion: "Europe", + tappedCountry: brazil, + cameraTier: "world", + }), + ).toBe(false); + expect( + shouldSelectCountryInFocusedContinentFromMapTap({ + focusedRegion: null, + tappedCountry: france, + cameraTier: "world", + }), + ).toBe(false); + }); + + it("canRefocusContinentFromMapTap blocks same continent at world zoom", () => { + expect(canRefocusContinentFromMapTap("Europe", "Europe", "world")).toBe( + false, + ); + expect(canRefocusContinentFromMapTap("Europe", "Asia", "world")).toBe(true); + expect(canRefocusContinentFromMapTap("Europe", "Europe", "region")).toBe( + true, + ); + }); + + it("shouldShowFeaturedChipsInMapChrome follows live camera at continent zoom", () => { + expect(shouldShowFeaturedChipsInMapChrome(false, "region", "Asia")).toBe( + false, + ); + expect(shouldShowFeaturedChipsInMapChrome(false, "world", "Asia")).toBe( + true, + ); + expect(shouldShowFeaturedChipsInMapChrome(true, "region", "Asia")).toBe( + true, + ); + }); + + it("areRegionBoundariesTappable matches explore zoom", () => { + expect(areRegionBoundariesTappable("Europe", "region")).toBe(true); + expect(areRegionBoundariesTappable("Europe", "world")).toBe(false); + expect(areRegionBoundariesTappable(null, "region")).toBe(false); + }); + + it("areRegionBoundariesTappable allows world-zoom globe picks when continent is focused", () => { + expect( + areRegionBoundariesTappable("Africa", "world", { + allowWorldZoomGlobe: true, + }), + ).toBe(true); + expect( + areRegionBoundariesTappable("Africa", "world", { + allowWorldZoomGlobe: false, + }), + ).toBe(false); + }); + + it("resolveNearestRegionFromCenter picks closest cluster", () => { + expect( + resolveNearestRegionFromCenter([africaCluster, europeCluster], 5, 18), + ).toBe("Africa"); + expect( + resolveNearestRegionFromCenter([africaCluster, europeCluster], 48, 12), + ).toBe("Europe"); + }); + + it("resolveEffectiveBoundaryRegion uses committed then preview intent", () => { + expect( + resolveEffectiveBoundaryRegion({ + focusedRegion: "Europe", + previewRegion: "Africa", + }), + ).toBe("Europe"); + expect( + resolveEffectiveBoundaryRegion({ + focusedRegion: null, + previewRegion: "Africa", + }), + ).toBe("Africa"); + expect( + resolveEffectiveBoundaryRegion({ + focusedRegion: null, + previewRegion: null, + }), + ).toBe(null); + }); + + it("shouldShowMapOnboarding requires world-scale camera", () => { + expect( + shouldShowMapOnboarding({ + is3d: false, + cameraTier: "world", + status: "ready", + countryCount: 10, + hasActiveCountry: false, + hasFocusTransition: false, + }), + ).toBe(true); + expect( + shouldShowMapOnboarding({ + is3d: false, + cameraTier: "region", + status: "ready", + countryCount: 10, + hasActiveCountry: false, + hasFocusTransition: false, + }), + ).toBe(false); + }); +}); diff --git a/lib/map-signal-sources.ts b/lib/map-signal-sources.ts new file mode 100644 index 0000000..b22d8b8 --- /dev/null +++ b/lib/map-signal-sources.ts @@ -0,0 +1,215 @@ +import type { Region } from "react-native-maps"; + +import { + regionForClusterFocus, + regionForMapCountry, + regionForWorldViewCountry, +} from "@/constants/map-regions"; +import type { CameraZoomTier } from "@/lib/map-camera-zoom"; +import type { MapCluster } from "@/lib/map-clusters"; +import type { MapCountry } from "@/types/country"; +import type { MapPresentationMode } from "@/types/map-presentation"; + +/** + * Map screen signal sources — use the right input for each decision: + * + * **Live camera** (`cameraTier`, `latitudeDelta`, `globeDistance`): + * zoom tier, marker density, UI scaling, explore-vs-world behavior. + * + * **`focusedRegion`** + **`presentationMode`** / active country: + * tap routing, what is selected, continent navigation targets. + * + * **`presentationMode`** (+ active country): + * UI chrome — preview sheet, focus pill, overlays, tab bar. + * + * **Region snapshot** (`MapRegionSnapshot`, flight phases only): + * animation setup, camera interpolation, flight destinations — never for + * density, chrome, or tap routing while the camera is still moving. + */ + +/** Camera target for flat-map flights — not a substitute for live zoom tier. */ +export type MapRegionSnapshot = Region; + +/** Live-camera: continent explore mode (focused + not at world zoom). */ +export function isExploreMapZoom( + focusedRegion: string | null, + cameraTier: CameraZoomTier, +): boolean { + return !!focusedRegion && cameraTier !== "world"; +} + +/** + * Map tap on a country inside the focused continent selects that country + * (explore zoom, or world camera while continent intent is still active). + */ +export function shouldSelectCountryInFocusedContinentFromMapTap(input: { + focusedRegion: string | null; + tappedCountry: MapCountry; + cameraTier: CameraZoomTier; +}): boolean { + if ( + !input.focusedRegion || + input.tappedCountry.region !== input.focusedRegion + ) { + return false; + } + return ( + isExploreMapZoom(input.focusedRegion, input.cameraTier) || + input.cameraTier === "world" + ); +} + +/** + * Resolved country polygon while another continent is focused — select the + * country (fly + update intent), not continent-only navigation. + */ +export function shouldSelectCountryAcrossFocusedContinentFromMapTap(input: { + focusedRegion: string | null; + tappedCountry: MapCountry; +}): boolean { + return ( + !!input.focusedRegion && input.tappedCountry.region !== input.focusedRegion + ); +} + +/** + * Intent-first: while a country is in focus, same-continent map taps select + * the tapped country (even at world camera tier — e.g. Explore handoff). + */ +export function shouldDelegateMapTapToCountrySelection(input: { + presentationMode: MapPresentationMode; + activeCountry: MapCountry | null; + focusedRegion: string | null; + tappedCountry: MapCountry; +}): boolean { + if (input.presentationMode !== "focus" || !input.activeCountry) { + return false; + } + const focusedContinent = input.focusedRegion ?? input.activeCountry.region; + return input.tappedCountry.region === focusedContinent; +} + +/** Intent + live camera: map tap may start continent focus only at world zoom. */ +export function canFocusContinentFromMapTap( + _is3d: boolean, + _focusedRegion: string | null, + cameraTier: CameraZoomTier, +): boolean { + return cameraTier === "world"; +} + +/** Block continent re-focus when the same continent is already active at world zoom. */ +export function canRefocusContinentFromMapTap( + focusedRegion: string | null, + tappedContinent: string | null, + cameraTier: CameraZoomTier, +): boolean { + if ( + focusedRegion && + tappedContinent === focusedRegion && + cameraTier === "world" + ) { + return false; + } + return true; +} + +/** Live camera: show world onboarding (world zoom, no selection). */ +export function shouldShowMapOnboarding(input: { + is3d: boolean; + cameraTier: CameraZoomTier; + status: string; + countryCount: number; + hasActiveCountry: boolean; + hasFocusTransition: boolean; +}): boolean { + return ( + (input.is3d || input.cameraTier === "world") && + input.status !== "loading" && + input.countryCount > 0 && + !input.hasActiveCountry && + !input.hasFocusTransition + ); +} + +/** Live camera: featured chips vs region filter chips in top chrome. */ +export function shouldShowFeaturedChipsInMapChrome( + is3d: boolean, + cameraTier: CameraZoomTier, + focusedRegion: string | null, +): boolean { + return is3d || cameraTier === "world" || !focusedRegion; +} + +/** Intent + live camera: country boundaries accept taps in explore zoom. */ +export function areRegionBoundariesTappable( + boundaryFocusRegion: string | null, + cameraTier: CameraZoomTier, + options?: { allowWorldZoomGlobe?: boolean }, +): boolean { + if (!boundaryFocusRegion) return false; + if (cameraTier !== "world") return true; + return options?.allowWorldZoomGlobe === true; +} + +/** Nearest app region label for a map center (continent cluster). */ +export function resolveNearestRegionFromCenter( + clusters: MapCluster[], + latitude: number, + longitude: number, +): string | null { + if ( + clusters.length === 0 || + !Number.isFinite(latitude) || + !Number.isFinite(longitude) + ) { + return null; + } + + let nearest: MapCluster | null = null; + let best = Number.POSITIVE_INFINITY; + + for (const cluster of clusters) { + const dLat = cluster.center[0] - latitude; + const dLng = cluster.center[1] - longitude; + const d = dLat * dLat + dLng * dLng; + if (d < best) { + best = d; + nearest = cluster; + } + } + + return nearest?.region ?? null; +} + +/** + * Region used for boundary outlines + tap targets. + * Matches 2D: committed continent intent, then world-preview intent — never inferred. + */ +export function resolveEffectiveBoundaryRegion(input: { + focusedRegion: string | null; + previewRegion: string | null; +}): string | null { + return input.focusedRegion ?? input.previewRegion; +} + +// --- Region snapshots (flights / interpolation only) --- + +export function flightRegionForClusterFocus( + cluster: MapCluster, +): MapRegionSnapshot { + return regionForClusterFocus(cluster); +} + +export function flightRegionForCountry( + country: MapCountry, + latitudeDelta?: number, +): MapRegionSnapshot { + return regionForMapCountry(country, latitudeDelta); +} + +export function flightRegionForWorldViewCountry( + country: MapCountry, +): MapRegionSnapshot { + return regionForWorldViewCountry(country); +} diff --git a/lib/map-view-transition.ts b/lib/map-view-transition.ts index b5f9071..908638d 100644 --- a/lib/map-view-transition.ts +++ b/lib/map-view-transition.ts @@ -8,6 +8,24 @@ export type MapViewTransition = export const GLOBE_CROSSFADE_MS = 300; export const MAP_DIM_HOLD_MS = 200; +/** Initial/restored transition — no crossfade when rehydrating a persisted mode. */ +export function resolveStableMapViewTransition( + mapMode: "2d" | "3d", +): MapViewTransition { + return mapMode === "3d" ? "ready" : "idle"; +} + +/** Align transition with persisted map mode without interrupting an active crossfade. */ +export function syncMapViewTransitionForMode( + mapMode: "2d" | "3d", + current: MapViewTransition, +): MapViewTransition { + if (current === "enteringGlobe" || current === "enteringFlat") { + return current; + } + return resolveStableMapViewTransition(mapMode); +} + export function shouldShowFlatMapLayer( mapMode: "2d" | "3d", transition: MapViewTransition, @@ -50,3 +68,8 @@ export function isFlatMapUi( export function shouldShowFlatMapMarkers(mapMode: "2d" | "3d"): boolean { return mapMode === "2d"; } + +/** Continent/country highlight fills — flat map only; globe renders its own GL layers. */ +export function shouldShowFlatMapOverlays(mapMode: "2d" | "3d"): boolean { + return mapMode === "2d"; +} diff --git a/lib/open-country-on-map.ts b/lib/open-country-on-map.ts index 9ef09c3..914fb8c 100644 --- a/lib/open-country-on-map.ts +++ b/lib/open-country-on-map.ts @@ -3,11 +3,11 @@ import { Image } from "expo-image"; import { buildFlagCdnUrl, resolveFlagCdnUrl } from "@/lib/flag-url"; import { cca2FromFlagUrl } from "@/lib/map-country"; import { syncMapRegionFocusForCountry } from "@/lib/map-region-focus"; +import type { SelectionSource } from "@/store/use-identity-store"; import { useMapStore } from "@/store/use-map-store"; import { useMapUiStore } from "@/store/use-map-ui-store"; import { useRecentlyViewedStore } from "@/store/use-recently-viewed-store"; import { useSearchUiStore } from "@/store/use-search-ui-store"; -import type { SelectionSource } from "@/store/use-identity-store"; import type { Country } from "@/types/country"; function prefetchCountryFlag(country: Country): void { @@ -30,15 +30,16 @@ function prepareMapForCountry( source: Exclude = "search", ): void { const mapUi = useMapUiStore.getState(); - const map = useMapStore.getState(); prefetchCountryFlag(country); - map.setMapMode("2d"); mapUi.setCountryMarkerMode("flag"); - // Explore discovery starts at world zoom — region sync happens during the camera flight. - if (source !== "explore") { - syncMapRegionFocusForCountry(country); + // Explore discovery starts at world zoom — region sync happens after the camera flight. + if (source === "explore") { + mapUi.setFocusedRegion(null); + mapUi.setDisplayMode("globalPulse"); + } else { + syncMapRegionFocusForCountry(country, { explicitFocus: true }); } useRecentlyViewedStore.getState().recordView(country); } diff --git a/lib/prefetch-country-details.ts b/lib/prefetch-country-details.ts new file mode 100644 index 0000000..308412e --- /dev/null +++ b/lib/prefetch-country-details.ts @@ -0,0 +1,54 @@ +import { CLIENT_CACHE_KEYS, CLIENT_CACHE_TTL } from "@/constants/client-cache"; +import { fetchCountryByName } from "@/lib/api"; +import { staleWhileRevalidate } from "@/lib/client-cache"; +import type { MapCountry } from "@/types/country"; + +/** Top map countries by population — likely preview taps after map hydrate. */ +const DEFAULT_PREFETCH_COUNT = 20; + +let prefetchGeneration = 0; + +function topCountriesByPopulation( + countries: MapCountry[], + max: number, +): MapCountry[] { + return [...countries] + .sort((a, b) => b.population - a.population) + .slice(0, max); +} + +/** + * Background-warm country detail (AI + images) into AsyncStorage after map load. + * Sequential, low priority — skips network when client cache is still fresh. + */ +export async function prefetchMapCountryDetails( + countries: MapCountry[], + options?: { max?: number }, +): Promise { + if (countries.length === 0) return; + + const generation = ++prefetchGeneration; + const max = options?.max ?? DEFAULT_PREFETCH_COUNT; + const targets = topCountriesByPopulation(countries, max); + + if (__DEV__) { + console.log("[prefetch-country-details]", { + count: targets.length, + names: targets.map((c) => c.name), + }); + } + + for (const country of targets) { + if (generation !== prefetchGeneration) return; + + try { + await staleWhileRevalidate({ + key: CLIENT_CACHE_KEYS.countryDetail(country.name), + ttlSeconds: CLIENT_CACHE_TTL.countryDetail, + fetcher: () => fetchCountryByName(country.name), + }); + } catch { + // Background prefetch — ignore per-country failures. + } + } +} diff --git a/lib/search-countries.ts b/lib/search-countries.ts new file mode 100644 index 0000000..b11ef7b --- /dev/null +++ b/lib/search-countries.ts @@ -0,0 +1,128 @@ +import { CLIENT_CACHE_KEYS, CLIENT_CACHE_TTL } from "@/constants/client-cache"; +import { fetchSearchCountries } from "@/lib/api"; +import { + filterCountriesForExploreRegion, + isSplitAmericasRegion, +} from "@/lib/app-region"; +import { getClientCache, staleWhileRevalidate } from "@/lib/client-cache"; +import { fetchExploreRegionCountries } from "@/lib/explore-region-countries"; +import type { Country } from "@/types/country"; + +function filterByQuery(countries: Country[], query: string): Country[] { + const q = query.trim().toLowerCase(); + if (!q) return countries; + return countries.filter((c) => c.name.toLowerCase().includes(q)); +} + +function sortByName(countries: Country[]): Country[] { + return [...countries].sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * Resolves search results with app continent ids (North/South America) + * and legacy `Americas` payloads — same rules as Explore region tabs. + */ +export async function fetchSearchCountriesResolved( + query?: string, + region?: string, +): Promise { + const q = query?.trim() ?? ""; + const r = region?.trim() ?? ""; + + if (!q && !r) { + throw new Error("At least one of query or region is required"); + } + + if (!q && r) { + return fetchExploreRegionCountries(r); + } + + if (q && !r) { + const { data } = await fetchSearchCountries(q); + return data; + } + + if (isSplitAmericasRegion(r)) { + const regionCountries = await fetchExploreRegionCountries(r); + return sortByName(filterByQuery(regionCountries, q)); + } + + const { data } = await fetchSearchCountries(q, r); + if (data.length > 0) return data; + + const regionCountries = await fetchExploreRegionCountries(r); + return sortByName(filterByQuery(regionCountries, q)); +} + +/** Disk cache for a search query — includes feed region cache for region-only filters. */ +export async function getCachedSearchResults( + query: string, + region: string, +): Promise { + return readHydratedSearchCache(query, region); +} + +async function readHydratedSearchCache( + query: string, + region: string, +): Promise { + const cacheKey = CLIENT_CACHE_KEYS.search(query, region); + const diskCache = await getClientCache(cacheKey); + if (diskCache.data && diskCache.data.length > 0) { + return diskCache.data; + } + + if (!query && region) { + const feedRegionKey = CLIENT_CACHE_KEYS.feedRegion(region); + const feedRegionDisk = await getClientCache(feedRegionKey); + if (feedRegionDisk.data) { + const filtered = filterCountriesForExploreRegion( + feedRegionDisk.data, + region, + ); + if (filtered.length > 0) return filtered; + } + } + + return null; +} + +export async function searchCountriesWithCache( + query: string, + region: string, + options?: { + force?: boolean; + onCached?: (countries: Country[]) => void; + onFetched?: (countries: Country[]) => void; + }, +): Promise { + const q = query.trim(); + const r = region.trim(); + const cacheKey = CLIENT_CACHE_KEYS.search(q, r); + const hydrated = options?.force ? null : await readHydratedSearchCache(q, r); + + if (hydrated) { + options?.onCached?.(hydrated); + } + + try { + return await staleWhileRevalidate({ + key: cacheKey, + ttlSeconds: CLIENT_CACHE_TTL.search, + force: options?.force, + fetcher: () => + fetchSearchCountriesResolved(q || undefined, r || undefined), + onCached: (data) => { + if (!hydrated) options?.onCached?.(data); + }, + onFetched: options?.onFetched, + }); + } catch (err) { + if (hydrated) return hydrated; + + const fallback = await readHydratedSearchCache(q, r); + if (fallback) return fallback; + + throw err; + } +} diff --git a/package-lock.json b/package-lock.json index 8b598c3..fea1513 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", "@react-three/fiber": "^9.6.1", + "earcut": "^3.0.2", "expo": "~54.0.35", "expo-asset": "~12.0.13", "expo-constants": "~18.0.13", @@ -52,7 +53,8 @@ "eslint-config-expo": "~10.0.0", "postcss": "^8.5.15", "tailwindcss": "^4.3.0", - "typescript": "~5.9.2" + "typescript": "~5.9.2", + "vitest": "^3.2.4" } }, "node_modules/@0no-co/graphql.web": { @@ -1626,644 +1628,668 @@ "tslib": "^2.4.0" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "node": ">=18" } }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=18" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">=18" } }, - "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.5" - }, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" } }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" } }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=18" } }, - "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" + "node": ">=18" } }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" } }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" } }, - "node_modules/@expo/code-signing-certificates": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", - "integrity": "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==", + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, "license": "MIT", - "dependencies": { - "node-forge": "^1.3.3" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@expo/config": { - "version": "56.0.9", - "resolved": "https://registry.npmjs.org/@expo/config/-/config-56.0.9.tgz", - "integrity": "sha512-/lqFeWGSrhpKJVP8tTN8LjuoIe8u8q2w7FzBL0C+wHgl+WM8l1qUIEYWy/sMvsG/NbpUIUsDHJRhQvOkU58eIw==", + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "@expo/config-plugins": "~56.0.8", - "@expo/config-types": "^56.0.5", - "@expo/json-file": "^10.2.0", - "@expo/require-utils": "^56.1.3", - "deepmerge": "^4.3.1", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "resolve-workspace-root": "^2.0.0", - "semver": "^7.6.0", - "slugify": "^1.3.4" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@expo/config-plugins": { - "version": "56.0.8", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-56.0.8.tgz", - "integrity": "sha512-phTuyBhgVLfqUHMjQkAfRtbyoY6yTxoKja1awtpVnEkoJDxPJuXx1KX5uvq1eZtt4bJQ08OBJ6P95INqRSHpRg==", + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "@expo/config-types": "^56.0.5", - "@expo/json-file": "~10.2.0", - "@expo/plist": "^0.7.0", - "@expo/require-utils": "^56.1.3", - "@expo/sdk-runtime-versions": "^1.0.0", - "chalk": "^4.1.2", - "debug": "^4.3.5", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "semver": "^7.5.4", - "slugify": "^1.6.6", - "xcode": "^3.0.1", - "xml2js": "0.6.0" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@expo/config-plugins/node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", - "license": "ISC", - "peer": true, - "bin": { - "semver": "bin/semver.js" - }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/@expo/config-types": { - "version": "56.0.5", - "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-56.0.5.tgz", - "integrity": "sha512-GsAHO/MwW9ZRdgnmyfRXqVGLCP/zejD6rWnp5OROp8mBGRObKm4HfrjlUyT1skjMwCj1OrURx9ZfIc6yeBAkIA==", + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, "license": "MIT", - "peer": true - }, - "node_modules/@expo/config/node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", - "license": "ISC", - "peer": true, - "bin": { - "semver": "bin/semver.js" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/@expo/devcert": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.1.tgz", - "integrity": "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==", + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@expo/sudo-prompt": "^9.3.1", - "debug": "^3.1.0" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@expo/devcert/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "ms": "^2.1.1" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@expo/devtools": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/@expo/devtools/-/devtools-0.1.8.tgz", - "integrity": "sha512-SVLxbuanDjJPgc0sy3EfXUMLb/tXzp6XIHkhtPVmTWJAp+FOr6+5SeiCfJrCzZFet0Ifyke2vX3sFcKwEvCXwQ==", + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "chalk": "^4.1.2" - }, - "peerDependencies": { - "react": "*", - "react-native": "*" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-native": { - "optional": true - } + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@expo/env": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.3.0.tgz", - "integrity": "sha512-9HnnIbzwTTdbwSjNLXTk0fPm9ZwMJ7c1/31tsni8HZ8Q62KzYCyspahH+V365vg5J6lr001DzNwBxVWSaYCQLg==", + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "chalk": "^4.0.0", - "debug": "^4.3.4", - "getenv": "^2.0.0" - }, + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=20.12.0" + "node": ">=18" } }, - "node_modules/@expo/fingerprint": { - "version": "0.15.5", - "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.15.5.tgz", - "integrity": "sha512-mdVoAMcux1WlM6kd1RoWiHRNqKqS+J6mKmWQ/BKgeh937S/fcW58EE68O6nc4KDXtWi3PBeNHskOFcgyIuD4hw==", + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@expo/spawn-async": "^1.7.2", - "arg": "^5.0.2", - "chalk": "^4.1.2", - "debug": "^4.3.4", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "ignore": "^5.3.1", - "minimatch": "^10.2.2", - "p-limit": "^3.1.0", - "resolve-from": "^5.0.0", - "semver": "^7.6.0" - }, - "bin": { - "fingerprint": "bin/cli.js" + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@expo/fingerprint/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": "18 || 20 || >=22" + "node": ">=18" } }, - "node_modules/@expo/fingerprint/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, + "optional": true, + "os": [ + "openharmony" + ], "engines": { - "node": "18 || 20 || >=22" + "node": ">=18" } }, - "node_modules/@expo/fingerprint/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=18" } }, - "node_modules/@expo/fingerprint/node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/@expo/image-utils": { - "version": "0.8.14", - "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.8.14.tgz", - "integrity": "sha512-5Sn+jG4Cw+shC2wDMXoqSAJnvERbiwzHn05FpWtD5IBflfTIs5gUmjzwiGVyjOdlMSQhgRrw/AymPbmO9h9mpQ==", + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@expo/require-utils": "^55.0.5", - "@expo/spawn-async": "^1.7.2", - "chalk": "^4.0.0", - "getenv": "^2.0.0", - "jimp-compact": "0.16.1", - "parse-png": "^2.1.0", - "semver": "^7.6.0" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@expo/image-utils/node_modules/@expo/require-utils": { - "version": "55.0.5", - "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-55.0.5.tgz", - "integrity": "sha512-U4K/CQ2VpXuwfNGsN+daKmYOt15hCP8v/pXaYH6eut7kdYZo6SfJ1yr67BIcJ+1Gzzs+QzTxswAZChKpXmceyw==", + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.20.0", - "@babel/core": "^7.25.2", - "@babel/plugin-transform-modules-commonjs": "^7.24.8" - }, - "peerDependencies": { - "typescript": "^5.0.0 || ^5.0.0-0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@expo/image-utils/node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" }, "engines": { - "node": ">=10" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@expo/json-file": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", - "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.20.0", - "json5": "^2.2.3" + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@expo/metro": { - "version": "56.0.0", - "resolved": "https://registry.npmjs.org/@expo/metro/-/metro-56.0.0.tgz", - "integrity": "sha512-5gIgQHtEpjjvsjKfVtIv23a98LLRV0/y07PDShEwYSytAMlE3FSF8RHXqtHc1sUJL6dn7hnuIBpIbrLXXuVi0A==", + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, "license": "MIT", - "peer": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "metro": "0.84.4", - "metro-babel-transformer": "0.84.4", - "metro-cache": "0.84.4", - "metro-cache-key": "0.84.4", - "metro-config": "0.84.4", - "metro-core": "0.84.4", - "metro-file-map": "0.84.4", - "metro-minify-terser": "0.84.4", - "metro-resolver": "0.84.4", - "metro-runtime": "0.84.4", - "metro-source-map": "0.84.4", - "metro-symbolicate": "0.84.4", - "metro-transform-plugins": "0.84.4", - "metro-transform-worker": "0.84.4" + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@expo/metro-config": { - "version": "56.0.13", - "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-56.0.13.tgz", - "integrity": "sha512-OPyNYiex/6Ms8zT2POdIZsLhcAZYk7O+yJvpz5uG/4QRA7aiESfCy1I+0YHewMlR4P1YQeyxIrfTurs6m9xfZA==", - "license": "MIT", - "peer": true, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@babel/code-frame": "^7.20.0", - "@babel/core": "^7.20.0", - "@babel/generator": "^7.20.5", - "@expo/config": "~56.0.9", - "@expo/env": "~2.3.0", - "@expo/json-file": "~10.2.0", - "@expo/metro": "~56.0.0", - "@expo/require-utils": "^56.1.3", - "@expo/spawn-async": "^1.8.0", - "@jridgewell/gen-mapping": "^0.3.13", - "@jridgewell/remapping": "^2.3.5", - "@jridgewell/sourcemap-codec": "^1.5.5", - "browserslist": "^4.25.0", - "chalk": "^4.1.0", - "debug": "^4.3.2", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "hermes-parser": "^0.33.3", - "jsc-safe-url": "^0.2.4", - "lightningcss": "^1.30.1", - "msgpackr": "^2.0.1", - "picomatch": "^4.0.4", - "postcss": "^8.5.14", - "resolve-from": "^5.0.0" + "@eslint/core": "^0.17.0" }, - "peerDependencies": { - "expo": "*" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" }, - "peerDependenciesMeta": { - "expo": { - "optional": true - } + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@expo/metro-runtime": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-6.1.2.tgz", - "integrity": "sha512-nvM+Qv45QH7pmYvP8JB1G8JpScrWND3KrMA6ZKe62cwwNiX/BjHU28Ear0v/4bQWXlOY0mv6B8CDIm8JxXde9g==", + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, "license": "MIT", "dependencies": { - "anser": "^1.4.9", - "pretty-format": "^29.7.0", - "stacktrace-parser": "^0.1.10", - "whatwg-fetch": "^3.0.0" + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" }, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-dom": "*", - "react-native": "*" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@expo/osascript": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.6.0.tgz", - "integrity": "sha512-QvqDBlJXa8CS2vRORJ4wEflY1m0vVI07uSJdIRgBrLxRPBcsrXxrtU7+wXRXMqfq9zLwNP9XbvRsXF2omoDylg==", + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, "license": "MIT", - "dependencies": { - "@expo/spawn-async": "^1.8.0" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=12" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@expo/package-manager": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.12.0.tgz", - "integrity": "sha512-SWr6093nwBjn94cvElsYZNUnhvs+XtUatUz3h0vAn0IbaWG0B6l/V5ZfOBptX/xq6rMpFG5ibIf/eckLSXw8Gg==", - "license": "MIT", + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@expo/json-file": "^10.2.0", - "@expo/spawn-async": "^1.8.0", - "chalk": "^4.0.0", - "npm-package-arg": "^11.0.0", - "ora": "^3.4.0", - "resolve-workspace-root": "^2.0.0" + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@expo/plist": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.7.0.tgz", - "integrity": "sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q==", + "node_modules/@expo/code-signing-certificates": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", + "integrity": "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==", "license": "MIT", - "peer": true, "dependencies": { - "@xmldom/xmldom": "^0.8.8", - "base64-js": "^1.5.1", - "xmlbuilder": "^15.1.1" + "node-forge": "^1.3.3" } }, - "node_modules/@expo/prebuild-config": { - "version": "54.0.8", - "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-54.0.8.tgz", - "integrity": "sha512-EA7N4dloty2t5Rde+HP0IEE+nkAQiu4A/+QGZGT9mFnZ5KKjPPkqSyYcRvP5bhQE10D+tvz6X0ngZpulbMdbsg==", + "node_modules/@expo/config": { + "version": "56.0.9", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-56.0.9.tgz", + "integrity": "sha512-/lqFeWGSrhpKJVP8tTN8LjuoIe8u8q2w7FzBL0C+wHgl+WM8l1qUIEYWy/sMvsG/NbpUIUsDHJRhQvOkU58eIw==", "license": "MIT", + "peer": true, "dependencies": { - "@expo/config": "~12.0.13", - "@expo/config-plugins": "~54.0.4", - "@expo/config-types": "^54.0.10", - "@expo/image-utils": "^0.8.8", - "@expo/json-file": "^10.0.8", - "@react-native/normalize-colors": "0.81.5", - "debug": "^4.3.1", - "resolve-from": "^5.0.0", - "semver": "^7.6.0", - "xml2js": "0.6.0" - }, - "peerDependencies": { - "expo": "*" - } - }, - "node_modules/@expo/prebuild-config/node_modules/@babel/code-frame": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", - "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", - "license": "MIT", - "dependencies": { - "@babel/highlight": "^7.10.4" - } - }, - "node_modules/@expo/prebuild-config/node_modules/@expo/config": { - "version": "12.0.13", - "resolved": "https://registry.npmjs.org/@expo/config/-/config-12.0.13.tgz", - "integrity": "sha512-Cu52arBa4vSaupIWsF0h7F/Cg//N374nYb7HAxV0I4KceKA7x2UXpYaHOL7EEYYvp7tZdThBjvGpVmr8ScIvaQ==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "~7.10.4", - "@expo/config-plugins": "~54.0.4", - "@expo/config-types": "^54.0.10", - "@expo/json-file": "^10.0.8", + "@expo/config-plugins": "~56.0.8", + "@expo/config-types": "^56.0.5", + "@expo/json-file": "^10.2.0", + "@expo/require-utils": "^56.1.3", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", - "require-from-string": "^2.0.2", - "resolve-from": "^5.0.0", "resolve-workspace-root": "^2.0.0", "semver": "^7.6.0", - "slugify": "^1.3.4", - "sucrase": "~3.35.1" + "slugify": "^1.3.4" } }, - "node_modules/@expo/prebuild-config/node_modules/@expo/config-plugins": { - "version": "54.0.4", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-54.0.4.tgz", - "integrity": "sha512-g2yXGICdoOw5i3LkQSDxl2Q5AlQCrG7oniu0pCPPO+UxGb7He4AFqSvPSy8HpRUj55io17hT62FTjYRD+d6j3Q==", + "node_modules/@expo/config-plugins": { + "version": "56.0.8", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-56.0.8.tgz", + "integrity": "sha512-phTuyBhgVLfqUHMjQkAfRtbyoY6yTxoKja1awtpVnEkoJDxPJuXx1KX5uvq1eZtt4bJQ08OBJ6P95INqRSHpRg==", "license": "MIT", + "peer": true, "dependencies": { - "@expo/config-types": "^54.0.10", - "@expo/json-file": "~10.0.8", - "@expo/plist": "^0.4.8", + "@expo/config-types": "^56.0.5", + "@expo/json-file": "~10.2.0", + "@expo/plist": "^0.7.0", + "@expo/require-utils": "^56.1.3", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", "glob": "^13.0.0", - "resolve-from": "^5.0.0", "semver": "^7.5.4", - "slash": "^3.0.0", "slugify": "^1.6.6", "xcode": "^3.0.1", "xml2js": "0.6.0" } }, - "node_modules/@expo/prebuild-config/node_modules/@expo/config-types": { - "version": "54.0.10", - "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-54.0.10.tgz", - "integrity": "sha512-/J16SC2an1LdtCZ67xhSkGXpALYUVUNyZws7v+PVsFZxClYehDSoKLqyRaGkpHlYrCc08bS0RF5E0JV6g50psA==", - "license": "MIT" - }, - "node_modules/@expo/prebuild-config/node_modules/@expo/json-file": { - "version": "10.0.16", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.16.tgz", - "integrity": "sha512-fcVkWEj+hLuP2yt5W0aw6LmDRqSPWDLUSxOMcmFeV+algmIF59sQVKCwB9btjQLd4V6x9N0pISkQEkBubUHrCw==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "~7.10.4", - "json5": "^2.2.3" + "node_modules/@expo/config-plugins/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, - "node_modules/@expo/prebuild-config/node_modules/@expo/plist": { - "version": "0.4.9", - "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.4.9.tgz", - "integrity": "sha512-MPVpmKGfnQEnrCzgxuXcmPP/y/t6AVm+DcSb2Myp21LKWv1N3l8uFxMggesfF4ixAxkRlGmMMx9GyDC9M+XklQ==", + "node_modules/@expo/config-types": { + "version": "56.0.5", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-56.0.5.tgz", + "integrity": "sha512-GsAHO/MwW9ZRdgnmyfRXqVGLCP/zejD6rWnp5OROp8mBGRObKm4HfrjlUyT1skjMwCj1OrURx9ZfIc6yeBAkIA==", "license": "MIT", - "dependencies": { - "@xmldom/xmldom": "^0.8.8", - "base64-js": "^1.2.3", - "xmlbuilder": "^15.1.1" - } + "peer": true }, - "node_modules/@expo/prebuild-config/node_modules/semver": { + "node_modules/@expo/config/node_modules/semver": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "license": "ISC", + "peer": true, "bin": { "semver": "bin/semver.js" }, @@ -2271,1445 +2297,2213 @@ "node": ">=10" } }, - "node_modules/@expo/require-utils": { - "version": "56.1.3", - "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.3.tgz", - "integrity": "sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==", + "node_modules/@expo/devcert": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.1.tgz", + "integrity": "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==", "license": "MIT", - "peer": true, "dependencies": { - "@babel/code-frame": "^7.20.0", - "@babel/core": "^7.25.2", - "@babel/plugin-transform-modules-commonjs": "^7.24.8" + "@expo/sudo-prompt": "^9.3.1", + "debug": "^3.1.0" + } + }, + "node_modules/@expo/devcert/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/@expo/devtools": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@expo/devtools/-/devtools-0.1.8.tgz", + "integrity": "sha512-SVLxbuanDjJPgc0sy3EfXUMLb/tXzp6XIHkhtPVmTWJAp+FOr6+5SeiCfJrCzZFet0Ifyke2vX3sFcKwEvCXwQ==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" }, "peerDependencies": { - "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0" + "react": "*", + "react-native": "*" }, "peerDependenciesMeta": { - "typescript": { + "react": { + "optional": true + }, + "react-native": { "optional": true } } }, - "node_modules/@expo/schema-utils": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-0.1.8.tgz", - "integrity": "sha512-9I6ZqvnAvKKDiO+ZF8BpQQFYWXOJvTAL5L/227RUbWG1OVZDInFifzCBiqAZ3b67NRfeAgpgvbA7rejsqhY62A==", - "license": "MIT" - }, - "node_modules/@expo/sdk-runtime-versions": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@expo/sdk-runtime-versions/-/sdk-runtime-versions-1.0.0.tgz", - "integrity": "sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==", - "license": "MIT" - }, - "node_modules/@expo/spawn-async": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.8.0.tgz", - "integrity": "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==", + "node_modules/@expo/env": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.3.0.tgz", + "integrity": "sha512-9HnnIbzwTTdbwSjNLXTk0fPm9ZwMJ7c1/31tsni8HZ8Q62KzYCyspahH+V365vg5J6lr001DzNwBxVWSaYCQLg==", "license": "MIT", + "peer": true, "dependencies": { - "cross-spawn": "^7.0.6" + "chalk": "^4.0.0", + "debug": "^4.3.4", + "getenv": "^2.0.0" }, "engines": { - "node": ">=12" + "node": ">=20.12.0" } }, - "node_modules/@expo/sudo-prompt": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/@expo/sudo-prompt/-/sudo-prompt-9.3.2.tgz", - "integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==", - "license": "MIT" - }, - "node_modules/@expo/vector-icons": { - "version": "15.1.1", - "resolved": "https://registry.npmjs.org/@expo/vector-icons/-/vector-icons-15.1.1.tgz", - "integrity": "sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw==", + "node_modules/@expo/fingerprint": { + "version": "0.15.5", + "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.15.5.tgz", + "integrity": "sha512-mdVoAMcux1WlM6kd1RoWiHRNqKqS+J6mKmWQ/BKgeh937S/fcW58EE68O6nc4KDXtWi3PBeNHskOFcgyIuD4hw==", "license": "MIT", - "peerDependencies": { - "expo-font": ">=14.0.4", - "react": "*", - "react-native": "*" - } - }, - "node_modules/@expo/ws-tunnel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-1.0.6.tgz", - "integrity": "sha512-nDRbLmSrJar7abvUjp3smDwH8HcbZcoOEa5jVPUv9/9CajgmWw20JNRwTuBRzWIWIkEJDkz20GoNA+tSwUqk0Q==", - "license": "MIT" - }, - "node_modules/@expo/xcpretty": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.4.tgz", - "integrity": "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==", - "license": "BSD-3-Clause", "dependencies": { - "@babel/code-frame": "^7.20.0", - "chalk": "^4.1.0", - "js-yaml": "^4.1.0" + "@expo/spawn-async": "^1.7.2", + "arg": "^5.0.2", + "chalk": "^4.1.2", + "debug": "^4.3.4", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "ignore": "^5.3.1", + "minimatch": "^10.2.2", + "p-limit": "^3.1.0", + "resolve-from": "^5.0.0", + "semver": "^7.6.0" }, "bin": { - "excpretty": "build/cli.js" + "fingerprint": "bin/cli.js" } }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", - "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/types": "^0.15.0" - }, + "node_modules/@expo/fingerprint/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", "engines": { - "node": ">=18.18.0" + "node": "18 || 20 || >=22" } }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", - "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", - "dev": true, - "license": "Apache-2.0", + "node_modules/@expo/fingerprint/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=18.18.0" + "node": "18 || 20 || >=22" } }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" + "node_modules/@expo/fingerprint/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", "engines": { - "node": ">=18.18" + "node": "18 || 20 || >=22" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "node_modules/@expo/fingerprint/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@isaacs/ttlcache": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", - "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==", - "license": "ISC", - "engines": { - "node": ">=12" + "node": ">=10" } }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", - "license": "ISC", + "node_modules/@expo/image-utils": { + "version": "0.8.14", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.8.14.tgz", + "integrity": "sha512-5Sn+jG4Cw+shC2wDMXoqSAJnvERbiwzHn05FpWtD5IBflfTIs5gUmjzwiGVyjOdlMSQhgRrw/AymPbmO9h9mpQ==", + "license": "MIT", "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" + "@expo/require-utils": "^55.0.5", + "@expo/spawn-async": "^1.7.2", + "chalk": "^4.0.0", + "getenv": "^2.0.0", + "jimp-compact": "0.16.1", + "parse-png": "^2.1.0", + "semver": "^7.6.0" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "node_modules/@expo/image-utils/node_modules/@expo/require-utils": { + "version": "55.0.5", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-55.0.5.tgz", + "integrity": "sha512-U4K/CQ2VpXuwfNGsN+daKmYOt15hCP8v/pXaYH6eut7kdYZo6SfJ1yr67BIcJ+1Gzzs+QzTxswAZChKpXmceyw==", "license": "MIT", "dependencies": { - "sprintf-js": "~1.0.2" + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^5.0.0-0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "license": "MIT", + "node_modules/@expo/image-utils/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, "engines": { - "node": ">=6" + "node": ">=10" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "node_modules/@expo/json-file": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", + "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", "license": "MIT", "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "node_modules/@expo/metro": { + "version": "56.0.0", + "resolved": "https://registry.npmjs.org/@expo/metro/-/metro-56.0.0.tgz", + "integrity": "sha512-5gIgQHtEpjjvsjKfVtIv23a98LLRV0/y07PDShEwYSytAMlE3FSF8RHXqtHc1sUJL6dn7hnuIBpIbrLXXuVi0A==", "license": "MIT", + "peer": true, "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "metro": "0.84.4", + "metro-babel-transformer": "0.84.4", + "metro-cache": "0.84.4", + "metro-cache-key": "0.84.4", + "metro-config": "0.84.4", + "metro-core": "0.84.4", + "metro-file-map": "0.84.4", + "metro-minify-terser": "0.84.4", + "metro-resolver": "0.84.4", + "metro-runtime": "0.84.4", + "metro-source-map": "0.84.4", + "metro-symbolicate": "0.84.4", + "metro-transform-plugins": "0.84.4", + "metro-transform-worker": "0.84.4" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "node_modules/@expo/metro-config": { + "version": "56.0.13", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-56.0.13.tgz", + "integrity": "sha512-OPyNYiex/6Ms8zT2POdIZsLhcAZYk7O+yJvpz5uG/4QRA7aiESfCy1I+0YHewMlR4P1YQeyxIrfTurs6m9xfZA==", "license": "MIT", + "peer": true, "dependencies": { - "p-locate": "^4.1.0" + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.20.0", + "@babel/generator": "^7.20.5", + "@expo/config": "~56.0.9", + "@expo/env": "~2.3.0", + "@expo/json-file": "~10.2.0", + "@expo/metro": "~56.0.0", + "@expo/require-utils": "^56.1.3", + "@expo/spawn-async": "^1.8.0", + "@jridgewell/gen-mapping": "^0.3.13", + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.5.5", + "browserslist": "^4.25.0", + "chalk": "^4.1.0", + "debug": "^4.3.2", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "hermes-parser": "^0.33.3", + "jsc-safe-url": "^0.2.4", + "lightningcss": "^1.30.1", + "msgpackr": "^2.0.1", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "resolve-from": "^5.0.0" }, - "engines": { - "node": ">=8" + "peerDependencies": { + "expo": "*" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "node_modules/@expo/metro-runtime": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-6.1.2.tgz", + "integrity": "sha512-nvM+Qv45QH7pmYvP8JB1G8JpScrWND3KrMA6ZKe62cwwNiX/BjHU28Ear0v/4bQWXlOY0mv6B8CDIm8JxXde9g==", "license": "MIT", "dependencies": { - "p-try": "^2.0.0" + "anser": "^1.4.9", + "pretty-format": "^29.7.0", + "stacktrace-parser": "^0.1.10", + "whatwg-fetch": "^3.0.0" }, - "engines": { - "node": ">=6" + "peerDependencies": { + "expo": "*", + "react": "*", + "react-dom": "*", + "react-native": "*" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "node_modules/@expo/osascript": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.6.0.tgz", + "integrity": "sha512-QvqDBlJXa8CS2vRORJ4wEflY1m0vVI07uSJdIRgBrLxRPBcsrXxrtU7+wXRXMqfq9zLwNP9XbvRsXF2omoDylg==", "license": "MIT", "dependencies": { - "p-limit": "^2.2.0" + "@expo/spawn-async": "^1.8.0" }, "engines": { - "node": ">=8" + "node": ">=12" } }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", - "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/create-cache-key-function": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-29.7.0.tgz", - "integrity": "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==", + "node_modules/@expo/package-manager": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.12.0.tgz", + "integrity": "sha512-SWr6093nwBjn94cvElsYZNUnhvs+XtUatUz3h0vAn0IbaWG0B6l/V5ZfOBptX/xq6rMpFG5ibIf/eckLSXw8Gg==", "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "@expo/json-file": "^10.2.0", + "@expo/spawn-async": "^1.8.0", + "chalk": "^4.0.0", + "npm-package-arg": "^11.0.0", + "ora": "^3.4.0", + "resolve-workspace-root": "^2.0.0" } }, - "node_modules/@jest/environment": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", - "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "node_modules/@expo/plist": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.7.0.tgz", + "integrity": "sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q==", "license": "MIT", + "peer": true, "dependencies": { - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" } }, - "node_modules/@jest/fake-timers": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", - "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "node_modules/@expo/prebuild-config": { + "version": "54.0.8", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-54.0.8.tgz", + "integrity": "sha512-EA7N4dloty2t5Rde+HP0IEE+nkAQiu4A/+QGZGT9mFnZ5KKjPPkqSyYcRvP5bhQE10D+tvz6X0ngZpulbMdbsg==", "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", - "@types/node": "*", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" + "@expo/config": "~12.0.13", + "@expo/config-plugins": "~54.0.4", + "@expo/config-types": "^54.0.10", + "@expo/image-utils": "^0.8.8", + "@expo/json-file": "^10.0.8", + "@react-native/normalize-colors": "0.81.5", + "debug": "^4.3.1", + "resolve-from": "^5.0.0", + "semver": "^7.6.0", + "xml2js": "0.6.0" }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "peerDependencies": { + "expo": "*" } }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "node_modules/@expo/prebuild-config/node_modules/@babel/code-frame": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", + "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", "license": "MIT", "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "@babel/highlight": "^7.10.4" } }, - "node_modules/@jest/transform": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", - "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "node_modules/@expo/prebuild-config/node_modules/@expo/config": { + "version": "12.0.13", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-12.0.13.tgz", + "integrity": "sha512-Cu52arBa4vSaupIWsF0h7F/Cg//N374nYb7HAxV0I4KceKA7x2UXpYaHOL7EEYYvp7tZdThBjvGpVmr8ScIvaQ==", "license": "MIT", "dependencies": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "write-file-atomic": "^4.0.2" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "@babel/code-frame": "~7.10.4", + "@expo/config-plugins": "~54.0.4", + "@expo/config-types": "^54.0.10", + "@expo/json-file": "^10.0.8", + "deepmerge": "^4.3.1", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "require-from-string": "^2.0.2", + "resolve-from": "^5.0.0", + "resolve-workspace-root": "^2.0.0", + "semver": "^7.6.0", + "slugify": "^1.3.4", + "sucrase": "~3.35.1" } }, - "node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "node_modules/@expo/prebuild-config/node_modules/@expo/config-plugins": { + "version": "54.0.4", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-54.0.4.tgz", + "integrity": "sha512-g2yXGICdoOw5i3LkQSDxl2Q5AlQCrG7oniu0pCPPO+UxGb7He4AFqSvPSy8HpRUj55io17hT62FTjYRD+d6j3Q==", "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "@expo/config-types": "^54.0.10", + "@expo/json-file": "~10.0.8", + "@expo/plist": "^0.4.8", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "resolve-from": "^5.0.0", + "semver": "^7.5.4", + "slash": "^3.0.0", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "node_modules/@expo/prebuild-config/node_modules/@expo/config-types": { + "version": "54.0.10", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-54.0.10.tgz", + "integrity": "sha512-/J16SC2an1LdtCZ67xhSkGXpALYUVUNyZws7v+PVsFZxClYehDSoKLqyRaGkpHlYrCc08bS0RF5E0JV6g50psA==", + "license": "MIT" + }, + "node_modules/@expo/prebuild-config/node_modules/@expo/json-file": { + "version": "10.0.16", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.16.tgz", + "integrity": "sha512-fcVkWEj+hLuP2yt5W0aw6LmDRqSPWDLUSxOMcmFeV+algmIF59sQVKCwB9btjQLd4V6x9N0pISkQEkBubUHrCw==", "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "@babel/code-frame": "~7.10.4", + "json5": "^2.2.3" } }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "node_modules/@expo/prebuild-config/node_modules/@expo/plist": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.4.9.tgz", + "integrity": "sha512-MPVpmKGfnQEnrCzgxuXcmPP/y/t6AVm+DcSb2Myp21LKWv1N3l8uFxMggesfF4ixAxkRlGmMMx9GyDC9M+XklQ==", "license": "MIT", "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.2.3", + "xmlbuilder": "^15.1.1" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "license": "MIT", + "node_modules/@expo/prebuild-config/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, "engines": { - "node": ">=6.0.0" + "node": ">=10" } }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", - "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "node_modules/@expo/require-utils": { + "version": "56.1.3", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.3.tgz", + "integrity": "sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==", "license": "MIT", + "peer": true, "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "node_modules/@expo/schema-utils": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-0.1.8.tgz", + "integrity": "sha512-9I6ZqvnAvKKDiO+ZF8BpQQFYWXOJvTAL5L/227RUbWG1OVZDInFifzCBiqAZ3b67NRfeAgpgvbA7rejsqhY62A==", "license": "MIT" }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "node_modules/@expo/sdk-runtime-versions": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@expo/sdk-runtime-versions/-/sdk-runtime-versions-1.0.0.tgz", + "integrity": "sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==", + "license": "MIT" + }, + "node_modules/@expo/spawn-async": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.8.0.tgz", + "integrity": "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==", "license": "MIT", "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "cross-spawn": "^7.0.6" + }, + "engines": { + "node": ">=12" } }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", - "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", - "cpu": [ - "arm64" - ], + "node_modules/@expo/sudo-prompt": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@expo/sudo-prompt/-/sudo-prompt-9.3.2.tgz", + "integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==", + "license": "MIT" + }, + "node_modules/@expo/vector-icons": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/@expo/vector-icons/-/vector-icons-15.1.1.tgz", + "integrity": "sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw==", "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true + "peerDependencies": { + "expo-font": ">=14.0.4", + "react": "*", + "react-native": "*" + } }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", - "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true + "node_modules/@expo/ws-tunnel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-1.0.6.tgz", + "integrity": "sha512-nDRbLmSrJar7abvUjp3smDwH8HcbZcoOEa5jVPUv9/9CajgmWw20JNRwTuBRzWIWIkEJDkz20GoNA+tSwUqk0Q==", + "license": "MIT" }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", - "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true + "node_modules/@expo/xcpretty": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.4.tgz", + "integrity": "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "chalk": "^4.1.0", + "js-yaml": "^4.1.0" + }, + "bin": { + "excpretty": "build/cli.js" + } }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", - "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", - "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } }, - "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", - "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" }, "funding": { "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@nolyfill/is-core-module": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", - "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "engines": { - "node": ">=12.4.0" + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@radix-ui/primitive": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", - "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", - "license": "MIT" - }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", - "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "node_modules/@isaacs/ttlcache": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", + "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==", + "license": "ISC", + "engines": { + "node": ">=12" } }, - "node_modules/@radix-ui/react-direction": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", - "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "engines": { + "node": ">=8" } }, - "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", - "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "dependencies": { + "sprintf-js": "~1.0.2" } }, - "node_modules/@radix-ui/react-id": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", - "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "engines": { + "node": ">=6" } }, - "node_modules/@radix-ui/react-slot": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz", - "integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "engines": { + "node": ">=8" } }, - "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", - "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", - "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "p-locate": "^4.1.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "engines": { + "node": ">=8" } }, - "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", - "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" + "p-try": "^2.0.0" }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "engines": { + "node": ">=6" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", - "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "p-limit": "^2.2.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "engines": { + "node": ">=8" } }, - "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", - "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "engines": { + "node": ">=8" } }, - "node_modules/@react-native-async-storage/async-storage": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", - "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", + "node_modules/@jest/create-cache-key-function": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-29.7.0.tgz", + "integrity": "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==", "license": "MIT", "dependencies": { - "merge-options": "^3.0.4" + "@jest/types": "^29.6.3" }, - "peerDependencies": { - "react-native": "^0.0.0-0 || >=0.65 <1.0" + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@react-native-community/slider": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@react-native-community/slider/-/slider-5.0.1.tgz", - "integrity": "sha512-K3JRWkIW4wQ79YJ6+BPZzp1SamoikxfPRw7Yw4B4PElEQmqZFrmH9M5LxvIo460/3QSrZF/wCgi3qizJt7g/iw==", - "license": "MIT" - }, - "node_modules/@react-native/assets-registry": { - "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz", - "integrity": "sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w==", + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, "engines": { - "node": ">= 20.19.4" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@react-native/babel-plugin-codegen": { - "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.81.5.tgz", - "integrity": "sha512-oF71cIH6je3fSLi6VPjjC3Sgyyn57JLHXs+mHWc9MoCiJJcM4nqsS5J38zv1XQ8d3zOW2JtHro+LF0tagj2bfQ==", + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.3", - "@react-native/codegen": "0.81.5" + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" }, "engines": { - "node": ">= 20.19.4" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@react-native/babel-preset": { - "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.81.5.tgz", - "integrity": "sha512-UoI/x/5tCmi+pZ3c1+Ypr1DaRMDLI3y+Q70pVLLVgrnC3DHsHRIbHcCHIeG/IJvoeFqFM2sTdhSOLJrf8lOPrA==", + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", "license": "MIT", "dependencies": { - "@babel/core": "^7.25.2", - "@babel/plugin-proposal-export-default-from": "^7.24.7", - "@babel/plugin-syntax-dynamic-import": "^7.8.3", - "@babel/plugin-syntax-export-default-from": "^7.24.7", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-transform-arrow-functions": "^7.24.7", - "@babel/plugin-transform-async-generator-functions": "^7.25.4", - "@babel/plugin-transform-async-to-generator": "^7.24.7", - "@babel/plugin-transform-block-scoping": "^7.25.0", - "@babel/plugin-transform-class-properties": "^7.25.4", - "@babel/plugin-transform-classes": "^7.25.4", - "@babel/plugin-transform-computed-properties": "^7.24.7", - "@babel/plugin-transform-destructuring": "^7.24.8", - "@babel/plugin-transform-flow-strip-types": "^7.25.2", - "@babel/plugin-transform-for-of": "^7.24.7", - "@babel/plugin-transform-function-name": "^7.25.1", - "@babel/plugin-transform-literals": "^7.25.2", - "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", - "@babel/plugin-transform-modules-commonjs": "^7.24.8", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", - "@babel/plugin-transform-numeric-separator": "^7.24.7", - "@babel/plugin-transform-object-rest-spread": "^7.24.7", - "@babel/plugin-transform-optional-catch-binding": "^7.24.7", - "@babel/plugin-transform-optional-chaining": "^7.24.8", - "@babel/plugin-transform-parameters": "^7.24.7", - "@babel/plugin-transform-private-methods": "^7.24.7", - "@babel/plugin-transform-private-property-in-object": "^7.24.7", - "@babel/plugin-transform-react-display-name": "^7.24.7", - "@babel/plugin-transform-react-jsx": "^7.25.2", - "@babel/plugin-transform-react-jsx-self": "^7.24.7", - "@babel/plugin-transform-react-jsx-source": "^7.24.7", - "@babel/plugin-transform-regenerator": "^7.24.7", - "@babel/plugin-transform-runtime": "^7.24.7", - "@babel/plugin-transform-shorthand-properties": "^7.24.7", - "@babel/plugin-transform-spread": "^7.24.7", - "@babel/plugin-transform-sticky-regex": "^7.24.7", - "@babel/plugin-transform-typescript": "^7.25.2", - "@babel/plugin-transform-unicode-regex": "^7.24.7", - "@babel/template": "^7.25.0", - "@react-native/babel-plugin-codegen": "0.81.5", - "babel-plugin-syntax-hermes-parser": "0.29.1", - "babel-plugin-transform-flow-enums": "^0.0.2", - "react-refresh": "^0.14.0" + "@sinclair/typebox": "^0.27.8" }, "engines": { - "node": ">= 20.19.4" - }, - "peerDependencies": { - "@babel/core": "*" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@react-native/codegen": { - "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.81.5.tgz", - "integrity": "sha512-a2TDA03Up8lpSa9sh5VRGCQDXgCTOyDOFH+aqyinxp1HChG8uk89/G+nkJ9FPd0rqgi25eCTR16TWdS3b+fA6g==", + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", "license": "MIT", "dependencies": { - "@babel/core": "^7.25.2", - "@babel/parser": "^7.25.3", - "glob": "^7.1.1", - "hermes-parser": "0.29.1", - "invariant": "^2.2.4", - "nullthrows": "^1.1.1", - "yargs": "^17.6.2" + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" }, "engines": { - "node": ">= 20.19.4" - }, - "peerDependencies": { - "@babel/core": "*" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@react-native/codegen/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "license": "MIT", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" }, "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@react-native/codegen/node_modules/hermes-estree": { - "version": "0.29.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.29.1.tgz", - "integrity": "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ==", - "license": "MIT" - }, - "node_modules/@react-native/codegen/node_modules/hermes-parser": { - "version": "0.29.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.29.1.tgz", - "integrity": "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA==", - "license": "MIT", - "dependencies": { - "hermes-estree": "0.29.1" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@react-native/community-cli-plugin": { - "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.81.5.tgz", - "integrity": "sha512-yWRlmEOtcyvSZ4+OvqPabt+NS36vg0K/WADTQLhrYrm9qdZSuXmq8PmdJWz/68wAqKQ+4KTILiq2kjRQwnyhQw==", + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "license": "MIT", "dependencies": { - "@react-native/dev-middleware": "0.81.5", - "debug": "^4.4.0", - "invariant": "^2.2.4", - "metro": "^0.83.1", - "metro-config": "^0.83.1", - "metro-core": "^0.83.1", - "semver": "^7.1.3" - }, - "engines": { - "node": ">= 20.19.4" - }, - "peerDependencies": { - "@react-native-community/cli": "*", - "@react-native/metro-config": "*" - }, - "peerDependenciesMeta": { - "@react-native-community/cli": { - "optional": true - }, - "@react-native/metro-config": { - "optional": true - } + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@react-native/community-cli-plugin/node_modules/hermes-estree": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz", - "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==", - "license": "MIT" - }, - "node_modules/@react-native/community-cli-plugin/node_modules/hermes-parser": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz", - "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==", + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "license": "MIT", "dependencies": { - "hermes-estree": "0.35.0" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@react-native/community-cli-plugin/node_modules/metro": { - "version": "0.83.7", - "resolved": "https://registry.npmjs.org/metro/-/metro-0.83.7.tgz", - "integrity": "sha512-SPaPEyvTsTmd0LpT7RaZciQyDw2i/JB7+iY9L5VfBo72+psescFxBqpI1TL9dnL+pmnfkU+l/J1mEEGLeF65EQ==", + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/core": "^7.25.2", - "@babel/generator": "^7.29.1", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", - "accepts": "^2.0.0", - "ci-info": "^2.0.0", - "connect": "^3.6.5", - "debug": "^4.4.0", - "error-stack-parser": "^2.0.6", - "flow-enums-runtime": "^0.0.6", - "graceful-fs": "^4.2.4", - "hermes-parser": "0.35.0", - "image-size": "^1.0.2", - "invariant": "^2.2.4", - "jest-worker": "^29.7.0", - "jsc-safe-url": "^0.2.2", - "lodash.throttle": "^4.1.1", - "metro-babel-transformer": "0.83.7", - "metro-cache": "0.83.7", - "metro-cache-key": "0.83.7", - "metro-config": "0.83.7", - "metro-core": "0.83.7", - "metro-file-map": "0.83.7", - "metro-resolver": "0.83.7", - "metro-runtime": "0.83.7", - "metro-source-map": "0.83.7", - "metro-symbolicate": "0.83.7", - "metro-transform-plugins": "0.83.7", - "metro-transform-worker": "0.83.7", - "mime-types": "^3.0.1", - "nullthrows": "^1.1.1", - "serialize-error": "^2.1.0", - "source-map": "^0.5.6", - "throat": "^5.0.0", - "ws": "^7.5.10", - "yargs": "^17.6.2" - }, - "bin": { - "metro": "src/cli.js" - }, "engines": { - "node": ">=20.19.4" + "node": ">=6.0.0" } }, - "node_modules/@react-native/community-cli-plugin/node_modules/metro-babel-transformer": { - "version": "0.83.7", - "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.83.7.tgz", - "integrity": "sha512-sBqBkt6kNut/88bv+Ucvm4yqdPetbvAEsHzi3MAgJEifOSYYzX5Z5Kgw3TFOrwf/mHJTOBG2ONlaMHoyfP15TA==", + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "license": "MIT", "dependencies": { - "@babel/core": "^7.25.2", - "flow-enums-runtime": "^0.0.6", - "hermes-parser": "0.35.0", - "metro-cache-key": "0.83.7", - "nullthrows": "^1.1.1" - }, - "engines": { - "node": ">=20.19.4" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" } }, - "node_modules/@react-native/community-cli-plugin/node_modules/metro-cache": { - "version": "0.83.7", - "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.83.7.tgz", - "integrity": "sha512-E9SRePXQ1Zvlj79VcOk57q7VC7rMHMFQ+jhmPHBiq+dJ0bJB5BL87lWZF6oh5X76Cci5tpDuQNaDwwuSCToEeg==", + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", "dependencies": { - "exponential-backoff": "^3.1.1", - "flow-enums-runtime": "^0.0.6", - "https-proxy-agent": "^7.0.5", - "metro-core": "0.83.7" - }, - "engines": { - "node": ">=20.19.4" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@react-native/community-cli-plugin/node_modules/metro-cache-key": { - "version": "0.83.7", - "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.83.7.tgz", - "integrity": "sha512-W1c2Nmx8MiJTJt+eWhMO08z9VKi3kZOaz99IYGdqeqDgY9j+yZjXl62rUav4Di0heZfh4/n2s722PqRL1OODeg==", + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "flow-enums-runtime": "^0.0.6" - }, - "engines": { - "node": ">=20.19.4" - } + "optional": true, + "os": [ + "darwin" + ], + "peer": true }, - "node_modules/@react-native/community-cli-plugin/node_modules/metro-config": { - "version": "0.83.7", - "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.83.7.tgz", - "integrity": "sha512-83mjWFbFOt2GeJ6pFIum5mSnc1uTsZJAtD8o4ej0s4NVsYsA7fB+pHvTfHhFrpeMONaobu2riKavkPei05Er/Q==", + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "connect": "^3.6.5", - "flow-enums-runtime": "^0.0.6", - "jest-validate": "^29.7.0", - "metro": "0.83.7", - "metro-cache": "0.83.7", - "metro-core": "0.83.7", - "metro-runtime": "0.83.7", - "yaml": "^2.6.1" - }, - "engines": { - "node": ">=20.19.4" - } + "optional": true, + "os": [ + "darwin" + ], + "peer": true }, - "node_modules/@react-native/community-cli-plugin/node_modules/metro-core": { - "version": "0.83.7", - "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.83.7.tgz", - "integrity": "sha512-6yn3w1wnltT6RQl7p7YES2l95ArC+mWrOssEiH8p5/DDrJS65/szf9LsC9JrBv8c5DdvSY3V3f0GRYg0Ox7hCg==", + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "flow-enums-runtime": "^0.0.6", - "lodash.throttle": "^4.1.1", - "metro-resolver": "0.83.7" + "@tybys/wasm-util": "^0.10.1" }, - "engines": { - "node": ">=20.19.4" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, - "node_modules/@react-native/community-cli-plugin/node_modules/metro-file-map": { - "version": "0.83.7", - "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.83.7.tgz", - "integrity": "sha512-+j0F1m+FQYVAQ6syf+mwhIPV5GoFQrkInX8bppuc50IzNsZbMrp8R5H/Sx/K2daQ3YEa9F/XwkeZT8gzJfgeCw==", + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "fb-watchman": "^2.0.0", - "flow-enums-runtime": "^0.0.6", - "graceful-fs": "^4.2.4", - "invariant": "^2.2.4", - "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", - "nullthrows": "^1.1.1", - "walker": "^1.0.7" - }, "engines": { - "node": ">=20.19.4" + "node": ">=12.4.0" } }, - "node_modules/@react-native/community-cli-plugin/node_modules/metro-minify-terser": { - "version": "0.83.7", - "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.83.7.tgz", - "integrity": "sha512-MfJar2IS4tBRuLb9svwb0Gu5l9BsH+pcRm8eGcEi/wy8MzZinfinh5dFLt2nWkocnulIgtGB5NkFDdbXqMXKhQ==", + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", "license": "MIT", - "dependencies": { - "flow-enums-runtime": "^0.0.6", - "terser": "^5.15.0" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "engines": { - "node": ">=20.19.4" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@react-native/community-cli-plugin/node_modules/metro-resolver": { - "version": "0.83.7", - "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.83.7.tgz", - "integrity": "sha512-WSJIENlMcoSsuz66IfBHOkgfp3KJt2UW2TnEHPf1b8pIG2eEXNOVmo2+03A0H17WY2XGXWgxL0CG7FAopqgB1A==", + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", "license": "MIT", - "dependencies": { - "flow-enums-runtime": "^0.0.6" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "engines": { - "node": ">=20.19.4" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@react-native/community-cli-plugin/node_modules/metro-runtime": { - "version": "0.83.7", - "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.83.7.tgz", - "integrity": "sha512-9GKkJURaB2iyYoEExKnedzAHzxmKtSi+k0tsZUvMoU27tBZJElchYt7JH/Ai/XzYAI9lCAaV7u5HZSI8J5Z+wQ==", + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.25.0", - "flow-enums-runtime": "^0.0.6" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "engines": { - "node": ">=20.19.4" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@react-native/community-cli-plugin/node_modules/metro-source-map": { - "version": "0.83.7", - "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.83.7.tgz", - "integrity": "sha512-JgA1h7oc1a1jydBe1GhVFsUoMYo3wLPk7oRA32rjlDsq+sP2JLt9x2p2lWbNSxTm/u8NV4VRid3hvEJgcX8tKw==", + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", - "flow-enums-runtime": "^0.0.6", - "invariant": "^2.2.4", - "metro-symbolicate": "0.83.7", - "nullthrows": "^1.1.1", - "ob1": "0.83.7", - "source-map": "^0.5.6", - "vlq": "^1.0.0" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "engines": { - "node": ">=20.19.4" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@react-native/community-cli-plugin/node_modules/metro-symbolicate": { - "version": "0.83.7", - "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.83.7.tgz", - "integrity": "sha512-g4suyxw20WOHWI680c+Kq4wC/NF+Hx5pRH9afrMp+sMTxqLeKcPR1Xf4wMhsjlbvx7LbIREdke6q928jEjvJWw==", + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", "license": "MIT", "dependencies": { - "flow-enums-runtime": "^0.0.6", - "invariant": "^2.2.4", - "metro-source-map": "0.83.7", - "nullthrows": "^1.1.1", - "source-map": "^0.5.6", - "vlq": "^1.0.0" + "@radix-ui/react-use-layout-effect": "1.1.1" }, - "bin": { - "metro-symbolicate": "src/index.js" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "engines": { - "node": ">=20.19.4" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@react-native/community-cli-plugin/node_modules/metro-transform-plugins": { - "version": "0.83.7", - "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.83.7.tgz", - "integrity": "sha512-Ss0FpBiZDjX2kwhukMDl5sNdYK8T/06IPqxNE4H6PTlRlfs9q11cef13c/xESY/Pm4VCkp1yJUZO3kXzvMxQFA==", + "node_modules/@radix-ui/react-slot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz", + "integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==", "license": "MIT", "dependencies": { - "@babel/core": "^7.25.2", - "@babel/generator": "^7.29.1", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "flow-enums-runtime": "^0.0.6", - "nullthrows": "^1.1.1" + "@radix-ui/react-compose-refs": "1.1.2" }, - "engines": { - "node": ">=20.19.4" - } - }, - "node_modules/@react-native/community-cli-plugin/node_modules/metro-transform-worker": { - "version": "0.83.7", - "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.83.7.tgz", - "integrity": "sha512-UegCo7ygB2fT64mRK2nbAjQVJ1zSwIIHy8d96jJv2nKZFDaViYBiughEdu5HM/Ceq0WN3LZrZk3zhl9aoiLYFw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", "license": "MIT", "dependencies": { - "@babel/core": "^7.25.2", - "@babel/generator": "^7.29.1", - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "flow-enums-runtime": "^0.0.6", - "metro": "0.83.7", - "metro-babel-transformer": "0.83.7", - "metro-cache": "0.83.7", - "metro-cache-key": "0.83.7", - "metro-minify-terser": "0.83.7", - "metro-source-map": "0.83.7", - "metro-transform-plugins": "0.83.7", - "nullthrows": "^1.1.1" + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" }, - "engines": { - "node": ">=20.19.4" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@react-native/community-cli-plugin/node_modules/ob1": { - "version": "0.83.7", - "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.83.7.tgz", - "integrity": "sha512-9M5kpuOLyTPogMtZiQUIxdAZxl7Dxs6tVBbJErSumsqGMuhVSoUbkfeZ3XNPpLpwBBtqY5QDUzGwggLHX3slQg==", + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", "license": "MIT", "dependencies": { - "flow-enums-runtime": "^0.0.6" + "@radix-ui/react-use-layout-effect": "1.1.1" }, - "engines": { - "node": ">=20.19.4" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@react-native/community-cli-plugin/node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" }, - "engines": { - "node": ">=10" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@react-native/debugger-frontend": { - "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.81.5.tgz", - "integrity": "sha512-bnd9FSdWKx2ncklOetCgrlwqSGhMHP2zOxObJbOWXoj7GHEmih4MKarBo5/a8gX8EfA1EwRATdfNBQ81DY+h+w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">= 20.19.4" + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@react-native/dev-middleware": { - "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.81.5.tgz", - "integrity": "sha512-WfPfZzboYgo/TUtysuD5xyANzzfka8Ebni6RIb2wDxhb56ERi7qDrE4xGhtPsjCL4pQBXSVxyIlCy0d8I6EgGA==", + "node_modules/@react-native-async-storage/async-storage": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", + "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", "license": "MIT", "dependencies": { - "@isaacs/ttlcache": "^1.4.1", - "@react-native/debugger-frontend": "0.81.5", - "chrome-launcher": "^0.15.2", - "chromium-edge-launcher": "^0.2.0", - "connect": "^3.6.5", - "debug": "^4.4.0", - "invariant": "^2.2.4", - "nullthrows": "^1.1.1", - "open": "^7.0.3", - "serve-static": "^1.16.2", - "ws": "^6.2.3" + "merge-options": "^3.0.4" }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.65 <1.0" + } + }, + "node_modules/@react-native-community/slider": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@react-native-community/slider/-/slider-5.0.1.tgz", + "integrity": "sha512-K3JRWkIW4wQ79YJ6+BPZzp1SamoikxfPRw7Yw4B4PElEQmqZFrmH9M5LxvIo460/3QSrZF/wCgi3qizJt7g/iw==", + "license": "MIT" + }, + "node_modules/@react-native/assets-registry": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz", + "integrity": "sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w==", + "license": "MIT", "engines": { "node": ">= 20.19.4" } }, - "node_modules/@react-native/dev-middleware/node_modules/ws": { - "version": "6.2.4", - "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.4.tgz", - "integrity": "sha512-PNIUUyLI5YpkJZj60YBzX1o0ByQ4ovvfmq9N/Kig/PAYbVlGyz4R6G0SEWrD0O9acc0sT2+IdMBVLFv8FSi0Nw==", + "node_modules/@react-native/babel-plugin-codegen": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.81.5.tgz", + "integrity": "sha512-oF71cIH6je3fSLi6VPjjC3Sgyyn57JLHXs+mHWc9MoCiJJcM4nqsS5J38zv1XQ8d3zOW2JtHro+LF0tagj2bfQ==", "license": "MIT", "dependencies": { - "async-limiter": "~1.0.0" + "@babel/traverse": "^7.25.3", + "@react-native/codegen": "0.81.5" + }, + "engines": { + "node": ">= 20.19.4" } }, - "node_modules/@react-native/gradle-plugin": { + "node_modules/@react-native/babel-preset": { "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.81.5.tgz", - "integrity": "sha512-hORRlNBj+ReNMLo9jme3yQ6JQf4GZpVEBLxmTXGGlIL78MAezDZr5/uq9dwElSbcGmLEgeiax6e174Fie6qPLg==", + "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.81.5.tgz", + "integrity": "sha512-UoI/x/5tCmi+pZ3c1+Ypr1DaRMDLI3y+Q70pVLLVgrnC3DHsHRIbHcCHIeG/IJvoeFqFM2sTdhSOLJrf8lOPrA==", "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-transform-arrow-functions": "^7.24.7", + "@babel/plugin-transform-async-generator-functions": "^7.25.4", + "@babel/plugin-transform-async-to-generator": "^7.24.7", + "@babel/plugin-transform-block-scoping": "^7.25.0", + "@babel/plugin-transform-class-properties": "^7.25.4", + "@babel/plugin-transform-classes": "^7.25.4", + "@babel/plugin-transform-computed-properties": "^7.24.7", + "@babel/plugin-transform-destructuring": "^7.24.8", + "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-for-of": "^7.24.7", + "@babel/plugin-transform-function-name": "^7.25.1", + "@babel/plugin-transform-literals": "^7.25.2", + "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", + "@babel/plugin-transform-numeric-separator": "^7.24.7", + "@babel/plugin-transform-object-rest-spread": "^7.24.7", + "@babel/plugin-transform-optional-catch-binding": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.8", + "@babel/plugin-transform-parameters": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-react-display-name": "^7.24.7", + "@babel/plugin-transform-react-jsx": "^7.25.2", + "@babel/plugin-transform-react-jsx-self": "^7.24.7", + "@babel/plugin-transform-react-jsx-source": "^7.24.7", + "@babel/plugin-transform-regenerator": "^7.24.7", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/plugin-transform-shorthand-properties": "^7.24.7", + "@babel/plugin-transform-spread": "^7.24.7", + "@babel/plugin-transform-sticky-regex": "^7.24.7", + "@babel/plugin-transform-typescript": "^7.25.2", + "@babel/plugin-transform-unicode-regex": "^7.24.7", + "@babel/template": "^7.25.0", + "@react-native/babel-plugin-codegen": "0.81.5", + "babel-plugin-syntax-hermes-parser": "0.29.1", + "babel-plugin-transform-flow-enums": "^0.0.2", + "react-refresh": "^0.14.0" + }, "engines": { "node": ">= 20.19.4" + }, + "peerDependencies": { + "@babel/core": "*" } }, - "node_modules/@react-native/js-polyfills": { + "node_modules/@react-native/codegen": { "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.81.5.tgz", - "integrity": "sha512-fB7M1CMOCIUudTRuj7kzxIBTVw2KXnsgbQ6+4cbqSxo8NmRRhA0Ul4ZUzZj3rFd3VznTL4Brmocv1oiN0bWZ8w==", + "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.81.5.tgz", + "integrity": "sha512-a2TDA03Up8lpSa9sh5VRGCQDXgCTOyDOFH+aqyinxp1HChG8uk89/G+nkJ9FPd0rqgi25eCTR16TWdS3b+fA6g==", "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/parser": "^7.25.3", + "glob": "^7.1.1", + "hermes-parser": "0.29.1", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "yargs": "^17.6.2" + }, "engines": { "node": ">= 20.19.4" + }, + "peerDependencies": { + "@babel/core": "*" } }, - "node_modules/@react-native/normalize-colors": { - "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.81.5.tgz", - "integrity": "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==", - "license": "MIT" - }, - "node_modules/@react-navigation/bottom-tabs": { - "version": "7.16.2", - "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.16.2.tgz", - "integrity": "sha512-Lbp++BGMc7SQXnyKuO/JrQJIhFH0zyB5v4kIEbnzDJLJfgubd5hoSe+QfCqy4YHfLA4phC4Xf/6Q2Ic8x7datQ==", + "node_modules/@react-native/codegen/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@react-native/codegen/node_modules/hermes-estree": { + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.29.1.tgz", + "integrity": "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ==", + "license": "MIT" + }, + "node_modules/@react-native/codegen/node_modules/hermes-parser": { + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.29.1.tgz", + "integrity": "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.29.1" + } + }, + "node_modules/@react-native/community-cli-plugin": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.81.5.tgz", + "integrity": "sha512-yWRlmEOtcyvSZ4+OvqPabt+NS36vg0K/WADTQLhrYrm9qdZSuXmq8PmdJWz/68wAqKQ+4KTILiq2kjRQwnyhQw==", + "license": "MIT", + "dependencies": { + "@react-native/dev-middleware": "0.81.5", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "metro": "^0.83.1", + "metro-config": "^0.83.1", + "metro-core": "^0.83.1", + "semver": "^7.1.3" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@react-native-community/cli": "*", + "@react-native/metro-config": "*" + }, + "peerDependenciesMeta": { + "@react-native-community/cli": { + "optional": true + }, + "@react-native/metro-config": { + "optional": true + } + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/hermes-estree": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz", + "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==", + "license": "MIT" + }, + "node_modules/@react-native/community-cli-plugin/node_modules/hermes-parser": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz", + "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.35.0" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/metro": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro/-/metro-0.83.7.tgz", + "integrity": "sha512-SPaPEyvTsTmd0LpT7RaZciQyDw2i/JB7+iY9L5VfBo72+psescFxBqpI1TL9dnL+pmnfkU+l/J1mEEGLeF65EQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "accepts": "^2.0.0", + "ci-info": "^2.0.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "error-stack-parser": "^2.0.6", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "hermes-parser": "0.35.0", + "image-size": "^1.0.2", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "jsc-safe-url": "^0.2.2", + "lodash.throttle": "^4.1.1", + "metro-babel-transformer": "0.83.7", + "metro-cache": "0.83.7", + "metro-cache-key": "0.83.7", + "metro-config": "0.83.7", + "metro-core": "0.83.7", + "metro-file-map": "0.83.7", + "metro-resolver": "0.83.7", + "metro-runtime": "0.83.7", + "metro-source-map": "0.83.7", + "metro-symbolicate": "0.83.7", + "metro-transform-plugins": "0.83.7", + "metro-transform-worker": "0.83.7", + "mime-types": "^3.0.1", + "nullthrows": "^1.1.1", + "serialize-error": "^2.1.0", + "source-map": "^0.5.6", + "throat": "^5.0.0", + "ws": "^7.5.10", + "yargs": "^17.6.2" + }, + "bin": { + "metro": "src/cli.js" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/metro-babel-transformer": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.83.7.tgz", + "integrity": "sha512-sBqBkt6kNut/88bv+Ucvm4yqdPetbvAEsHzi3MAgJEifOSYYzX5Z5Kgw3TFOrwf/mHJTOBG2ONlaMHoyfP15TA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "flow-enums-runtime": "^0.0.6", + "hermes-parser": "0.35.0", + "metro-cache-key": "0.83.7", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/metro-cache": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.83.7.tgz", + "integrity": "sha512-E9SRePXQ1Zvlj79VcOk57q7VC7rMHMFQ+jhmPHBiq+dJ0bJB5BL87lWZF6oh5X76Cci5tpDuQNaDwwuSCToEeg==", + "license": "MIT", + "dependencies": { + "exponential-backoff": "^3.1.1", + "flow-enums-runtime": "^0.0.6", + "https-proxy-agent": "^7.0.5", + "metro-core": "0.83.7" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/metro-cache-key": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.83.7.tgz", + "integrity": "sha512-W1c2Nmx8MiJTJt+eWhMO08z9VKi3kZOaz99IYGdqeqDgY9j+yZjXl62rUav4Di0heZfh4/n2s722PqRL1OODeg==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/metro-config": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.83.7.tgz", + "integrity": "sha512-83mjWFbFOt2GeJ6pFIum5mSnc1uTsZJAtD8o4ej0s4NVsYsA7fB+pHvTfHhFrpeMONaobu2riKavkPei05Er/Q==", + "license": "MIT", + "dependencies": { + "connect": "^3.6.5", + "flow-enums-runtime": "^0.0.6", + "jest-validate": "^29.7.0", + "metro": "0.83.7", + "metro-cache": "0.83.7", + "metro-core": "0.83.7", + "metro-runtime": "0.83.7", + "yaml": "^2.6.1" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/metro-core": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.83.7.tgz", + "integrity": "sha512-6yn3w1wnltT6RQl7p7YES2l95ArC+mWrOssEiH8p5/DDrJS65/szf9LsC9JrBv8c5DdvSY3V3f0GRYg0Ox7hCg==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "lodash.throttle": "^4.1.1", + "metro-resolver": "0.83.7" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/metro-file-map": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.83.7.tgz", + "integrity": "sha512-+j0F1m+FQYVAQ6syf+mwhIPV5GoFQrkInX8bppuc50IzNsZbMrp8R5H/Sx/K2daQ3YEa9F/XwkeZT8gzJfgeCw==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "fb-watchman": "^2.0.0", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "nullthrows": "^1.1.1", + "walker": "^1.0.7" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/metro-minify-terser": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.83.7.tgz", + "integrity": "sha512-MfJar2IS4tBRuLb9svwb0Gu5l9BsH+pcRm8eGcEi/wy8MzZinfinh5dFLt2nWkocnulIgtGB5NkFDdbXqMXKhQ==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "terser": "^5.15.0" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/metro-resolver": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.83.7.tgz", + "integrity": "sha512-WSJIENlMcoSsuz66IfBHOkgfp3KJt2UW2TnEHPf1b8pIG2eEXNOVmo2+03A0H17WY2XGXWgxL0CG7FAopqgB1A==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/metro-runtime": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.83.7.tgz", + "integrity": "sha512-9GKkJURaB2iyYoEExKnedzAHzxmKtSi+k0tsZUvMoU27tBZJElchYt7JH/Ai/XzYAI9lCAaV7u5HZSI8J5Z+wQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.0", + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/metro-source-map": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.83.7.tgz", + "integrity": "sha512-JgA1h7oc1a1jydBe1GhVFsUoMYo3wLPk7oRA32rjlDsq+sP2JLt9x2p2lWbNSxTm/u8NV4VRid3hvEJgcX8tKw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-symbolicate": "0.83.7", + "nullthrows": "^1.1.1", + "ob1": "0.83.7", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/metro-symbolicate": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.83.7.tgz", + "integrity": "sha512-g4suyxw20WOHWI680c+Kq4wC/NF+Hx5pRH9afrMp+sMTxqLeKcPR1Xf4wMhsjlbvx7LbIREdke6q928jEjvJWw==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-source-map": "0.83.7", + "nullthrows": "^1.1.1", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "bin": { + "metro-symbolicate": "src/index.js" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/metro-transform-plugins": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.83.7.tgz", + "integrity": "sha512-Ss0FpBiZDjX2kwhukMDl5sNdYK8T/06IPqxNE4H6PTlRlfs9q11cef13c/xESY/Pm4VCkp1yJUZO3kXzvMxQFA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/metro-transform-worker": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.83.7.tgz", + "integrity": "sha512-UegCo7ygB2fT64mRK2nbAjQVJ1zSwIIHy8d96jJv2nKZFDaViYBiughEdu5HM/Ceq0WN3LZrZk3zhl9aoiLYFw==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "metro": "0.83.7", + "metro-babel-transformer": "0.83.7", + "metro-cache": "0.83.7", + "metro-cache-key": "0.83.7", + "metro-minify-terser": "0.83.7", + "metro-source-map": "0.83.7", + "metro-transform-plugins": "0.83.7", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/ob1": { + "version": "0.83.7", + "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.83.7.tgz", + "integrity": "sha512-9M5kpuOLyTPogMtZiQUIxdAZxl7Dxs6tVBbJErSumsqGMuhVSoUbkfeZ3XNPpLpwBBtqY5QDUzGwggLHX3slQg==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@react-native/debugger-frontend": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.81.5.tgz", + "integrity": "sha512-bnd9FSdWKx2ncklOetCgrlwqSGhMHP2zOxObJbOWXoj7GHEmih4MKarBo5/a8gX8EfA1EwRATdfNBQ81DY+h+w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/dev-middleware": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.81.5.tgz", + "integrity": "sha512-WfPfZzboYgo/TUtysuD5xyANzzfka8Ebni6RIb2wDxhb56ERi7qDrE4xGhtPsjCL4pQBXSVxyIlCy0d8I6EgGA==", + "license": "MIT", + "dependencies": { + "@isaacs/ttlcache": "^1.4.1", + "@react-native/debugger-frontend": "0.81.5", + "chrome-launcher": "^0.15.2", + "chromium-edge-launcher": "^0.2.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "open": "^7.0.3", + "serve-static": "^1.16.2", + "ws": "^6.2.3" + }, + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/ws": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.4.tgz", + "integrity": "sha512-PNIUUyLI5YpkJZj60YBzX1o0ByQ4ovvfmq9N/Kig/PAYbVlGyz4R6G0SEWrD0O9acc0sT2+IdMBVLFv8FSi0Nw==", + "license": "MIT", + "dependencies": { + "async-limiter": "~1.0.0" + } + }, + "node_modules/@react-native/gradle-plugin": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.81.5.tgz", + "integrity": "sha512-hORRlNBj+ReNMLo9jme3yQ6JQf4GZpVEBLxmTXGGlIL78MAezDZr5/uq9dwElSbcGmLEgeiax6e174Fie6qPLg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/js-polyfills": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.81.5.tgz", + "integrity": "sha512-fB7M1CMOCIUudTRuj7kzxIBTVw2KXnsgbQ6+4cbqSxo8NmRRhA0Ul4ZUzZj3rFd3VznTL4Brmocv1oiN0bWZ8w==", + "license": "MIT", + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/normalize-colors": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.81.5.tgz", + "integrity": "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==", + "license": "MIT" + }, + "node_modules/@react-navigation/bottom-tabs": { + "version": "7.16.2", + "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.16.2.tgz", + "integrity": "sha512-Lbp++BGMc7SQXnyKuO/JrQJIhFH0zyB5v4kIEbnzDJLJfgubd5hoSe+QfCqy4YHfLA4phC4Xf/6Q2Ic8x7datQ==", + "license": "MIT", + "dependencies": { + "@react-navigation/elements": "^2.9.19", + "color": "^4.2.3", + "sf-symbols-typescript": "^2.1.0" + }, + "peerDependencies": { + "@react-navigation/native": "^7.2.5", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0", + "react-native-screens": ">= 4.0.0" + } + }, + "node_modules/@react-navigation/core": { + "version": "7.17.5", + "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.17.5.tgz", + "integrity": "sha512-6fDCwDTWC7DJn0SDb9DJGRlipaygHIc+2elpZBJI6Crl/2Pu+Z1d6W4jMJ2gZO6iHKf+Pe5sUiQ/uwepGprZtg==", + "license": "MIT", + "dependencies": { + "@react-navigation/routers": "^7.5.5", + "escape-string-regexp": "^4.0.0", + "fast-deep-equal": "^3.1.3", + "nanoid": "^3.3.11", + "query-string": "^7.1.3", + "react-is": "^19.1.0", + "use-latest-callback": "^0.2.4", + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "react": ">= 18.2.0" + } + }, + "node_modules/@react-navigation/elements": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.19.tgz", + "integrity": "sha512-gBUvCZuUkOGw1KpLQEZIkByUz8RYPwXeoA6mZFJy9K1mxd8GdqHDMFCIoB0lfPz9rgrHj99RvtdlGZ/ZzkZv2A==", + "license": "MIT", + "dependencies": { + "color": "^4.2.3", + "use-latest-callback": "^0.2.4", + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@react-native-masked-view/masked-view": ">= 0.2.0", + "@react-navigation/native": "^7.2.5", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0" + }, + "peerDependenciesMeta": { + "@react-native-masked-view/masked-view": { + "optional": true + } + } + }, + "node_modules/@react-navigation/native": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.2.5.tgz", + "integrity": "sha512-01AAUQiiHQAfTabq+ZyU1/ZWq+AbB/J3v0CB0UTJSON6M6cuadWNsbChzrZUdqQvHrXvg96U5i2PQLJzK3+zpg==", + "license": "MIT", + "dependencies": { + "@react-navigation/core": "^7.17.5", + "escape-string-regexp": "^4.0.0", + "fast-deep-equal": "^3.1.3", + "nanoid": "^3.3.11", + "use-latest-callback": "^0.2.4" + }, + "peerDependencies": { + "react": ">= 18.2.0", + "react-native": "*" + } + }, + "node_modules/@react-navigation/native-stack": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.16.0.tgz", + "integrity": "sha512-wM21rHYR2XifjDnKLrr3HeHUeGsWQZJRwPqEzy1Vp/a9k3ieiwTGpmpDItD/jtERH9qkYESwDPO6oEtrVBEpQg==", + "license": "MIT", + "dependencies": { + "@react-navigation/elements": "^2.9.19", + "color": "^4.2.3", + "sf-symbols-typescript": "^2.1.0", + "warn-once": "^0.1.1" + }, + "peerDependencies": { + "@react-navigation/native": "^7.2.5", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0", + "react-native-screens": ">= 4.0.0" + } + }, + "node_modules/@react-navigation/routers": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.5.5.tgz", + "integrity": "sha512-9/hhMte12Kgu+pMnLfA4EWJ0OQmIEAMVMX06FPH2yGkEQSQ3JhhCN/GkcRikzQhtEi97VYYQA15umptBUShcOQ==", + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11" + } + }, + "node_modules/@react-three/fiber": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.1.tgz", + "integrity": "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^2.0.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.27.0", + "suspend-react": "^0.1.3", + "use-sync-external-store": "^1.4.0", + "zustand": "^5.0.3" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=19 <19.3", + "react-dom": ">=19 <19.3", + "react-native": ">=0.78", + "three": ">=0.156" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@react-navigation/elements": "^2.9.19", - "color": "^4.2.3", - "sf-symbols-typescript": "^2.1.0" - }, - "peerDependencies": { - "@react-navigation/native": "^7.2.5", - "react": ">= 18.2.0", - "react-native": "*", - "react-native-safe-area-context": ">= 4.0.0", - "react-native-screens": ">= 4.0.0" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@react-navigation/core": { - "version": "7.17.5", - "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.17.5.tgz", - "integrity": "sha512-6fDCwDTWC7DJn0SDb9DJGRlipaygHIc+2elpZBJI6Crl/2Pu+Z1d6W4jMJ2gZO6iHKf+Pe5sUiQ/uwepGprZtg==", + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@react-navigation/routers": "^7.5.5", - "escape-string-regexp": "^4.0.0", - "fast-deep-equal": "^3.1.3", - "nanoid": "^3.3.11", - "query-string": "^7.1.3", - "react-is": "^19.1.0", - "use-latest-callback": "^0.2.4", - "use-sync-external-store": "^1.5.0" - }, - "peerDependencies": { - "react": ">= 18.2.0" - } + "optional": true, + "os": [ + "openbsd" + ] }, - "node_modules/@react-navigation/elements": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.19.tgz", - "integrity": "sha512-gBUvCZuUkOGw1KpLQEZIkByUz8RYPwXeoA6mZFJy9K1mxd8GdqHDMFCIoB0lfPz9rgrHj99RvtdlGZ/ZzkZv2A==", + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "color": "^4.2.3", - "use-latest-callback": "^0.2.4", - "use-sync-external-store": "^1.5.0" - }, - "peerDependencies": { - "@react-native-masked-view/masked-view": ">= 0.2.0", - "@react-navigation/native": "^7.2.5", - "react": ">= 18.2.0", - "react-native": "*", - "react-native-safe-area-context": ">= 4.0.0" - }, - "peerDependenciesMeta": { - "@react-native-masked-view/masked-view": { - "optional": true - } - } + "optional": true, + "os": [ + "openharmony" + ] }, - "node_modules/@react-navigation/native": { - "version": "7.2.5", - "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.2.5.tgz", - "integrity": "sha512-01AAUQiiHQAfTabq+ZyU1/ZWq+AbB/J3v0CB0UTJSON6M6cuadWNsbChzrZUdqQvHrXvg96U5i2PQLJzK3+zpg==", + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@react-navigation/core": "^7.17.5", - "escape-string-regexp": "^4.0.0", - "fast-deep-equal": "^3.1.3", - "nanoid": "^3.3.11", - "use-latest-callback": "^0.2.4" - }, - "peerDependencies": { - "react": ">= 18.2.0", - "react-native": "*" - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@react-navigation/native-stack": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.16.0.tgz", - "integrity": "sha512-wM21rHYR2XifjDnKLrr3HeHUeGsWQZJRwPqEzy1Vp/a9k3ieiwTGpmpDItD/jtERH9qkYESwDPO6oEtrVBEpQg==", + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@react-navigation/elements": "^2.9.19", - "color": "^4.2.3", - "sf-symbols-typescript": "^2.1.0", - "warn-once": "^0.1.1" - }, - "peerDependencies": { - "@react-navigation/native": "^7.2.5", - "react": ">= 18.2.0", - "react-native": "*", - "react-native-safe-area-context": ">= 4.0.0", - "react-native-screens": ">= 4.0.0" - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@react-navigation/routers": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.5.5.tgz", - "integrity": "sha512-9/hhMte12Kgu+pMnLfA4EWJ0OQmIEAMVMX06FPH2yGkEQSQ3JhhCN/GkcRikzQhtEi97VYYQA15umptBUShcOQ==", + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11" - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@react-three/fiber": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.1.tgz", - "integrity": "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==", + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.17.8", - "@types/webxr": "*", - "base64-js": "^1.5.1", - "buffer": "^6.0.3", - "its-fine": "^2.0.0", - "react-use-measure": "^2.1.7", - "scheduler": "^0.27.0", - "suspend-react": "^0.1.3", - "use-sync-external-store": "^1.4.0", - "zustand": "^5.0.3" - }, - "peerDependencies": { - "expo": ">=43.0", - "expo-asset": ">=8.4", - "expo-file-system": ">=11.0", - "expo-gl": ">=11.0", - "react": ">=19 <19.3", - "react-dom": ">=19 <19.3", - "react-native": ">=0.78", - "three": ">=0.156" - }, - "peerDependenciesMeta": { - "expo": { - "optional": true - }, - "expo-asset": { - "optional": true - }, - "expo-file-system": { - "optional": true - }, - "expo-gl": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "react-native": { - "optional": true - } - } + "optional": true, + "os": [ + "win32" + ] }, "node_modules/@rtsao/scc": { "version": "1.1.0", @@ -4065,6 +4859,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -4074,6 +4879,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -4834,27 +5646,142 @@ "win32" ] }, - "node_modules/@urql/core": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@urql/core/-/core-5.2.0.tgz", - "integrity": "sha512-/n0ieD0mvvDnVAXEQgX/7qJiVcvYvNkOHeBvkwtylfjydar123caCXcl58PXFY11oU1oquJocVXHxLAbtv4x1A==", + "node_modules/@urql/core": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@urql/core/-/core-5.2.0.tgz", + "integrity": "sha512-/n0ieD0mvvDnVAXEQgX/7qJiVcvYvNkOHeBvkwtylfjydar123caCXcl58PXFY11oU1oquJocVXHxLAbtv4x1A==", + "license": "MIT", + "dependencies": { + "@0no-co/graphql.web": "^1.0.13", + "wonka": "^6.3.2" + } + }, + "node_modules/@urql/exchange-retry": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@urql/exchange-retry/-/exchange-retry-1.3.2.tgz", + "integrity": "sha512-TQMCz2pFJMfpNxmSfX1VSfTjwUIFx/mL+p1bnfM1xjjdla7Z+KnGMW/EhFbpckp3LyWAH4PgOsMwOMnIN+MBFg==", + "license": "MIT", + "dependencies": { + "@urql/core": "^5.1.2", + "wonka": "^6.3.2" + }, + "peerDependencies": { + "@urql/core": "^5.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, "license": "MIT", "dependencies": { - "@0no-co/graphql.web": "^1.0.13", - "wonka": "^6.3.2" + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@urql/exchange-retry": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@urql/exchange-retry/-/exchange-retry-1.3.2.tgz", - "integrity": "sha512-TQMCz2pFJMfpNxmSfX1VSfTjwUIFx/mL+p1bnfM1xjjdla7Z+KnGMW/EhFbpckp3LyWAH4PgOsMwOMnIN+MBFg==", + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, "license": "MIT", "dependencies": { - "@urql/core": "^5.1.2", - "wonka": "^6.3.2" + "tinyspy": "^4.0.3" }, - "peerDependencies": { - "@urql/core": "^5.0.0" + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, "node_modules/@xmldom/xmldom": { @@ -5223,6 +6150,16 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -5636,6 +6573,16 @@ "node": ">= 0.8" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -5728,6 +6675,23 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5744,6 +6708,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -6144,6 +7118,16 @@ "node": ">=0.10" } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -6315,6 +7299,12 @@ "node": ">= 0.4" } }, + "node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -6490,6 +7480,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -6550,6 +7547,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -6960,6 +7999,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -6988,6 +8037,16 @@ "node": ">=6" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/expo": { "version": "54.0.35", "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.35.tgz", @@ -10573,6 +11632,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -11805,6 +12871,23 @@ "node": "20 || >=22" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -12896,6 +13979,58 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-array-concat": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", @@ -13288,6 +14423,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -13418,6 +14560,13 @@ "node": ">=8" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stackframe": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", @@ -13445,6 +14594,13 @@ "node": ">= 0.6" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -13624,6 +14780,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/structured-headers": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/structured-headers/-/structured-headers-0.4.1.tgz", @@ -13881,6 +15057,20 @@ "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -13897,6 +15087,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -14579,6 +15799,177 @@ } } }, + "node_modules/vite": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/vlq": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", @@ -14782,6 +16173,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wonka": { "version": "6.3.6", "resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.6.tgz", diff --git a/package.json b/package.json index 8873819..c478c71 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "android": "expo start --android", "ios": "expo start --ios", "web": "expo start --web", - "lint": "expo lint" + "lint": "expo lint", + "test": "vitest run", + "typecheck": "tsc --noEmit" }, "dependencies": { "@expo/vector-icons": "^15.0.3", @@ -18,6 +20,7 @@ "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", "@react-three/fiber": "^9.6.1", + "earcut": "^3.0.2", "expo": "~54.0.35", "expo-asset": "~12.0.13", "expo-constants": "~18.0.13", @@ -55,7 +58,8 @@ "eslint-config-expo": "~10.0.0", "postcss": "^8.5.15", "tailwindcss": "^4.3.0", - "typescript": "~5.9.2" + "typescript": "~5.9.2", + "vitest": "^3.2.4" }, "private": true, "overrides": { diff --git a/prompts/01-nativewind.md b/prompts/01-nativewind.md index 0a15f7c..065b43e 100644 --- a/prompts/01-nativewind.md +++ b/prompts/01-nativewind.md @@ -3,4 +3,3 @@ Read AGENTS.md first and follow it strictly. Set up NativeWind in this Expo app by following the NativeWind documentation I provided exactly. Use the installed NativeWind version and apply the required config, globals.css setup, Babel/Metro changes, TypeScript types, and app entry imports as needed. Do not use outdated setup steps or different version docs. ([Here paste the latest Nativewind documentation](https://www.nativewind.dev/v5/getting-started/installation)) - diff --git a/prompts/14-client-side-cache.md b/prompts/14-client-side-cache.md new file mode 100644 index 0000000..ba17da9 --- /dev/null +++ b/prompts/14-client-side-cache.md @@ -0,0 +1,365 @@ +Read AGENTS.md first and follow it strictly. + +Parent: [`08-zustand.md`](./08-zustand.md) · Related: [`12-map-ui.md`](./12-map-ui.md), [`13d-map-v3-ui-ux-upgrade.md`](./13d-map-v3-ui-ux-upgrade.md), `prompts-worldloop/08-redis-caching-layer.md` + +## Client-side cache (AsyncStorage) + +**Goal:** Persist hot API payloads on device so Map and Explore feel instant on cold start, while still refreshing from the backend in the background. Complements server Redis — it does not replace it. + +--- + +## Problem + +Today the app relies on: + +| Layer | Behavior | +| -------------------- | ----------------------------------------------------------------------------------------------------------- | +| **Backend Redis** | Caches `map:countries`, `country:{name}`, `feed:countries`, etc. — only helps after a network round-trip | +| **Zustand (memory)** | `use-map-store` skips refetch until process death; `use-country-feed-store` keeps `regionCache` in RAM only | +| **AsyncStorage** | Used for small UI prefs (map onboarding, saved countries) — not yet used for API payloads | +| **Plain `fetch`** | Every app launch re-downloads map countries and preview country details | + +Users see a loading spinner on Map even when they opened the tab yesterday. Preview cards refetch `GET /country/:name` on every open. + +--- + +## Solution overview + +Use **`@react-native-async-storage/async-storage`** (already in the project) for **API response caching** with TTL and **stale-while-revalidate (SWR)**. + +```txt +Screen / store action + │ + ▼ + await clientCache.get(key) ──► hit + fresh? ──► return (async) + │ miss or stale + ▼ + show cached data if any (stale OK for UI) + │ + ▼ + fetch(API) ──► await clientCache.set(key) ──► update Zustand +``` + +**Design rules** + +1. **AsyncStorage for API payloads** — map list, country detail, optional feed first page. Use a dedicated key prefix (`cache:`) separate from Zustand `persist` keys. +2. **Keep Zustand as source of truth in memory** — AsyncStorage is hydration + offline backup, not a second state manager. +3. **SWR everywhere** — render cached data first, refresh in background, swap when newer data arrives. +4. **Do not cache secrets** — only public backend JSON already returned by `lib/api.ts`. +5. **Async reads** — AsyncStorage is async; hydrate on mount with `await getClientCache` before or alongside network fetch. Accept one brief frame of loading on cold start if cache read is slower than memory. + +--- + +## Prerequisites + +- [`08-zustand.md`](./08-zustand.md) completed — `lib/api.ts`, feed + map stores exist +- [`12-map-ui.md`](./12-map-ui.md) / map v2+ wired — `use-map-store`, `MapCountryPreviewCard` +- Backend running with Redis (optional but recommended for fast background refresh) +- `@react-native-async-storage/async-storage` already installed (used by saved countries, map UI prefs, etc.) + +--- + +## Dependencies + +No new packages required. Reuse the existing AsyncStorage dependency: + +```bash +# Already in package.json — verify only +npm ls @react-native-async-storage/async-storage +``` + +Do **not** add React Query, TanStack Query, axios, or MMKV. One small `lib/client-cache.ts` plus a thin storage wrapper is enough for this teaching project. + +Works in **Expo Go** and dev clients — no native rebuild needed. + +--- + +## Files to add or change + +| Path | Purpose | +| --------------------------------------------- | ------------------------------------------------------------------- | +| `lib/client-storage.ts` | AsyncStorage read/write/delete helpers with shared key prefix | +| `lib/client-cache.ts` | Typed keys, TTL, async `get` / `set` / `remove`, SWR helper | +| `constants/client-cache.ts` | Cache key prefixes + TTL seconds (mirror backend where sensible) | +| `store/use-map-store.ts` | Hydrate from AsyncStorage on load; background refresh | +| `components/map/map-country-preview-card.tsx` | Country detail SWR (cache → fetch) | +| `lib/api.ts` | Optional thin wrappers — prefer cache in stores, not inside `fetch` | +| `store/use-country-feed-store.ts` | _(Optional)_ Persist first feed page + `regionCache` snapshot | + +**Out of scope for AsyncStorage:** parsed GeoJSON boundary polygons — keep a **module-level memo** in `lib/map-country-boundaries.ts` (single parse per app session). The asset is already bundled; duplicating multi-MB JSON into AsyncStorage hurts more than it helps. + +--- + +## `constants/client-cache.ts` + +Define versioned keys and TTLs (seconds): + +```ts +export const CLIENT_CACHE_SCHEMA_VERSION = 1; + +export const CLIENT_CACHE_KEYS = { + schemaVersion: "cache:meta:schemaVersion", + mapCountries: "cache:map:countries", + countryDetail: (name: string) => `cache:country:${name.trim().toLowerCase()}`, + feedFirstPage: "cache:feed:countries:cursor=all", + feedRegion: (region: string) => + `cache:feed:region:${region.trim().toLowerCase()}`, +} as const; + +/** Align with prompts-worldloop TTLs where it matters. */ +export const CLIENT_CACHE_TTL = { + mapCountries: 30 * 24 * 60 * 60, // 30d — match backend map TTL + countryDetail: 7 * 24 * 60 * 60, // 7d — AI + images can change + feedFirstPage: 24 * 60 * 60, // 1d — feed order is shuffled server-side + feedRegion: 7 * 24 * 60 * 60, +} as const; +``` + +Bump `CLIENT_CACHE_SCHEMA_VERSION` and clear keys when `Country` / `MapCountry` shapes change. + +--- + +## `lib/client-storage.ts` + +Thin wrapper around AsyncStorage for cache entries only (do not mix with Zustand `persist` keys): + +```ts +import AsyncStorage from "@react-native-async-storage/async-storage"; + +export async function readCacheString(key: string): Promise { + return AsyncStorage.getItem(key); +} + +export async function writeCacheString( + key: string, + value: string, +): Promise { + await AsyncStorage.setItem(key, value); +} + +export async function deleteCacheKey(key: string): Promise { + await AsyncStorage.removeItem(key); +} + +export async function getAllCacheKeys(): Promise { + const keys = await AsyncStorage.getAllKeys(); + return keys.filter((k) => k.startsWith("cache:")); +} +``` + +Use `getAllCacheKeys()` in `clearAllClientCache()` — do not call `AsyncStorage.clear()` (that would wipe bookmarks and UI prefs). + +--- + +## `lib/client-cache.ts` + +### Envelope type + +Wrap every cached value: + +```ts +type CacheEnvelope = { + v: number; // CLIENT_CACHE_SCHEMA_VERSION + savedAt: number; // Date.now() + ttlSeconds: number; + data: T; +}; +``` + +### API (minimum) + +All methods are **async** because AsyncStorage is async: + +```ts +export async function getClientCache(key: string): Promise<{ + data: T | null; + isFresh: boolean; + isStale: boolean; + savedAt: number | null; +}>; + +export async function setClientCache( + key: string, + data: T, + ttlSeconds: number, +): Promise; + +export async function removeClientCache(key: string): Promise; + +export async function clearAllClientCache(): Promise; // dev helper +``` + +- **`isFresh`** — `savedAt + ttl > now` +- **`isStale`** — envelope exists but TTL expired (still return `data` for SWR UI) +- On read: if `v !== CLIENT_CACHE_SCHEMA_VERSION`, delete key and return miss +- JSON `parse` / `stringify` in try/catch — corrupt entry → delete key + +### SWR helper (teachable, small) + +```ts +export async function staleWhileRevalidate(options: { + key: string; + ttlSeconds: number; + fetcher: () => Promise; + onCached?: (data: T, meta: { isFresh: boolean }) => void; + onFetched?: (data: T) => void; +}): Promise; +``` + +Flow: + +1. `await getClientCache` → if data exists, call `onCached` immediately. +2. If fresh, return without network (optional `force` flag for pull-to-refresh later). +3. If stale or miss, run `fetcher`, `await setClientCache`, `onFetched`, return. + +--- + +## Wire `use-map-store` + +Update `loadMapCountries`: + +1. **Async hydrate** — `await getClientCache(CLIENT_CACHE_KEYS.mapCountries)`. + - If data exists: `set({ countries: withValidCoordinates(data), status: "idle", mapCountriesFullyLoaded: true })` before or while the network refresh runs. +2. **Background refresh** — call `staleWhileRevalidate`: + - `fetcher`: `() => fetchMapCountries().then((r) => r.data)` + - `onCached`: hydrate store from disk (same as step 1 if not already applied) + - `onFetched`: update `countries` + `mapCountriesFullyLoaded` + - If offline and only stale cache exists, keep showing cache; set `error` only when miss + network fails +3. Keep `mapCountriesLoadPromise` deduping so two tabs don’t double-fetch. + +**Do not** persist the entire Zustand map slice via `persist` middleware — only the API list goes through `client-cache.ts` (explicit TTL). Keep Zustand `persist` keys (`saved-countries`, `map-ui`, etc.) separate from `cache:*` keys. + +--- + +## Wire `MapCountryPreviewCard` + +Replace the bare `useEffect` + `fetchCountryByName` with SWR: + +1. On `country.name` change, `await` read `CLIENT_CACHE_KEYS.countryDetail(name)`. +2. If hit → `setDetail(cached)` immediately, `detailStatus: "idle"`. +3. Run `staleWhileRevalidate` with `fetchCountryByName` as fetcher. +4. While revalidating stale data, show existing fact (no full-card spinner). Show spinner only when **no** cached detail exists. + +Cancel in-flight updates on unmount / name change (keep existing `cancelled` flag pattern). + +--- + +## Optional: feed store + +Lower priority than Map; implement if time allows: + +| Key | When to write | When to read | +| --------------------- | -------------------------------------- | ------------------------------------ | +| `feedFirstPage` | After successful `loadInitialFeed` | Before first fetch on cold start | +| `feedRegion:{region}` | After `ensureRegionCountries` resolves | Before `fetchExploreRegionCountries` | + +Keep in-memory `regionCache` as today — AsyncStorage repopulates it on launch so Explore region chips don’t flash empty. + +**Do not** persist paginated tail (`loadMoreFeed`) — unbounded growth. + +--- + +## Boundary polygons (memory only) + +In `lib/map-country-boundaries.ts` add: + +```ts +let parsedCountryBoundaries: CountryBoundaryPolygon[] | null = null; + +export function getCountryBoundaryPolygons( + geoJson: GeoJsonFeatureCollection, +): CountryBoundaryPolygon[] { + if (!parsedCountryBoundaries) { + parsedCountryBoundaries = parseCountryBoundaryPolygons(geoJson); + } + return parsedCountryBoundaries; +} +``` + +Replace duplicate `useMemo(() => parseCountryBoundaryPolygons(...), [])` in map/globe components with this helper. **Not** AsyncStorage. + +--- + +## Invalidation & dev tools + +| Trigger | Action | +| -------------------- | ---------------------------------------------------------------------------------- | +| Schema version bump | `await clearAllClientCache()` on app start (once) or migration in `getClientCache` | +| User logout (future) | Clear country + feed caches; keep map UI prefs | +| Dev menu / settings | “Clear local cache” button calling `clearAllClientCache()` | + +Log in `__DEV__` only: + +```ts +console.log("[client-cache]", { key, hit: !!data, isFresh, isStale }); +``` + +--- + +## Out of scope + +- Caching image binary data in AsyncStorage (continue `expo-image` `cachePolicy="memory-disk"`) +- Replacing backend Redis or changing TTLs server-side +- Caching AI generation client-side beyond what `GET /country/:name` already returns +- Search results persistence (ephemeral; optional later) +- Full offline mode / queue writes +- MMKV or other native-only storage (keep this prompt AsyncStorage-only for simplicity) + +--- + +## Acceptance criteria + +- [ ] No new dependencies; `@react-native-async-storage/async-storage` used via `lib/client-storage.ts` +- [ ] Map tab shows countries **immediately** on second launch (minimal or no spinner) when cache exists, then silently refreshes +- [ ] Airplane mode after one successful load: map still shows last cached countries; error only if never cached +- [ ] Preview card shows cached fun fact instantly on re-open; network refresh updates text when backend changed +- [ ] `npm run lint` and `npm run typecheck` pass +- [ ] Web build works — AsyncStorage is supported on web in this stack +- [ ] No API keys or secrets in AsyncStorage cache keys +- [ ] `clearAllClientCache` only removes `cache:*` keys — bookmarks and UI prefs survive +- [ ] `clearAllClientCache` documented for students (dev button or comment in `lib/client-cache.ts`) + +--- + +## Testing + +```bash +# Terminal 1 — repo root +docker compose up -d redis + +# Terminal 2 +cd backend && npm run dev + +# Terminal 3 — Expo Go or dev client +npx expo start +``` + +1. Open Map — wait for countries to load (network). +2. Kill app fully; relaunch — countries should appear quickly from AsyncStorage (watch `[client-cache]` logs). +3. Open a country preview — dismiss — reopen same country; fact should appear instantly. +4. Toggle airplane mode — Map still shows cached list; preview uses cached detail if available. +5. Call `await clearAllClientCache()` — next launch shows loading again until fetch completes. +6. Confirm saved countries and map UI prefs still exist after clearing API cache. + +--- + +## Architecture note (teaching) + +| Store | Server cache (Redis) | Client cache (AsyncStorage) | +| -------------- | ------------------------------------ | -------------------------------------------- | +| Map countries | `map:countries` | `cache:map:countries` | +| Country detail | `country:{name}` + `ai:` + `images:` | `cache:country:{name}` | +| Feed page | `feed:countries:{cursor}` | `cache:feed:countries:cursor=all` (optional) | + +Redis avoids repeated **origin** work; AsyncStorage avoids repeated **network** work. Together: fast server + faster UI on relaunch. + +**Trade-off (teaching moment):** AsyncStorage is slower than MMKV for large JSON and reads are async. For this project’s payload sizes (map list + country detail), that is acceptable and keeps the stack simpler — one storage library students already use for Zustand `persist`. + +--- + +## Next steps + +- Pull-to-refresh on Map calling `loadMapCountries({ force: true })` bypassing fresh TTL +- Prefetch top N country details after map hydrate (background, low priority) +- EAS Update hook to bump `CLIENT_CACHE_SCHEMA_VERSION` when API shapes ship OTA +- _(Optional stretch)_ Migrate to MMKV if cache size or sync reads become a bottleneck diff --git a/store/use-country-feed-store.ts b/store/use-country-feed-store.ts index 31020df..777a495 100644 --- a/store/use-country-feed-store.ts +++ b/store/use-country-feed-store.ts @@ -1,11 +1,17 @@ import { create } from "zustand"; +import { CLIENT_CACHE_KEYS, CLIENT_CACHE_TTL } from "@/constants/client-cache"; import { CONTINENTS } from "@/constants/regions"; +import { fetchFeedCountries } from "@/lib/api"; import { filterCountriesForExploreRegion, normalizeCountriesRegions, } from "@/lib/app-region"; -import { fetchFeedCountries } from "@/lib/api"; +import { + getClientCache, + setClientCache, + staleWhileRevalidate, +} from "@/lib/client-cache"; import { fetchExploreRegionCountries } from "@/lib/explore-region-countries"; import { prefetchFeedHeroImages } from "@/lib/prefetch-feed-heroes"; import type { Country } from "@/types/country"; @@ -135,16 +141,41 @@ async function ensureRegionCountries(region: string): Promise { const inFlight = regionFetchPromises.get(region); if (inFlight) return inFlight; - const promise = fetchExploreRegionCountries(region) - .then((countries) => { + const promise = (async () => { + const cacheKey = CLIENT_CACHE_KEYS.feedRegion(region); + const diskCache = await getClientCache(cacheKey); + + if (diskCache.data) { useCountryFeedStore.setState((state) => ({ - regionCache: { ...state.regionCache, [region]: countries }, + regionCache: { ...state.regionCache, [region]: diskCache.data! }, })); - return countries; - }) - .finally(() => { - regionFetchPromises.delete(region); - }); + const filtered = filterCountriesForExploreRegion(diskCache.data, region); + if (filtered.length > 0) { + void staleWhileRevalidate({ + key: cacheKey, + ttlSeconds: CLIENT_CACHE_TTL.feedRegion, + fetcher: () => fetchExploreRegionCountries(region), + onFetched: (countries) => { + useCountryFeedStore.setState((state) => ({ + regionCache: { ...state.regionCache, [region]: countries }, + })); + }, + }).catch(() => { + // Background revalidate — keep showing cached region list. + }); + return filtered; + } + } + + const countries = await fetchExploreRegionCountries(region); + await setClientCache(cacheKey, countries, CLIENT_CACHE_TTL.feedRegion); + useCountryFeedStore.setState((state) => ({ + regionCache: { ...state.regionCache, [region]: countries }, + })); + return countries; + })().finally(() => { + regionFetchPromises.delete(region); + }); regionFetchPromises.set(region, promise); return promise; @@ -189,6 +220,35 @@ export const useCountryFeedStore = create((set, get) => ({ if (!options?.force && get().countries.length > 0) return; if (!options?.force && isLoading(get().status)) return; + const cacheKey = CLIENT_CACHE_KEYS.feedFirstPage; + let hydratedFromDisk = false; + + if (!options?.force && get().countries.length === 0) { + const diskCache = await getClientCache<{ + countries: Country[]; + nextCursor: string | null; + }>(cacheKey); + + if (diskCache.data) { + hydratedFromDisk = true; + const normalized = normalizeCountriesRegions(diskCache.data.countries); + const { sortField, sortOrder } = get(); + const feedTail = sortCountries(normalized, sortField, sortOrder); + set({ + countries: feedTail, + nextCursor: diskCache.data.nextCursor, + currentIndex: 0, + selectedRegion: null, + forYouSnapshot: { + countries: feedTail, + nextCursor: diskCache.data.nextCursor, + }, + status: "idle", + error: null, + }); + } + } + const showBlockingLoad = get().countries.length === 0; if (showBlockingLoad) { set({ status: "loading", error: null }); @@ -196,11 +256,42 @@ export const useCountryFeedStore = create((set, get) => ({ try { const prior = get(); - const { data, nextCursor } = await fetchFeedCountries(undefined, limit); - const normalized = normalizeCountriesRegions(data); - await prefetchFeedHeroImages(normalized); + const payload = await staleWhileRevalidate({ + key: cacheKey, + ttlSeconds: CLIENT_CACHE_TTL.feedFirstPage, + force: options?.force, + fetcher: async () => { + const { data, nextCursor } = await fetchFeedCountries( + undefined, + limit, + ); + return { + countries: normalizeCountriesRegions(data), + nextCursor, + }; + }, + onCached: (data) => { + if (hydratedFromDisk || get().countries.length > 0) return; + const { sortField, sortOrder } = get(); + const feedTail = sortCountries(data.countries, sortField, sortOrder); + set({ + countries: feedTail, + nextCursor: data.nextCursor, + currentIndex: 0, + selectedRegion: null, + forYouSnapshot: { + countries: feedTail, + nextCursor: data.nextCursor, + }, + status: "idle", + error: null, + }); + }, + }); + + await prefetchFeedHeroImages(payload.countries); const { sortField, sortOrder } = get(); - const feedTail = sortCountries(normalized, sortField, sortOrder); + const feedTail = sortCountries(payload.countries, sortField, sortOrder); const { countries, currentIndex } = mergeFetchedWithFocusedCountry( feedTail, prior.countries, @@ -210,21 +301,23 @@ export const useCountryFeedStore = create((set, get) => ({ ); set({ countries, - nextCursor, + nextCursor: payload.nextCursor, currentIndex, selectedRegion: null, - forYouSnapshot: { countries: feedTail, nextCursor }, + forYouSnapshot: { countries: feedTail, nextCursor: payload.nextCursor }, status: "idle", error: null, }); void prefetchFeedHeroImages(countries.slice(1, 3)); prefetchRegionsSequentially(null); } catch (err) { - set({ - status: "error", - error: - err instanceof Error ? err.message : "Failed to load country feed", - }); + if (get().countries.length === 0) { + set({ + status: "error", + error: + err instanceof Error ? err.message : "Failed to load country feed", + }); + } } }, @@ -361,11 +454,7 @@ export const useCountryFeedStore = create((set, get) => ({ const nextCountries = (state.sortOrder ?? DEFAULT_FEED_SORT_ORDER) === "random" ? appended - : sortCountries( - appended, - state.sortField, - state.sortOrder, - ); + : sortCountries(appended, state.sortField, state.sortOrder); const snapshotTail = state.forYouSnapshot ? sortCountries( [...state.forYouSnapshot.countries, ...uniqueNew], @@ -399,25 +488,55 @@ export const useCountryFeedStore = create((set, get) => ({ }, setSort: (field, order) => { - set((state) => ({ - sortField: field, - sortOrder: order, - countries: sortCountries(state.countries, field, order), - currentIndex: 0, - })); + set((state) => { + const sortedCountries = sortCountries(state.countries, field, order); + return { + sortField: field, + sortOrder: order, + countries: sortedCountries, + currentIndex: 0, + ...(state.forYouSnapshot + ? { + forYouSnapshot: { + ...state.forYouSnapshot, + countries: sortCountries( + state.forYouSnapshot.countries, + field, + order, + ), + }, + } + : {}), + }; + }); }, clearSort: () => { - set((state) => ({ - sortField: DEFAULT_FEED_SORT_FIELD, - sortOrder: DEFAULT_FEED_SORT_ORDER, - countries: sortCountries( + set((state) => { + const sortedCountries = sortCountries( state.countries, DEFAULT_FEED_SORT_FIELD, DEFAULT_FEED_SORT_ORDER, - ), - currentIndex: 0, - })); + ); + return { + sortField: DEFAULT_FEED_SORT_FIELD, + sortOrder: DEFAULT_FEED_SORT_ORDER, + countries: sortedCountries, + currentIndex: 0, + ...(state.forYouSnapshot + ? { + forYouSnapshot: { + ...state.forYouSnapshot, + countries: sortCountries( + state.forYouSnapshot.countries, + DEFAULT_FEED_SORT_FIELD, + DEFAULT_FEED_SORT_ORDER, + ), + }, + } + : {}), + }; + }); }, setCurrentIndex: (index: number) => { @@ -445,7 +564,9 @@ export const useCountryFeedStore = create((set, get) => ({ return; } - const tail = forYouSnapshot.countries.filter((c) => c.name !== country.name); + const tail = forYouSnapshot.countries.filter( + (c) => c.name !== country.name, + ); set({ countries: [country, ...tail], diff --git a/store/use-map-store.ts b/store/use-map-store.ts index 9ff7d27..267038d 100644 --- a/store/use-map-store.ts +++ b/store/use-map-store.ts @@ -1,8 +1,13 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; -import { normalizeCountryRegion } from "@/lib/app-region"; +import { CLIENT_CACHE_KEYS, CLIENT_CACHE_TTL } from "@/constants/client-cache"; import { fetchMapCountries } from "@/lib/api"; +import { normalizeCountryRegion } from "@/lib/app-region"; +import { getClientCache, staleWhileRevalidate } from "@/lib/client-cache"; import { countryToMapCountry, isValidLatLng } from "@/lib/map-country"; +import { prefetchMapCountryDetails } from "@/lib/prefetch-country-details"; import { useIdentityStore, type SelectionSource, @@ -44,7 +49,7 @@ type MapState = { activeChip: MapFilterChip; mapMode: MapMode; globeCamera: GlobeCameraHandle | null; - loadMapCountries: () => Promise; + loadMapCountries: (options?: { force?: boolean }) => Promise; focusCountryFromExternal: ( name: string, fallback?: Country, @@ -94,122 +99,184 @@ export function filterMapCountriesByChip( return countries.filter((c) => topNames.has(c.name)); } -export const useMapStore = create((set, get) => ({ - countries: [], - status: "idle", - error: null, - mapCountriesFullyLoaded: false, - pendingMapIntent: null, - activeChip: "all", - mapMode: "2d", - globeCamera: null, - - loadMapCountries: async () => { - if (get().mapCountriesFullyLoaded) { - return; - } - if (mapCountriesLoadPromise) { - return mapCountriesLoadPromise; - } - - mapCountriesLoadPromise = (async () => { - set({ status: "loading", error: null }); - - try { - const { data } = await fetchMapCountries(); - const nextCountries = withValidCoordinates(data); +function normalizeMapMode(value: unknown): MapMode { + return value === "3d" ? "3d" : "2d"; +} + +export const useMapStore = create()( + persist( + (set, get) => ({ + countries: [], + status: "idle", + error: null, + mapCountriesFullyLoaded: false, + pendingMapIntent: null, + activeChip: "all", + mapMode: "2d", + globeCamera: null, + + loadMapCountries: async (options) => { + const force = options?.force ?? false; + + if (!force && get().mapCountriesFullyLoaded) { + return; + } + if (mapCountriesLoadPromise) { + return mapCountriesLoadPromise; + } + + mapCountriesLoadPromise = (async () => { + const cacheKey = CLIENT_CACHE_KEYS.mapCountries; + const diskCache = await getClientCache(cacheKey); + + if (diskCache.data) { + set({ + countries: withValidCoordinates(diskCache.data), + status: "idle", + error: null, + mapCountriesFullyLoaded: true, + }); + } else { + set({ status: "loading", error: null }); + } + + try { + await staleWhileRevalidate({ + key: cacheKey, + ttlSeconds: CLIENT_CACHE_TTL.mapCountries, + force, + fetcher: () => + fetchMapCountries().then((response) => response.data), + onCached: (data) => { + if (!diskCache.data) { + set({ + countries: withValidCoordinates(data), + status: "idle", + error: null, + mapCountriesFullyLoaded: true, + }); + } + }, + onFetched: (data) => { + set({ + countries: withValidCoordinates(data), + status: "idle", + error: null, + mapCountriesFullyLoaded: true, + }); + }, + }); + } catch (err) { + if (get().countries.length === 0) { + const message = + err instanceof Error + ? err.message + : "Failed to load map countries"; + set({ status: "error", error: message }); + } + } + + const countries = get().countries; + if (countries.length > 0) { + void prefetchMapCountryDetails(countries); + } + })().finally(() => { + mapCountriesLoadPromise = null; + }); + + return mapCountriesLoadPromise; + }, + + focusCountryFromExternal: (name, fallback, source = "search") => { + const trimmed = name.trim(); + if (!trimmed) return; + + let country = resolveMapCountry(get().countries, trimmed); + let countries = get().countries; + + if (!country && fallback) { + const injected = countryToMapCountry(fallback); + country = injected; + if (!countries.some((c) => c.name === injected.name)) { + countries = [...countries, injected]; + } + } + set({ - countries: nextCountries, - status: "idle", - error: null, - mapCountriesFullyLoaded: true, + countries, + pendingMapIntent: { + countryName: trimmed, + mode: "focus", + source, + }, }); - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to load map countries"; - set({ status: "error", error: message }); - } finally { - mapCountriesLoadPromise = null; - } - })(); - - return mapCountriesLoadPromise; - }, - - focusCountryFromExternal: (name, fallback, source = "search") => { - const trimmed = name.trim(); - if (!trimmed) return; - - let country = resolveMapCountry(get().countries, trimmed); - let countries = get().countries; - - if (!country && fallback) { - const injected = countryToMapCountry(fallback); - country = injected; - if (!countries.some((c) => c.name === injected.name)) { - countries = [...countries, injected]; - } - } - - set({ - countries, - pendingMapIntent: { - countryName: trimmed, - mode: "focus", - source, }, - }); - }, - - clearPendingMapIntent: () => set({ pendingMapIntent: null }), - - selectRandomCountry: () => { - const visible = get().getVisibleCountries(); - if (visible.length === 0) return null; - - const pick = visible[Math.floor(Math.random() * visible.length)] ?? null; - if (pick) { - set({ - pendingMapIntent: { - countryName: pick.name, - mode: "focus", - source: "shuffle", - }, - }); - } - return pick; - }, - - setActiveChip: (chip) => set({ activeChip: chip }), - - setMapMode: (mode) => set({ mapMode: mode }), - - toggleMapMode: () => - set((state) => ({ - mapMode: state.mapMode === "3d" ? "2d" : "3d", - })), - - registerGlobeCamera: (handle) => { - set({ globeCamera: handle }); - }, - - focusCountryOnGlobe: (name, duration) => { - const country = - resolveMapCountry(get().countries, name) ?? - (useIdentityStore.getState().activeCountry?.name === name - ? useIdentityStore.getState().activeCountry - : null); - if (!country) return; - get().globeCamera?.focusCountry(country, duration); - }, - - focusLatLngOnGlobe: (lat, lng, duration, targetDistance) => { - if (!Number.isFinite(lat) || !Number.isFinite(lng)) return; - get().globeCamera?.focusLatLng(lat, lng, duration, targetDistance); - }, - - getVisibleCountries: () => { - const { countries, activeChip } = get(); - return filterMapCountriesByChip(countries, activeChip); - }, -})); + + clearPendingMapIntent: () => set({ pendingMapIntent: null }), + + selectRandomCountry: () => { + const visible = get().getVisibleCountries(); + if (visible.length === 0) return null; + + const pick = + visible[Math.floor(Math.random() * visible.length)] ?? null; + if (pick) { + set({ + pendingMapIntent: { + countryName: pick.name, + mode: "focus", + source: "shuffle", + }, + }); + } + return pick; + }, + + setActiveChip: (chip) => set({ activeChip: chip }), + + setMapMode: (mode) => set({ mapMode: mode }), + + toggleMapMode: () => + set((state) => ({ + mapMode: state.mapMode === "3d" ? "2d" : "3d", + })), + + registerGlobeCamera: (handle) => { + set({ globeCamera: handle }); + }, + + focusCountryOnGlobe: (name, duration) => { + const country = + resolveMapCountry(get().countries, name) ?? + (useIdentityStore.getState().activeCountry?.name === name + ? useIdentityStore.getState().activeCountry + : null); + if (!country) return; + get().globeCamera?.focusCountry(country, duration); + }, + + focusLatLngOnGlobe: (lat, lng, duration, targetDistance) => { + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return; + get().globeCamera?.focusLatLng(lat, lng, duration, targetDistance); + }, + + getVisibleCountries: () => { + const { countries, activeChip } = get(); + return filterMapCountriesByChip(countries, activeChip); + }, + }), + { + name: "worldloop-map", + version: 1, + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ mapMode: state.mapMode }), + merge: (persistedState, currentState) => { + const persisted = persistedState as Partial | undefined; + return { + ...currentState, + mapMode: normalizeMapMode(persisted?.mapMode), + }; + }, + }, + ), +); diff --git a/store/use-map-ui-store.ts b/store/use-map-ui-store.ts index 9412a55..fe32a78 100644 --- a/store/use-map-ui-store.ts +++ b/store/use-map-ui-store.ts @@ -4,6 +4,8 @@ import { createJSONStorage, persist } from "zustand/middleware"; import { DEFAULT_MAP_BOUNDARY_STYLE, + migrateLegacyCountryHighlightColorToAmber, + migrateLegacyFillColorToAmber, normalizeBoundaryStyle, type MapBoundaryStyleSettings, } from "@/constants/map-boundary-style"; @@ -112,7 +114,9 @@ export const useMapUiStore = create()( setCountryMarkerMode: (mode) => set({ countryMarkerMode: mode }), cycleCountryMarkerMode: () => set((state) => ({ - countryMarkerMode: nextCountryMarkerDisplayMode(state.countryMarkerMode), + countryMarkerMode: nextCountryMarkerDisplayMode( + state.countryMarkerMode, + ), })), setShowBoundaryLines: (show) => set({ showBoundaryLines: show }), setBoundaryStyle: (style) => @@ -132,7 +136,7 @@ export const useMapUiStore = create()( }), { name: "worldloop-map-ui", - version: 2, + version: 4, storage: createJSONStorage(() => AsyncStorage), partialize: (state) => ({ hasSeenMapOnboarding: state.hasSeenMapOnboarding, @@ -141,7 +145,7 @@ export const useMapUiStore = create()( showBoundaryLines: state.showBoundaryLines, boundaryStyle: state.boundaryStyle, }), - migrate: (persistedState) => { + migrate: (persistedState, version) => { const persisted = persistedState as | (Partial & { showCountryFlags?: boolean }) | undefined; @@ -151,9 +155,21 @@ export const useMapUiStore = create()( focusedRegion: _focusedRegion, featuredShortcut: _featuredShortcut, showCountryFlags, + boundaryStyle: persistedBoundaryStyle, ...settings } = persisted; - return settings; + + let boundaryStyle = normalizeBoundaryStyle(persistedBoundaryStyle); + if (version < 3) { + boundaryStyle = migrateLegacyFillColorToAmber(boundaryStyle); + } + if (version < 4) { + boundaryStyle = normalizeBoundaryStyle(boundaryStyle); + } + boundaryStyle = + migrateLegacyCountryHighlightColorToAmber(boundaryStyle); + + return { ...settings, boundaryStyle }; }, merge: (persistedState, currentState) => { const persisted = persistedState as @@ -174,10 +190,13 @@ export const useMapUiStore = create()( persisted?.countryMarkerMode, persisted?.showCountryFlags, ), - boundaryStyle: normalizeBoundaryStyle(persisted?.boundaryStyle), + boundaryStyle: migrateLegacyCountryHighlightColorToAmber( + migrateLegacyFillColorToAmber( + normalizeBoundaryStyle(persisted?.boundaryStyle), + ), + ), }; }, }, ), ); - diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..73d4427 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["lib/**/*.test.ts"], + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "."), + }, + }, +});