index.tsx 15 KB

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