import { Animated as Animation, Dimensions, Linking, Platform, Text, TextInput, TouchableOpacity, View } from 'react-native'; import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import MapView, { Geojson, Marker, UrlTile } from 'react-native-maps'; import * as turf from '@turf/turf'; import * as FileSystem from 'expo-file-system'; import * as Location from 'expo-location'; import { storage, StoreType } from '../../../storage'; import MenuIcon from '../../../../assets/icons/menu.svg'; import SearchIcon from '../../../../assets/icons/search.svg'; import LocationIcon from '../../../../assets/icons/location.svg'; import CloseSvg from '../../../../assets/icons/close.svg'; import FilterIcon from 'assets/icons/filter.svg'; import regions from '../../../../assets/geojson/nm2022.json'; import jsonData, { fetchJsonData } from '../../../database/geojsonService'; import { getFirstDatabase, getSecondDatabase, refreshDatabases } from '../../../db'; import { LocationPopup, WarningModal, EditNmModal, Button } from '../../../components'; import { styles } from './style'; import { calculateMapRegion, filterCandidates, filterCandidatesMarkers, findRegionInDataset, processMarkerData } from '../../../utils/mapHelpers'; import { getData } from '../../../modules/map/regionData'; import { fetchSeriesData, usePostSetToggleItem } from '@api/series'; import MarkerItem from './MarkerItem'; import ClusterItem from './ClusterItem'; import { FeatureCollection, ItemSeries, MapScreenProps, Region, Series } from '../../../types/map'; import { API_HOST, FASTEST_MAP_HOST } from 'src/constants'; import { useConnection } from 'src/contexts/ConnectionContext'; import ClusteredMapView from 'react-native-map-clustering'; import { fetchUserData, fetchUserDataDare } from '@api/regions'; import RegionPopup from 'src/components/RegionPopup'; import { usePostSetNmRegionMutation } from '@api/myRegions'; import { usePostSetDareRegionMutation } from '@api/myDARE'; import moment from 'moment'; import { qualityOptions } from '../TravelsScreen/utils/constants'; import Animated, { Easing } from 'react-native-reanimated'; import { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated'; import { Colors } from 'src/theme'; import { useGetUniversalSearch } from '@api/search'; import SearchModal from './UniversalSearch'; import FilterModal from './FilterModal'; import InfoIcon from 'assets/icons/info-solid.svg'; import { NAVIGATION_PAGES } from 'src/types'; import { useRegion } from 'src/contexts/RegionContext'; import { useFocusEffect } from '@react-navigation/native'; const localTileDir = `${FileSystem.cacheDirectory}tiles/background`; const localGridDir = `${FileSystem.cacheDirectory}tiles/grid`; const localVisitedDir = `${FileSystem.cacheDirectory}tiles/user_visited`; const localDareDir = `${FileSystem.cacheDirectory}tiles/regions_mqp`; const AnimatedMarker = Animation.createAnimatedComponent(Marker); const MapScreen: React.FC = ({ navigation }) => { const [dareData, setDareData] = useState(jsonData); const tilesBaseURL = `${FASTEST_MAP_HOST}/tiles_osm`; const gridUrl = `${FASTEST_MAP_HOST}/tiles_nm/grid`; const dareTiles = `${FASTEST_MAP_HOST}/tiles_nm/regions_mqp`; const userId = storage.get('uid', StoreType.STRING); const token = storage.get('token', StoreType.STRING) as string; const netInfo = useConnection(); const { mutateAsync } = fetchSeriesData(); const { mutateAsync: mutateUserData } = fetchUserData(); const { mutateAsync: mutateUserDataDare } = fetchUserDataDare(); const { mutate: updateSeriesItem } = usePostSetToggleItem(); const visitedTiles = `${FASTEST_MAP_HOST}/tiles_nm/user_visited/${userId}`; const visitedUNTiles = `${FASTEST_MAP_HOST}/tiles_nm/user_visited_un/${userId}`; const mapRef = useRef(null); const [isConnected, setIsConnected] = useState(true); const [selectedRegion, setSelectedRegion] = useState(null); const [regionPopupVisible, setRegionPopupVisible] = useState(false); const [regionData, setRegionData] = useState(null); const [userAvatars, setUserAvatars] = useState([]); const [location, setLocation] = useState(null); const [askLocationVisible, setAskLocationVisible] = useState(false); const [openSettingsVisible, setOpenSettingsVisible] = useState(false); const [isWarningModalVisible, setIsWarningModalVisible] = useState(false); const [markers, setMarkers] = useState([]); const [series, setSeries] = useState(null); const [processedMarkers, setProcessedMarkers] = useState([]); const [zoomLevel, setZoomLevel] = useState(0); const [isEditModalVisible, setIsEditModalVisible] = useState(false); const [modalState, setModalState] = useState({ selectedFirstYear: 2021, selectedLastYear: 2021, selectedQuality: qualityOptions[2], selectedNoOfVisits: 1, years: [], id: null }); const [search, setSearch] = useState(''); const [searchInput, setSearchInput] = useState(''); const { data: searchData } = useGetUniversalSearch(search); const [isFilterVisible, setIsFilterVisible] = useState(false); const [tilesType, setTilesType] = useState({ label: 'NM regions', value: 0 }); const tilesTypes = [ { label: 'NM regions', value: 0 }, { label: 'UN countries', value: 1 } ]; const [type, setType] = useState(0); const { handleUpdateNM, handleUpdateDare, userData, setUserData } = useRegion(); useFocusEffect( useCallback(() => { const updateMarkers = async () => { await mutateAsync( { regions: JSON.stringify([regionData?.id]), token: String(token) }, { onSuccess: (data) => { setSeries(data.series); const allMarkers = data.items.map(processMarkerData); setProcessedMarkers(allMarkers); setMarkers(allMarkers); } } ); }; if (userData && userData?.type === 'nm') { updateMarkers(); } }, [userData]) ); useEffect(() => { if (!dareData) { const fetchData = async () => { const fetchedData = await fetchJsonData(); setDareData(fetchedData); }; fetchData(); } }, [dareData]); const handleModalStateChange = (updates: { [key: string]: any }) => { setModalState((prevState) => ({ ...prevState, ...updates })); }; const handleOpenEditModal = () => { handleModalStateChange({ selectedFirstYear: userData?.first_visit_year, selectedLastYear: userData?.last_visit_year, selectedQuality: qualityOptions.find((quality) => quality.id === userData?.best_visit_quality) || qualityOptions[2], selectedNoOfVisits: userData?.no_of_visits || 1, id: regionData?.id }); setIsEditModalVisible(true); }; useEffect(() => { const currentYear = moment().year(); let yearSelector: { label: string; value: number }[] = [{ label: 'visited', value: 1 }]; for (let i = currentYear; i >= 1951; i--) { yearSelector.push({ label: i.toString(), value: i }); } handleModalStateChange({ years: yearSelector }); }, []); const cancelTokenRef = useRef(false); const currentTokenRef = useRef(0); const strokeWidthAnim = useRef(new Animation.Value(2)).current; const [isExpanded, setIsExpanded] = useState(false); const [searchVisible, setSearchVisible] = useState(false); const [index, setIndex] = useState(0); const width = useSharedValue(48); const usableWidth = Dimensions.get('window').width - 32; useEffect(() => { if (netInfo?.isInternetReachable) { setIsConnected(true); } else { setIsConnected(false); } }, [netInfo?.isInternetReachable]); useEffect(() => { Animation.loop( Animation.sequence([ Animation.timing(strokeWidthAnim, { toValue: 3, duration: 700, useNativeDriver: false }), Animation.timing(strokeWidthAnim, { toValue: 2, duration: 700, useNativeDriver: false }) ]) ).start(); }, [strokeWidthAnim]); useEffect(() => { navigation.addListener('state', (state) => { navigation .getParent() ?.getParent() ?.setOptions({ tabBarStyle: { display: (state.data.state.history[1] as { status?: string })?.status === 'open' || regionPopupVisible ? 'none' : 'flex', position: 'absolute', ...Platform.select({ android: { height: 58 } }) } }); }); navigation .getParent() ?.getParent() ?.setOptions({ tabBarStyle: { display: regionPopupVisible ? 'none' : 'flex', position: 'absolute', ...Platform.select({ android: { height: 58 } }) } }); }, [regionPopupVisible, navigation]); useEffect(() => { (async () => { let { status } = await Location.getForegroundPermissionsAsync(); if (status !== 'granted') { return; } let currentLocation = await Location.getCurrentPositionAsync({}); setLocation(currentLocation.coords); })(); }, []); const findFeaturesInVisibleMapArea = async (visibleMapArea: { latitude?: any; longitude?: any; latitudeDelta: any; longitudeDelta?: any; }) => { if (!isConnected) return; const currentZoom = Math.log2(360 / visibleMapArea.latitudeDelta); setZoomLevel(currentZoom); if (cancelTokenRef.current) { setMarkers(processedMarkers); return; } const thisToken = ++currentTokenRef.current; if (!regions || !dareData) return; if (currentZoom < 7) { setMarkers([]); return; } const { latitude, longitude, latitudeDelta, longitudeDelta } = visibleMapArea; const bbox: turf.BBox = [ longitude - longitudeDelta / 2, latitude - latitudeDelta / 2, longitude + longitudeDelta / 2, latitude + latitudeDelta / 2 ]; const visibleAreaPolygon = turf.bboxPolygon(bbox); const regionsFound = filterCandidates(regions, bbox); // const daresFound = filterCandidates(dareRegions, bbox); const regionIds = regionsFound.map( (region: { properties: { id: any } }) => region.properties.id ); isConnected && (await mutateAsync( { regions: JSON.stringify(regionIds), token: String(token) }, { onSuccess: (data) => { if (thisToken !== currentTokenRef.current) return; setSeries(data.series); const markersVisible = filterCandidatesMarkers(data.items, visibleAreaPolygon); const allMarkers = markersVisible.map(processMarkerData); setMarkers(allMarkers); } } )); }; const handleGetLocation = async () => { let { status, canAskAgain } = await Location.getForegroundPermissionsAsync(); if (status === 'granted') { getLocation(); } else if (!canAskAgain) { setOpenSettingsVisible(true); } else { setAskLocationVisible(true); } }; const getLocation = async () => { let currentLocation = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced }); setLocation(currentLocation.coords); mapRef.current?.animateToRegion( { latitude: currentLocation.coords.latitude, longitude: currentLocation.coords.longitude, latitudeDelta: 5, longitudeDelta: 5 }, 800 ); handleClosePopup(); }; const handleAcceptPermission = async () => { setAskLocationVisible(false); let { status, canAskAgain } = await Location.requestForegroundPermissionsAsync(); if (status === 'granted') { getLocation(); } else if (!canAskAgain) { setOpenSettingsVisible(true); } }; const handleRegionData = (regionData: Region, avatars: string[]) => { setRegionData(regionData); setUserAvatars(avatars); }; const handleMapPress = async (event: { nativeEvent: { coordinate: { latitude: any; longitude: any }; action?: string }; }) => { if (event.nativeEvent?.action === 'marker-press') return; cancelTokenRef.current = true; const { latitude, longitude } = event.nativeEvent.coordinate; const point = turf.point([longitude, latitude]); let db = getSecondDatabase(); let tableName = 'places'; let foundRegion = findRegionInDataset(dareData, point); if (!foundRegion) { foundRegion = findRegionInDataset(regions, point); db = getFirstDatabase(); tableName = 'regions'; } if (foundRegion) { if (foundRegion.properties?.id === regionData?.id) return; const id = foundRegion.properties?.id; setSelectedRegion({ type: 'FeatureCollection', features: [ { geometry: foundRegion.geometry, properties: { ...foundRegion.properties, fill: 'rgba(57, 115, 172, 0.2)', stroke: '#3973AC' }, type: 'Feature' } ] }); await getData(db, id, tableName, handleRegionData) .then(() => { setRegionPopupVisible(true); }) .catch((error) => { console.error('Error fetching data', error); refreshDatabases(); }); const bounds = turf.bbox(foundRegion); const region = calculateMapRegion(bounds); zoomLevel < 7 && mapRef.current?.animateToRegion(region, 1000); if (tableName === 'regions') { await mutateUserData( { region_id: +id, token: String(token) }, { onSuccess: (data) => { setUserData({ type: 'nm', ...data }); } } ); await mutateAsync( { regions: JSON.stringify([id]), token: String(token) }, { onSuccess: (data) => { setSeries(data.series); const allMarkers = data.items.map(processMarkerData); setProcessedMarkers(allMarkers); } } ); } else { await mutateUserDataDare( { dare_id: +id, token: String(token) }, { onSuccess: (data) => { setUserData({ type: 'dare', ...data }); } } ); setProcessedMarkers([]); } } else { handleClosePopup(); } }; const handleFindRegion = async (id: number, type: string) => { cancelTokenRef.current = true; const db = type === 'regions' ? getFirstDatabase() : getSecondDatabase(); const dataset = type === 'regions' ? regions : dareData; const foundRegion = dataset.features.find((region: any) => region.properties.id === id); if (foundRegion) { setSelectedRegion({ type: 'FeatureCollection', features: [ { geometry: foundRegion.geometry, properties: { ...foundRegion.properties, fill: 'rgba(57, 115, 172, 0.2)', stroke: '#3973AC' }, type: 'Feature' } ] }); await getData(db, id, type, handleRegionData) .then(() => { setRegionPopupVisible(true); }) .catch((error) => { console.error('Error fetching data', error); refreshDatabases(); }); const bounds = turf.bbox(foundRegion); const region = calculateMapRegion(bounds); mapRef.current?.animateToRegion(region, 1000); if (type === 'regions') { await mutateUserData( { region_id: +id, token: String(token) }, { onSuccess: (data) => { setUserData({ type: 'nm', ...data }); } } ); await mutateAsync( { regions: JSON.stringify([id]), token: String(token) }, { onSuccess: (data) => { setSeries(data.series); const allMarkers = data.items.map(processMarkerData); setProcessedMarkers(allMarkers); setMarkers(allMarkers); } } ); } else { await mutateUserDataDare( { dare_id: +id, token: String(token) }, { onSuccess: (data) => { setUserData({ type: 'dare', ...data }); } } ); setProcessedMarkers([]); setMarkers([]); } } else { handleClosePopup(); } }; const renderMapTiles = (url: string, cacheDir: string, zIndex: number, opacity = 1) => ( ); function renderGeoJSON() { if (!selectedRegion) return null; return ( ); } const handleClosePopup = async () => { cancelTokenRef.current = false; setRegionPopupVisible(false); setMarkers([]); setSelectedRegion(null); setRegionData(null); const boundaries = await mapRef.current?.getMapBoundaries(); const { northEast, southWest } = boundaries || {}; const latitudeDelta = (northEast?.latitude ?? 0) - (southWest?.latitude ?? 0); const longitudeDelta = (northEast?.longitude ?? 0) - (southWest?.longitude ?? 0); const latitude = (southWest?.latitude ?? 0) + latitudeDelta / 2; const longitude = (southWest?.longitude ?? 0) + longitudeDelta / 2; findFeaturesInVisibleMapArea({ latitude, longitude, latitudeDelta, longitudeDelta }); }; const renderedGeoJSON = useMemo(() => renderGeoJSON(), [selectedRegion]); const toggleSeries = useCallback( async (item: any) => { if (!token) { setIsWarningModalVisible(true); return; } setMarkers((currentMarkers) => currentMarkers.map((marker) => marker.id === item.id ? { ...marker, visited: Number(!marker.visited) as 0 | 1 } : marker ) ); setProcessedMarkers((currentMarkers) => currentMarkers.map((marker) => marker.id === item.id ? { ...marker, visited: Number(!marker.visited) as 0 | 1 } : marker ) ); const itemData = { token: token, series_id: item.series_id, item_id: item.id, checked: (!item.visited ? 1 : 0) as 0 | 1, double: 0 as 0 | 1 }; try { updateSeriesItem(itemData); } catch (error) { console.error('Failed to update series state', error); } }, [token, updateSeriesItem] ); const renderMarkers = () => { return markers.map((marker, idx) => { const coordinate = { latitude: marker.pointJSON[0], longitude: marker.pointJSON[1] }; const markerSeries = series?.find((s) => s.id === marker.series_id); const iconUrl = markerSeries ? API_HOST + markerSeries.icon : 'default_icon_url'; const seriesName = markerSeries ? markerSeries.name : 'Unknown'; return ( ); }); }; const handlePress = () => { if (isExpanded) { setIndex(0); setSearchInput(''); } setIsExpanded((prev) => !prev); width.value = withTiming(isExpanded ? 48 : usableWidth, { duration: 300, easing: Easing.inOut(Easing.ease) }); }; const animatedStyle = useAnimatedStyle(() => { return { width: width.value }; }); const handleSearch = async () => { setSearch(searchInput); setSearchVisible(true); }; const handleCloseModal = () => { setSearchInput(''); setSearchVisible(false); handlePress(); }; return ( } > {renderedGeoJSON} {renderMapTiles(tilesBaseURL, localTileDir, 1)} {renderMapTiles(gridUrl, localGridDir, 2)} {userId && renderMapTiles(type === 1 ? visitedUNTiles : visitedTiles, localVisitedDir, 2, 0.5)} {renderMapTiles(dareTiles, localDareDir, 2, 0.5)} {location && ( )} {markers && renderMarkers()} setAskLocationVisible(false)} onAccept={handleAcceptPermission} modalText="To use this feature we need your permission to access your location. If you press OK your system will ask you to approve location sharing with NomadMania app." /> setOpenSettingsVisible(false)} onAccept={() => Platform.OS === 'ios' ? Linking.openURL('app-settings:') : Linking.openSettings() } modalText="NomadMania app needs location permissions to function properly. Open settings?" /> {regionPopupVisible && regionData ? ( <> Close { if (!token) { setIsWarningModalVisible(true); return; } handleUpdateNM(id, first, last, visits, quality); }} updateDare={(id, visits) => { if (!token) { setIsWarningModalVisible(true); return; } handleUpdateDare(id, visits); }} disabled={!token || !isConnected} /> ) : ( <> {!isExpanded ? ( (navigation as any)?.openDrawer()} > ) : null} navigation.navigate(NAVIGATION_PAGES.INFO)} > {isExpanded ? ( <> setSearchInput(text)} onSubmitEditing={handleSearch} /> ) : ( )} { token ? setIsFilterVisible(true) : setIsWarningModalVisible(true); }} > )} setIsWarningModalVisible(false)} /> setIsEditModalVisible(false)} modalState={modalState} updateModalState={handleModalStateChange} updateNM={handleUpdateNM} /> ); }; export default MapScreen;