index.tsx 15 KB

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