index.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  1. import { Animated, Linking, Platform, Text, TouchableOpacity, View } from 'react-native';
  2. import React, { useEffect, useMemo, useRef, useState } from 'react';
  3. import MapView, { Geojson, Marker, UrlTile } from 'react-native-maps';
  4. import * as turf from '@turf/turf';
  5. import * as FileSystem from 'expo-file-system';
  6. import * as Location from 'expo-location';
  7. import { getOnlineStatus, storage, StoreType } from '../../../storage';
  8. import MenuIcon from '../../../../assets/icons/menu.svg';
  9. import SearchIcon from '../../../../assets/icons/search.svg';
  10. import RadarIcon from '../../../../assets/icons/radar.svg';
  11. import LocationIcon from '../../../../assets/icons/location.svg';
  12. import CloseSvg from '../../../../assets/icons/close.svg';
  13. import regions from '../../../../assets/geojson/nm2022.json';
  14. import dareRegions from '../../../../assets/geojson/mqp.json';
  15. import NetInfo from '@react-native-community/netinfo';
  16. import { getFirstDatabase, getSecondDatabase } from '../../../db';
  17. import { LocationPopup, RegionPopup, WarningModal } from '../../../components';
  18. import { styles } from './style';
  19. import {
  20. calculateMapRegion,
  21. clusterMarkers,
  22. filterCandidates,
  23. filterCandidatesMarkers,
  24. findRegionInDataset,
  25. processIconUrl,
  26. processMarkerData
  27. } from '../../../utils/mapHelpers';
  28. import { getData } from '../../../modules/map/regionData';
  29. import { fetchSeriesData } from '@api/series';
  30. import MarkerItem from './MarkerItem';
  31. import ClusterItem from './ClusterItem';
  32. import {
  33. ClusterData,
  34. FeatureCollection,
  35. ItemSeries,
  36. MapScreenProps,
  37. MarkerData,
  38. Region,
  39. Series
  40. } from '../../../types/map';
  41. import { MAP_HOST } from 'src/constants';
  42. const tilesBaseURL = `${MAP_HOST}/tiles_osm`;
  43. const localTileDir = `${FileSystem.cacheDirectory}tiles/background`;
  44. const gridUrl = `${MAP_HOST}/tiles_nm/grid`;
  45. const localGridDir = `${FileSystem.cacheDirectory}tiles/grid`;
  46. const localVisitedDir = `${FileSystem.cacheDirectory}tiles/user_visited`;
  47. const dareTiles = `${MAP_HOST}/tiles_nm/regions_mqp`;
  48. const localDareDir = `${FileSystem.cacheDirectory}tiles/regions_mqp`;
  49. const AnimatedMarker = Animated.createAnimatedComponent(Marker);
  50. const MapScreen: React.FC<MapScreenProps> = ({ navigation }) => {
  51. const userId = storage.get('uid', StoreType.STRING);
  52. const token = storage.get('token', StoreType.STRING);
  53. const isOnline = getOnlineStatus();
  54. const { mutateAsync } = fetchSeriesData();
  55. const visitedTiles = `${MAP_HOST}/tiles_nm/user_visited/${userId}`;
  56. const mapRef = useRef<MapView>(null);
  57. const [isConnected, setIsConnected] = useState<boolean | null>(true);
  58. const [selectedRegion, setSelectedRegion] = useState<FeatureCollection | null>(null);
  59. const [regionPopupVisible, setRegionPopupVisible] = useState<boolean | null>(false);
  60. const [regionData, setRegionData] = useState<Region | null>(null);
  61. const [userAvatars, setUserAvatars] = useState<string[]>([]);
  62. const [location, setLocation] = useState<Location.LocationObjectCoords | null>(null);
  63. const [askLocationVisible, setAskLocationVisible] = useState<boolean>(false);
  64. const [openSettingsVisible, setOpenSettingsVisible] = useState<boolean>(false);
  65. const [isWarningModalVisible, setIsWarningModalVisible] = useState<boolean>(false);
  66. const [markers, setMarkers] = useState<MarkerData[]>([]);
  67. const [clusters, setClusters] = useState<ClusterData | null>(null);
  68. const [series, setSeries] = useState<Series[] | null>(null);
  69. const [processedMarkers, setProcessedMarkers] = useState<ItemSeries[]>([]);
  70. const cancelTokenRef = useRef(false);
  71. const currentTokenRef = useRef(0);
  72. const strokeWidthAnim = useRef(new Animated.Value(2)).current;
  73. useEffect(() => {
  74. Animated.loop(
  75. Animated.sequence([
  76. Animated.timing(strokeWidthAnim, {
  77. toValue: 3,
  78. duration: 700,
  79. useNativeDriver: false
  80. }),
  81. Animated.timing(strokeWidthAnim, {
  82. toValue: 2,
  83. duration: 700,
  84. useNativeDriver: false
  85. })
  86. ])
  87. ).start();
  88. }, [strokeWidthAnim]);
  89. useEffect(() => {
  90. navigation.setOptions({
  91. tabBarStyle: {
  92. display: regionPopupVisible ? 'none' : 'flex',
  93. position: 'absolute',
  94. ...Platform.select({
  95. android: {
  96. height: 58
  97. }
  98. })
  99. }
  100. });
  101. }, [regionPopupVisible, navigation]);
  102. useEffect(() => {
  103. const unsubscribe = NetInfo.addEventListener((state) => {
  104. setIsConnected(isOnline as boolean);
  105. });
  106. return () => unsubscribe();
  107. }, [isOnline]);
  108. useEffect(() => {
  109. (async () => {
  110. let { status } = await Location.getForegroundPermissionsAsync();
  111. if (status !== 'granted') {
  112. return;
  113. }
  114. let currentLocation = await Location.getCurrentPositionAsync({});
  115. setLocation(currentLocation.coords);
  116. mapRef.current?.animateToRegion(
  117. {
  118. latitude: currentLocation.coords.latitude,
  119. longitude: currentLocation.coords.longitude,
  120. latitudeDelta: 5,
  121. longitudeDelta: 5
  122. },
  123. 1000
  124. );
  125. })();
  126. }, []);
  127. const findFeaturesInVisibleMapArea = async (visibleMapArea: {
  128. latitude?: any;
  129. longitude?: any;
  130. latitudeDelta: any;
  131. longitudeDelta?: any;
  132. }) => {
  133. if (!isOnline) return;
  134. const currentZoom = Math.log2(360 / visibleMapArea.latitudeDelta);
  135. if (cancelTokenRef.current) {
  136. const clusteredMarkers = clusterMarkers(processedMarkers, currentZoom, setClusters);
  137. setMarkers(clusteredMarkers as MarkerData[]);
  138. return;
  139. }
  140. const thisToken = ++currentTokenRef.current;
  141. if (!regions || !dareRegions) return;
  142. if (currentZoom < 7) {
  143. setMarkers([]);
  144. return;
  145. }
  146. const { latitude, longitude, latitudeDelta, longitudeDelta } = visibleMapArea;
  147. const bbox: turf.BBox = [
  148. longitude - longitudeDelta / 2,
  149. latitude - latitudeDelta / 2,
  150. longitude + longitudeDelta / 2,
  151. latitude + latitudeDelta / 2
  152. ];
  153. const visibleAreaPolygon = turf.bboxPolygon(bbox);
  154. const regionsFound = filterCandidates(regions, bbox);
  155. // const daresFound = filterCandidates(dareRegions, bbox);
  156. const regionIds = regionsFound.map(
  157. (region: { properties: { id: any } }) => region.properties.id
  158. );
  159. isOnline && await mutateAsync(
  160. { regions: JSON.stringify(regionIds), token: String(token) },
  161. {
  162. onSuccess: (data) => {
  163. if (thisToken !== currentTokenRef.current) return;
  164. setSeries(data.series);
  165. const markersVisible = filterCandidatesMarkers(data.items, visibleAreaPolygon);
  166. const allMarkers = markersVisible.map(processMarkerData);
  167. const clusteredMarkers = clusterMarkers(allMarkers, currentZoom, setClusters);
  168. setMarkers(clusteredMarkers as MarkerData[]);
  169. }
  170. }
  171. );
  172. };
  173. const renderMarkers = () => {
  174. if (!markers.length) return null;
  175. const singleMarkers = markers.filter((feature) => {
  176. return feature.properties.dbscan !== 'core';
  177. });
  178. return (
  179. <>
  180. {singleMarkers.map((marker, idx) => {
  181. const markerSeries = series?.find((s) => s.id === marker.properties.series_id);
  182. const iconUrl = markerSeries ? processIconUrl(markerSeries.icon) : 'default_icon_url';
  183. return <MarkerItem marker={marker} iconUrl={iconUrl} key={idx} />;
  184. })}
  185. {clusters &&
  186. Object.entries(clusters).map(([clusterId, data], idx) => (
  187. <ClusterItem clusterId={clusterId} data={data} key={idx} />
  188. ))}
  189. </>
  190. );
  191. };
  192. const handleGetLocation = async () => {
  193. let { status, canAskAgain } = await Location.getForegroundPermissionsAsync();
  194. if (status === 'granted') {
  195. getLocation();
  196. } else if (!canAskAgain) {
  197. setOpenSettingsVisible(true);
  198. } else {
  199. setAskLocationVisible(true);
  200. }
  201. };
  202. const getLocation = async () => {
  203. let currentLocation = await Location.getCurrentPositionAsync();
  204. setLocation(currentLocation.coords);
  205. mapRef.current?.animateToRegion(
  206. {
  207. latitude: currentLocation.coords.latitude,
  208. longitude: currentLocation.coords.longitude,
  209. latitudeDelta: 5,
  210. longitudeDelta: 5
  211. },
  212. 800
  213. );
  214. handleClosePopup();
  215. };
  216. const handleAcceptPermission = async () => {
  217. setAskLocationVisible(false);
  218. let { status, canAskAgain } = await Location.requestForegroundPermissionsAsync();
  219. if (status === 'granted') {
  220. getLocation();
  221. } else if (!canAskAgain) {
  222. setOpenSettingsVisible(true);
  223. }
  224. };
  225. const handleRegionData = (regionData: Region, avatars: string[]) => {
  226. setRegionData(regionData);
  227. setUserAvatars(avatars);
  228. };
  229. const handleMapPress = async (event: {
  230. nativeEvent: { coordinate: { latitude: any; longitude: any } };
  231. }) => {
  232. cancelTokenRef.current = true;
  233. const { latitude, longitude } = event.nativeEvent.coordinate;
  234. const point = turf.point([longitude, latitude]);
  235. setUserAvatars([]);
  236. let db = getSecondDatabase();
  237. let tableName = 'places';
  238. let foundRegion = findRegionInDataset(dareRegions, point);
  239. if (!foundRegion) {
  240. foundRegion = findRegionInDataset(regions, point);
  241. db = getFirstDatabase();
  242. tableName = 'regions';
  243. }
  244. if (foundRegion) {
  245. const id = foundRegion.properties?.id;
  246. setSelectedRegion({
  247. type: 'FeatureCollection',
  248. features: [
  249. {
  250. geometry: foundRegion.geometry,
  251. properties: {
  252. ...foundRegion.properties,
  253. fill: 'rgba(57, 115, 172, 0.2)',
  254. stroke: '#3973AC'
  255. },
  256. type: 'Feature'
  257. }
  258. ]
  259. });
  260. await getData(db, id, tableName, handleRegionData)
  261. .then(() => {
  262. setRegionPopupVisible(true);
  263. })
  264. .catch((error) => {
  265. console.error('Error fetching data', error);
  266. });
  267. const bounds = turf.bbox(foundRegion);
  268. const region = calculateMapRegion(bounds);
  269. mapRef.current?.animateToRegion(region, 1000);
  270. if (tableName === 'regions') {
  271. await mutateAsync(
  272. { regions: JSON.stringify([id]), token: String(token) },
  273. {
  274. onSuccess: (data) => {
  275. setSeries(data.series);
  276. const allMarkers = data.items.map(processMarkerData);
  277. setProcessedMarkers(allMarkers);
  278. }
  279. }
  280. );
  281. } else {
  282. setProcessedMarkers([]);
  283. }
  284. } else {
  285. handleClosePopup();
  286. }
  287. };
  288. const renderMapTiles = (url: string, cacheDir: string, zIndex: number, opacity = 1) => (
  289. <UrlTile
  290. urlTemplate={`${url}/{z}/{x}/{y}`}
  291. maximumZ={15}
  292. maximumNativeZ={13}
  293. tileCachePath={`${cacheDir}`}
  294. shouldReplaceMapContent
  295. minimumZ={0}
  296. offlineMode={!isConnected}
  297. opacity={opacity}
  298. zIndex={zIndex}
  299. />
  300. );
  301. function renderGeoJSON() {
  302. if (!selectedRegion) return null;
  303. return (
  304. <Geojson
  305. geojson={selectedRegion as any}
  306. fillColor="rgba(57, 115, 172, 0.2)"
  307. strokeColor="#3973ac"
  308. strokeWidth={Platform.OS == 'android' ? 3 : 2}
  309. zIndex={3}
  310. />
  311. );
  312. }
  313. const handleClosePopup = async () => {
  314. cancelTokenRef.current = false;
  315. setRegionPopupVisible(false);
  316. setMarkers([]);
  317. setSelectedRegion(null);
  318. const boundaries = await mapRef.current?.getMapBoundaries();
  319. const { northEast, southWest } = boundaries || {};
  320. const latitudeDelta = (northEast?.latitude ?? 0) - (southWest?.latitude ?? 0);
  321. const longitudeDelta = (northEast?.longitude ?? 0) - (southWest?.longitude ?? 0);
  322. const latitude = (southWest?.latitude ?? 0) + latitudeDelta / 2;
  323. const longitude = (southWest?.longitude ?? 0) + longitudeDelta / 2;
  324. findFeaturesInVisibleMapArea({ latitude, longitude, latitudeDelta, longitudeDelta });
  325. };
  326. const renderedGeoJSON = useMemo(() => renderGeoJSON(), [selectedRegion]);
  327. return (
  328. <View style={styles.container}>
  329. <MapView
  330. ref={mapRef}
  331. showsMyLocationButton={false}
  332. showsCompass={false}
  333. zoomControlEnabled={false}
  334. onPress={handleMapPress}
  335. style={styles.map}
  336. mapType={Platform.OS == 'android' ? 'none' : 'standard'}
  337. maxZoomLevel={15}
  338. minZoomLevel={0}
  339. onRegionChangeComplete={findFeaturesInVisibleMapArea}
  340. >
  341. {renderedGeoJSON}
  342. {renderMapTiles(tilesBaseURL, localTileDir, 1)}
  343. {renderMapTiles(gridUrl, localGridDir, 2)}
  344. {userId && renderMapTiles(visitedTiles, localVisitedDir, 2, 0.5)}
  345. {renderMapTiles(dareTiles, localDareDir, 2, 0.5)}
  346. {location && (
  347. <AnimatedMarker coordinate={location} anchor={{ x: 0.5, y: 0.5 }}>
  348. <Animated.View style={[styles.location, { borderWidth: strokeWidthAnim }]} />
  349. </AnimatedMarker>
  350. )}
  351. {markers && renderMarkers()}
  352. </MapView>
  353. <LocationPopup
  354. visible={askLocationVisible}
  355. onClose={() => setAskLocationVisible(false)}
  356. onAccept={handleAcceptPermission}
  357. 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."
  358. />
  359. <LocationPopup
  360. visible={openSettingsVisible}
  361. onClose={() => setOpenSettingsVisible(false)}
  362. onAccept={() =>
  363. Platform.OS === 'ios' ? Linking.openURL('app-settings:') : Linking.openSettings()
  364. }
  365. modalText="NomadMania app needs location permissions to function properly. Open settings?"
  366. />
  367. {regionPopupVisible && regionData ? (
  368. <>
  369. <TouchableOpacity
  370. style={[styles.cornerButton, styles.topLeftButton, styles.closeLeftButton]}
  371. onPress={handleClosePopup}
  372. >
  373. <CloseSvg fill="white" width={13} height={13} />
  374. <Text style={styles.textClose}>Close</Text>
  375. </TouchableOpacity>
  376. <TouchableOpacity
  377. onPress={handleGetLocation}
  378. style={[styles.cornerButton, styles.topRightButton, styles.bottomButton]}
  379. >
  380. <LocationIcon />
  381. </TouchableOpacity>
  382. <RegionPopup
  383. region={regionData}
  384. userAvatars={userAvatars}
  385. onMarkVisited={() => {
  386. if (!token) {
  387. setIsWarningModalVisible(true);
  388. } else {
  389. console.log('Mark as visited');
  390. }
  391. }}
  392. />
  393. </>
  394. ) : (
  395. <>
  396. <TouchableOpacity style={[styles.cornerButton, styles.topLeftButton]}>
  397. <MenuIcon />
  398. </TouchableOpacity>
  399. <TouchableOpacity style={[styles.cornerButton, styles.topRightButton]}>
  400. <SearchIcon fill={'#0F3F4F'} />
  401. </TouchableOpacity>
  402. <TouchableOpacity
  403. style={[styles.cornerButton, styles.bottomButton, styles.bottomLeftButton]}
  404. >
  405. <RadarIcon />
  406. </TouchableOpacity>
  407. <TouchableOpacity
  408. onPress={handleGetLocation}
  409. style={[styles.cornerButton, styles.bottomButton, styles.bottomRightButton]}
  410. >
  411. <LocationIcon />
  412. </TouchableOpacity>
  413. </>
  414. )}
  415. <WarningModal
  416. type={'unauthorized'}
  417. isVisible={isWarningModalVisible}
  418. onClose={() => setIsWarningModalVisible(false)}
  419. />
  420. </View>
  421. );
  422. };
  423. export default MapScreen;