index.tsx 15 KB

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