Sfoglia il codice sorgente

series view and location features

Viktoriia 1 anno fa
parent
commit
bac811eaea

File diff suppressed because it is too large
+ 508 - 133
package-lock.json


+ 45 - 0
src/components/LocationPopup/index.tsx

@@ -0,0 +1,45 @@
+import React from 'react';
+import { Modal, Text, View, TouchableOpacity } from 'react-native';
+import { Button } from '../Button';
+
+import { styles } from './styles';
+
+import CloseSVG from '../../../assets/icons/close.svg';
+
+interface LocationPopupProps {
+  visible: boolean;
+  onClose: () => void;
+  onAccept: () => void;
+  modalText: string;
+}
+
+export const LocationPopup: React.FC<LocationPopupProps> = ({
+  visible,
+  onClose,
+  onAccept,
+  modalText
+}) => {
+  return (
+    <Modal
+      animationType="slide"
+      transparent={true}
+      visible={visible}
+      statusBarTranslucent
+    >
+      <View style={styles.centeredView}>
+        <View style={styles.modalView}>
+          <TouchableOpacity style={styles.closeButton} onPress={onClose}>
+            <CloseSVG />
+          </TouchableOpacity>
+          <Text style={styles.modalTitle}>Oops!</Text>
+          <Text style={styles.modalText}>
+            {modalText}
+          </Text>
+          <Button onPress={onAccept}>
+              OK
+          </Button>
+        </View>
+      </View>
+    </Modal>
+  );
+};

+ 58 - 0
src/components/LocationPopup/styles.tsx

@@ -0,0 +1,58 @@
+import { StyleSheet } from 'react-native';
+import { Colors } from '../../theme';
+
+export const styles = StyleSheet.create({
+  centeredView: {
+    flex: 1,
+    justifyContent: "center",
+    alignItems: "center",
+    backgroundColor: 'rgba(0,0,0,0.5)',
+  },
+  modalView: {
+    margin: 16,
+    backgroundColor: "white",
+    borderRadius: 15,
+    paddingHorizontal: 20,
+    paddingVertical: 40,
+    gap: 10,
+    shadow: {
+      shadowColor: "#000000",
+      shadowOffset: {
+        width: 0,
+        height: 10,
+      },
+      shadowOpacity: 0.18,
+      shadowRadius: 46,
+      elevation: 12,
+    },
+  },
+  closeButton: {
+    position: 'absolute',
+    top: 5,
+    right: 5,
+    padding: 10,
+  },
+  okButton: {
+    backgroundColor: Colors.ORANGE,
+    borderRadius: 20,
+    padding: 10,
+    elevation: 2,
+    marginTop: 15,
+  },
+  okButtonText: {
+    color: "white",
+    fontWeight: "bold",
+    textAlign: "center"
+  },
+  modalText: {
+    marginBottom: 15,
+    color: '#3E6471',
+    fontSize: 14,
+  },
+  modalTitle: {
+    color: '#0F3F4F',
+    fontSize: 18,
+    fontWeight: 'bold',
+    textAlign: "center",
+  }
+});

+ 1 - 3
src/components/RegionPopup/index.tsx

@@ -22,7 +22,7 @@ interface RegionPopupProps {
   onMarkVisited: () => void;
 }
 
-const RegionPopup: React.FC<RegionPopupProps> = ({ region, userAvatars, onMarkVisited }) => {
+export const RegionPopup: React.FC<RegionPopupProps> = ({ region, userAvatars, onMarkVisited }) => {
   const fadeAnim = useRef(new Animated.Value(0)).current;
 
   useEffect(() => {
@@ -93,5 +93,3 @@ const RegionPopup: React.FC<RegionPopupProps> = ({ region, userAvatars, onMarkVi
     </Animated.View>
   );
 };
-
-export default RegionPopup;

+ 2 - 0
src/components/index.ts

@@ -7,3 +7,5 @@ export * from './Modal';
 export * from './PageWrapper';
 export * from './AvatarPicker';
 export * from './FlatList';
+export * from './RegionPopup';
+export * from './LocationPopup';

+ 11 - 0
src/modules/map/series/queries/use-post-get-series.tsx

@@ -0,0 +1,11 @@
+import { seriesApi } from '../series-api';
+
+export const fetchSeriesData = async (token: string | null, regions: string) => {
+  try {
+    const response = await seriesApi.getSeries(token, regions);
+    return response.data;
+  } catch (error) {
+    throw error;
+  }
+};
+

+ 23 - 0
src/modules/map/series/series-api.tsx

@@ -0,0 +1,23 @@
+import { request } from '../../../utils';
+import { API } from '../../../types';
+
+export interface SeriesResponse  {
+  result: 'OK' | 'ERROR';
+  result_description?: string;
+  series: { id: number; name: string; icon: string }[];
+  items: {
+    id: number;
+    series_id: number;
+    name: string;
+    region: number;
+    pointJSON: any;
+    polygonJSON: string;
+    visited?: 0 | 1;
+  }[];
+}
+
+export const seriesApi = {
+  getSeries: (token: string | null, regions: string) => 
+  request.postForm<SeriesResponse>(API.SERIES, { token, regions })
+};
+

+ 17 - 0
src/screens/InAppScreens/MapScreen/ClusterItem/index.tsx

@@ -0,0 +1,17 @@
+import { View, Text } from 'react-native';
+import { Marker } from 'react-native-maps';
+import { styles } from './styles';
+
+const ClusterItem = ({ clusterId, data }: { clusterId: string, data: { center: number[], size: number } }) => {
+  return (
+    <Marker
+      coordinate={{ latitude: data.center[1], longitude: data.center[0] }}
+    >
+      <View style={styles.clusterContainer}>
+        <Text style={styles.text}>{data.size}</Text>
+      </View>
+    </Marker>
+  );
+};
+
+export default ClusterItem;

+ 21 - 0
src/screens/InAppScreens/MapScreen/ClusterItem/styles.tsx

@@ -0,0 +1,21 @@
+import { StyleSheet } from 'react-native';
+import { Colors } from '../../../../theme';
+
+export const styles = StyleSheet.create({
+  clusterContainer: {
+    height: 28,
+    width: 28,
+    backgroundColor: Colors.TEXT_GRAY,
+    borderRadius: 14,
+    borderWidth: 2,
+    borderColor: Colors.WHITE,
+    alignItems: 'center',
+    justifyContent: 'center',
+  },
+  text: {
+    color: Colors.WHITE,
+    textAlign: 'center',
+    fontSize: 14,
+    fontWeight: '600',
+  },
+});

+ 24 - 0
src/screens/InAppScreens/MapScreen/MarkerItem/index.tsx

@@ -0,0 +1,24 @@
+import { View, Image } from 'react-native';
+import { Marker } from 'react-native-maps';
+import { styles } from './styles';
+import { MarkerData } from '../../../../types/map';
+
+const MarkerItem = ({ marker, iconUrl }: { marker: MarkerData, iconUrl: string }) => {
+  return (
+    <Marker
+      coordinate={{ latitude: marker.geometry.coordinates[1], longitude: marker.geometry.coordinates[0] }}
+      title={marker.properties.name}
+      tracksViewChanges={false}
+    >
+      <View style={styles.markerContainer}>
+        <Image
+          source={{ uri: iconUrl }}
+          style={styles.icon}
+          resizeMode="contain"
+        />
+      </View>
+    </Marker>
+  );
+};
+
+export default MarkerItem;

+ 20 - 0
src/screens/InAppScreens/MapScreen/MarkerItem/styles.tsx

@@ -0,0 +1,20 @@
+import { StyleSheet, Platform } from 'react-native';
+import { Colors } from '../../../../theme';
+
+export const styles = StyleSheet.create({
+  markerContainer: {
+    width: 30,
+    height: 30,
+    justifyContent: 'center',
+    alignItems: 'center',
+    backgroundColor: Colors.WHITE,
+    borderRadius: 15,
+    borderWidth: 2,
+    borderColor: Colors.TEXT_GRAY,
+  },
+  icon: {
+    width: 20,
+    height: 20,
+    zIndex: 5,
+  },
+});

+ 258 - 37
src/screens/InAppScreens/MapScreen/index.tsx

@@ -2,12 +2,16 @@ import {
   View,
   Platform,
   TouchableOpacity,
-  Text
+  Text,
+  Linking,
+  Animated,
 } from 'react-native';
 import React, { useEffect, useState, useRef, useMemo } from 'react';
-import MapView, { UrlTile, Geojson } from 'react-native-maps';
+import MapView, { UrlTile, Geojson, Marker } from 'react-native-maps';
 import * as turf from '@turf/turf';
 import * as FileSystem from 'expo-file-system';
+import * as Location from 'expo-location';
+import { storageGet } from '../../../storage';
 
 import MenuIcon from '../../../../assets/icons/menu.svg';
 import SearchIcon from '../../../../assets/icons/search.svg';
@@ -20,12 +24,36 @@ import dareRegions from '../../../../assets/geojson/mqp.json';
 
 import NetInfo from "@react-native-community/netinfo";
 import { getFirstDatabase, getSecondDatabase } from '../../../db';
-import RegionPopup from '../../../components/RegionPopup';
+import { RegionPopup, LocationPopup } from '../../../components';
 
-import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
 import { styles } from './style';
-import { findRegionInDataset, calculateMapRegion } from '../../../utils/mapHelpers';
+import {
+  findRegionInDataset,
+  calculateMapRegion,
+  clusterMarkers,
+  filterCandidates,
+  processMarkerData,
+  processIconUrl,
+  filterCandidatesMarkers
+} from '../../../utils/mapHelpers';
 import { getData } from '../../../modules/map/regionData';
+import { fetchSeriesData } from '../../../modules/map/series/queries/use-post-get-series';
+import MarkerItem from './MarkerItem';
+import ClusterItem from './ClusterItem';
+import {
+  Region,
+  Series,
+  ItemSeries,
+  MarkerData,
+  ClusterData,
+  MapScreenProps,
+  FeatureCollection
+} from '../../../types/map';
+
+let userId: string | null = '';
+let token: string | null = '';
+storageGet('id').then((data) => (userId = data));
+storageGet('token').then((data) => (token = data));
 
 const tilesBaseURL = 'https://maps.nomadmania.com/tiles_osm';
 const localTileDir = `${FileSystem.cacheDirectory}tiles`;
@@ -33,36 +61,57 @@ const localTileDir = `${FileSystem.cacheDirectory}tiles`;
 const gridUrl = 'https://maps.nomadmania.com/tiles_nm/grid';
 const localGridDir = `${FileSystem.cacheDirectory}tiles/grid`;
 
-const visitedTiles = 'https://maps.nomadmania.com/tiles_nm/user_visited/51363';
+const visitedTiles = `https://maps.nomadmania.com/tiles_nm/user_visited/${userId}`;
 const localVisitedDir = `${FileSystem.cacheDirectory}tiles/user_visited`;
 
 const dareTiles = 'https://maps.nomadmania.com/tiles_nm/regions_mqp';
 const localDareDir = `${FileSystem.cacheDirectory}tiles/regions_mqp`;
 
-interface Region {
-  id: number;
-  name: string;
-  region_photos: string;
-  visitors_count: number;
-}
-
-interface MapScreenProps {
-  navigation: BottomTabNavigationProp<any>;
-}
+const AnimatedMarker = Animated.createAnimatedComponent(Marker);
 
 const MapScreen: React.FC<MapScreenProps> = ({ navigation }) => {
   const mapRef = useRef<MapView>(null);
 
   const [isConnected, setIsConnected] = useState<boolean | null>(true);
-  const [selectedRegion, setSelectedRegion] = useState(null);
-  const [popupVisible, setPopupVisible] = useState<boolean | null>(false);
+  const [selectedRegion, setSelectedRegion] = useState<FeatureCollection | null>(null);
+  const [regionPopupVisible, setRegionPopupVisible] = useState<boolean | null>(false);
   const [regionData, setRegionData] = useState<Region | null>(null);
   const [userAvatars, setUserAvatars] = useState<string[]>([]);
+  const [location, setLocation] = useState<Location.LocationObjectCoords | null>(null);
+  const [askLocationVisible, setAskLocationVisible] = useState<boolean>(false);
+  const [openSettingsVisible, setOpenSettingsVisible] = useState<boolean>(false);
+
+  const [markers, setMarkers] = useState<MarkerData[]>([]);
+  const [clusters, setClusters] = useState<ClusterData | null>(null);
+  const [series, setSeries] = useState<Series[] | null>(null);
+  const [processedMarkers, setProcessedMarkers] = useState<ItemSeries[]>([]);
+
+  const cancelTokenRef = useRef(false);
+  const currentTokenRef = useRef(0);
+
+  const strokeWidthAnim = useRef(new Animated.Value(2)).current;
+
+  useEffect(() => {
+    Animated.loop(
+      Animated.sequence([
+        Animated.timing(strokeWidthAnim, {
+          toValue: 3,
+          duration: 700,
+          useNativeDriver: false,
+        }),
+        Animated.timing(strokeWidthAnim, {
+          toValue: 2,
+          duration: 700,
+          useNativeDriver: false,
+        }),
+      ])
+    ).start();
+  }, [strokeWidthAnim]);
 
   useEffect(() => {
     navigation.setOptions({
       tabBarStyle: {
-        display: popupVisible ? 'none' : 'flex',
+        display: regionPopupVisible ? 'none' : 'flex',
         position: 'absolute',
         ...Platform.select({
           android: {
@@ -71,7 +120,137 @@ const MapScreen: React.FC<MapScreenProps> = ({ navigation }) => {
         }),
       }
     });
-  }, [popupVisible, navigation]);
+  }, [regionPopupVisible, navigation]);
+
+  useEffect(() => {
+    const unsubscribe = NetInfo.addEventListener(state => {
+      setIsConnected(state.isConnected);
+    });
+  
+    return () => unsubscribe();
+  }, []);
+
+  useEffect(() => {
+    (async () => {
+      let { status } = await Location.getForegroundPermissionsAsync();
+      if (status !== 'granted') {
+        return;
+      }
+
+      let currentLocation = await Location.getCurrentPositionAsync({});
+      setLocation(currentLocation.coords);
+      
+      mapRef.current?.animateToRegion({
+        latitude: currentLocation.coords.latitude,
+        longitude: currentLocation.coords.longitude,
+        latitudeDelta: 5,
+        longitudeDelta: 5,
+      }, 1000);
+    })();
+  }, []);
+
+  const findFeaturesInVisibleMapArea = async (visibleMapArea: { latitude?: any; longitude?: any; latitudeDelta: any; longitudeDelta?: any; }) => {
+    const currentZoom = Math.log2(360 / visibleMapArea.latitudeDelta);
+
+    if (cancelTokenRef.current) {
+      const clusteredMarkers = clusterMarkers(processedMarkers, currentZoom, setClusters);
+      setMarkers(clusteredMarkers as MarkerData[]);
+
+      return;
+    }
+    const thisToken = ++currentTokenRef.current;
+
+    if (!regions || !dareRegions) 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);
+    const candidatesMarkers = await fetchSeriesData(token, JSON.stringify(regionIds));
+    
+    if (thisToken !== currentTokenRef.current) return;
+
+    setSeries(candidatesMarkers.series);
+
+    const markersVisible = filterCandidatesMarkers(candidatesMarkers.items, visibleAreaPolygon);
+    const allMarkers = markersVisible.map(processMarkerData);
+    const clusteredMarkers = clusterMarkers(allMarkers, currentZoom, setClusters);
+
+    setMarkers(clusteredMarkers as MarkerData[]);
+  };
+
+  const renderMarkers = () => {
+    if (!markers.length) return null;
+
+    const singleMarkers = markers.filter((feature) => {
+      return feature.properties.dbscan !== 'core';
+    })
+
+    return (
+      <>
+        {singleMarkers.map((marker, idx) => {
+          const markerSeries = series?.find(s => s.id === marker.properties.series_id);
+          const iconUrl = markerSeries ? processIconUrl(markerSeries.icon) : 'default_icon_url';
+
+          return <MarkerItem marker={marker} iconUrl={iconUrl} key={idx} />;
+        })}
+        {clusters && Object.entries(clusters).map(([clusterId, data], idx) => (
+          <ClusterItem clusterId={clusterId} data={data} key={idx} />
+        ))}
+      </>
+    );
+  };
+
+  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();
+    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);
@@ -79,6 +258,7 @@ const MapScreen: React.FC<MapScreenProps> = ({ navigation }) => {
   };
 
   const handleMapPress = async (event: { nativeEvent: { coordinate: { latitude: any; longitude: any; }; }; }) => {
+    cancelTokenRef.current = true;
     const { latitude, longitude } = event.nativeEvent.coordinate;
     const point = turf.point([longitude, latitude]);
     setUserAvatars([]);
@@ -112,7 +292,7 @@ const MapScreen: React.FC<MapScreenProps> = ({ navigation }) => {
 
       await getData(db, id, tableName, handleRegionData)
       .then(() => {
-        setPopupVisible(true);
+        setRegionPopupVisible(true);
       })
       .catch(error => {
         console.error("Error fetching data", error);
@@ -122,19 +302,20 @@ const MapScreen: React.FC<MapScreenProps> = ({ navigation }) => {
       const region = calculateMapRegion(bounds);
 
       mapRef.current?.animateToRegion(region, 1000);
+
+      if(tableName === 'regions') {
+        const seriesData = await fetchSeriesData(token, JSON.stringify([id]));
+        setSeries(seriesData.series);
+        const allMarkers = seriesData.items.map(processMarkerData);
+        setProcessedMarkers(allMarkers);
+      } else {
+        setProcessedMarkers([]);
+      }
     } else {
       handleClosePopup();
     }
   };
 
-  useEffect(() => {
-    const unsubscribe = NetInfo.addEventListener(state => {
-      setIsConnected(state.isConnected);
-    });
-  
-    return () => unsubscribe();
-  }, []);
-
   const renderMapTiles = (
     url: string,
     cacheDir: string,
@@ -159,7 +340,7 @@ const MapScreen: React.FC<MapScreenProps> = ({ navigation }) => {
 
     return (
         <Geojson
-          geojson={selectedRegion}
+          geojson={selectedRegion as any}
           fillColor="rgba(57, 115, 172, 0.2)"
           strokeColor="#3973ac"
           strokeWidth={Platform.OS == 'android' ? 3 : 2}
@@ -168,9 +349,21 @@ const MapScreen: React.FC<MapScreenProps> = ({ navigation }) => {
     );
   };
 
-  const handleClosePopup = () => {
-    setPopupVisible(false);
+  const handleClosePopup = async () => {
+    cancelTokenRef.current = false;
+
+    setRegionPopupVisible(false);
+    setMarkers([]);
     setSelectedRegion(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]);
@@ -185,18 +378,40 @@ const MapScreen: React.FC<MapScreenProps> = ({ navigation }) => {
         onPress={handleMapPress}
         style={styles.map}
         mapType={Platform.OS == 'android' ? 'none' : 'standard'}
-        offlineMode={!isConnected}
         maxZoomLevel={15}
         minZoomLevel={0}
+        onRegionChangeComplete={findFeaturesInVisibleMapArea}
       >
         {renderedGeoJSON}
         {renderMapTiles(tilesBaseURL, localTileDir, 1)}
         {renderMapTiles(gridUrl, localGridDir, 2)}
-        {renderMapTiles(visitedTiles, localVisitedDir, 2, 0.5)}
+        {userId && renderMapTiles(visitedTiles, localVisitedDir, 2, 0.5)}
         {renderMapTiles(dareTiles, localDareDir, 2, 0.5)}
+        {location && (
+          <AnimatedMarker
+            coordinate={location}
+            anchor={{ x: 0.5, y: 0.5 }}
+          >
+            <Animated.View style={[styles.location, { borderWidth: strokeWidthAnim }]} />
+          </AnimatedMarker>
+        )}
+        {markers && renderMarkers()}
       </MapView>
 
-      {popupVisible && regionData ? (
+      <LocationPopup
+        visible={askLocationVisible}
+        onClose={() => 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."
+      />
+      <LocationPopup
+        visible={openSettingsVisible}
+        onClose={() => 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 ? (
         <>
           <TouchableOpacity
             style={[ styles.cornerButton, styles.topLeftButton, styles.closeLeftButton ]}
@@ -210,7 +425,10 @@ const MapScreen: React.FC<MapScreenProps> = ({ navigation }) => {
             <Text style={styles.textClose}>Close</Text>
           </TouchableOpacity>
 
-          <TouchableOpacity style={[ styles.cornerButton, styles.topRightButton, styles.bottomButton ]}>
+          <TouchableOpacity
+            onPress={handleGetLocation}
+            style={[ styles.cornerButton, styles.topRightButton, styles.bottomButton ]}
+          >
             <LocationIcon />
           </TouchableOpacity>
 
@@ -234,7 +452,10 @@ const MapScreen: React.FC<MapScreenProps> = ({ navigation }) => {
             <RadarIcon />
           </TouchableOpacity>
 
-          <TouchableOpacity style={[styles.cornerButton, styles.bottomButton, styles.bottomRightButton]}>
+          <TouchableOpacity
+            onPress={handleGetLocation}
+            style={[styles.cornerButton, styles.bottomButton, styles.bottomRightButton]}
+          >
             <LocationIcon />
           </TouchableOpacity>
         </>

+ 18 - 0
src/screens/InAppScreens/MapScreen/style.tsx

@@ -60,4 +60,22 @@ export const styles = StyleSheet.create({
     fontWeight: '500',
     lineHeight: 14,
   },
+  location: {
+    width: 18,
+    height: 18,
+    borderRadius: 9,
+    borderColor: 'white',
+    backgroundColor: '#ED9334',
+    scale: 1,
+    shadow: {
+      shadowColor: "#212529",
+      shadowOffset: {
+        width: 0,
+        height: 4,
+      },
+      shadowOpacity: 0.12,
+      shadowRadius: 8,
+      elevation: 5,
+    },
+  },
 });

+ 6 - 3
src/types/api.ts

@@ -2,7 +2,8 @@ import { AxiosError } from 'axios';
 
 export enum API_ROUTE {
   USER = 'user',
-  REGIONS = 'regions'
+  REGIONS = 'regions',
+  SERIES = 'series'
 }
 
 export enum API_ENDPOINT {
@@ -10,7 +11,8 @@ export enum API_ENDPOINT {
   REGISTER = 'join',
   RESET_PASSWORD = 'recover-password',
   GET_REGIONS = 'get-regions',
-  JOIN_TEST = 'pre-join-test'
+  JOIN_TEST = 'pre-join-test',
+  SERIES = 'get-for-regions'
 }
 
 export enum API {
@@ -18,7 +20,8 @@ export enum API {
   REGISTER = `${API_ROUTE.USER}/${API_ENDPOINT.REGISTER}`,
   RESET_PASSWORD = `${API_ROUTE.USER}/${API_ENDPOINT.RESET_PASSWORD}`,
   GET_REGIONS = `${API_ROUTE.REGIONS}/${API_ENDPOINT.GET_REGIONS}`,
-  JOIN_TEST = `${API_ROUTE.USER}/${API_ENDPOINT.JOIN_TEST}`
+  JOIN_TEST = `${API_ROUTE.USER}/${API_ENDPOINT.JOIN_TEST}`,
+  SERIES = `${API_ROUTE.SERIES}/${API_ENDPOINT.SERIES}`
 }
 
 export type BaseAxiosError = AxiosError;

+ 67 - 0
src/types/map/index.ts

@@ -0,0 +1,67 @@
+import { BottomTabNavigationProp } from "@react-navigation/bottom-tabs";
+import { Feature } from '@turf/turf';
+
+export interface Region {
+  id: number;
+  name: string;
+  region_photos: string;
+  visitors_count: number;
+}
+
+export interface MapScreenProps {
+  navigation: BottomTabNavigationProp<any>;
+}
+
+export interface Series {
+  id: number;
+  name: string;
+  icon: string;
+}
+
+export interface ItemSeries {
+  id: number;
+  name: string;
+  pointJSON: any;
+  polygonJSON: string;
+  region: number;
+  series_id: number;
+  visited?: 0 | 1;
+}
+
+export interface MarkerData {
+  properties: {
+    dbscan?: string;
+    cluster?: number,
+    id: number;
+    series_id: number;
+    name: string;
+    pointJSON: any;
+    polygonJSON: string;
+    region: number;
+    visited?: 0 | 1;
+  };
+  geometry: {
+    coordinates: [number, number];
+    type: string;
+  };
+  type: string;
+  }
+  
+  export interface ClusterData {
+    [key: string]: { center: number[], size: number }
+  }
+
+  export type FeatureCollection = {
+    type: "FeatureCollection";
+    features: Feature[];
+  };
+
+  export interface RegionData {
+    type?: string;
+    name?: string;
+    crs?: {
+      type: string;
+      properties: { name: string; };
+    };
+    features?: any;
+  }  

+ 97 - 10
src/utils/mapHelpers.ts

@@ -1,15 +1,6 @@
 import * as turf from '@turf/turf';
 import { Feature } from '@turf/turf';
-
-interface RegionData {
-  type?: string;
-  name?: string;
-  crs?: {
-    type: string;
-    properties: { name: string; };
-  };
-  features?: any;
-}
+import { ItemSeries, ClusterData, RegionData } from '../types/map';
 
 export const findRegionInDataset = (dataset: RegionData, point: turf.helpers.Position | turf.helpers.Point | turf.helpers.Feature<turf.helpers.Point, turf.helpers.Properties>): Feature | undefined => {
   return dataset.features.find((region: any) => {
@@ -42,3 +33,99 @@ export const calculateMapRegion = (bounds: turf.BBox): any => {
     longitudeDelta: Math.abs(bounds[2] - bounds[0]) + padding,
   };
 };
+
+export const clusterMarkers = (markers: ItemSeries[], currentZoom: number, setClusters: React.Dispatch<React.SetStateAction<ClusterData | null>>) => {
+  if (!markers?.length) return [];
+  
+  const points = turf.featureCollection(markers.map(marker =>
+    turf.point([+marker.pointJSON[1], +marker.pointJSON[0]], { ...marker })
+  ));
+
+  const distance = currentZoom < 7 ? 280 : currentZoom < 9 ? 100 : 35;
+  const maxDistance = Math.max(0.1, distance * Math.pow(0.5, currentZoom / 2));
+
+  const clustered = turf.clustersDbscan(points, maxDistance, { minPoints: 11 });
+
+  const clustersData: { [key: string]: { center: number[], size: number } } = {};
+  turf.clusterEach(clustered, 'cluster', (cluster, clusterValue) => {
+    const clusterCenter = turf.center(cluster as turf.AllGeoJSON);
+    const clusterSize = cluster?.features.length ?? 0;
+    
+    clustersData[clusterValue] = {
+      center: clusterCenter.geometry.coordinates,
+      size: clusterSize
+    };
+  });
+
+  setClusters(clustersData);
+
+  return clustered.features;
+};
+
+const isBBoxOverlap = (bbox1: number[], bbox2: number[]) => {
+  return bbox1[0] <= bbox2[2] &&
+         bbox1[2] >= bbox2[0] &&
+         bbox1[1] <= bbox2[3] &&
+         bbox1[3] >= bbox2[1];
+};
+
+export const filterCandidates = (candidates: { features?: any; }, areaPolygon: turf.BBox) => {
+  const visibleAreaPolygon = turf.bboxPolygon(areaPolygon);
+
+  const candidateRegions = candidates.features.filter((feature: any) => {
+    const featureBBox = turf.bbox(feature);
+    return isBBoxOverlap(featureBBox, areaPolygon);
+  });
+
+  return candidateRegions.filter((feature: turf.helpers.Geometry | turf.helpers.Feature<any, turf.helpers.Properties>) => {
+    return turf.booleanIntersects(feature, visibleAreaPolygon) || 
+    turf.booleanOverlap(feature, visibleAreaPolygon);
+  });
+};
+
+export const processMarkerData = (marker: ItemSeries) => {
+  let coordinates = null;
+  if (marker.pointJSON) {
+    coordinates = JSON.parse(marker.pointJSON);
+  }
+  else if (marker.polygonJSON) {
+    const polygon = JSON.parse(marker.polygonJSON);
+    const polygonFeature = turf.polygon(polygon);
+    const center = turf.center(polygonFeature);
+    coordinates = center.geometry.coordinates;
+  }
+  return {
+    ...marker,
+    pointJSON: coordinates ?? null,
+  };
+};
+
+export const processIconUrl = (url: string) => {
+  let processedUrl = url.startsWith('/static') ? url.substring(7) : url;
+
+  if (processedUrl.endsWith('.png.png')) {
+    processedUrl = processedUrl.substring(0, processedUrl.length - 4);
+  }
+
+  return `https://nomadmania.eu${processedUrl}`;
+};
+
+export const filterCandidatesMarkers = (candidatesMarkers: any[], visibleAreaPolygon: turf.helpers.Geometry | turf.helpers.Feature<any, turf.helpers.Properties>) => (
+  candidatesMarkers.filter((marker: { pointJSON: string; polygonJSON: string; }) => {
+    if (marker.pointJSON) {
+      const coordinates = JSON.parse(marker.pointJSON);
+      const point = turf.point([coordinates[1], coordinates[0]]);
+      return turf.booleanPointInPolygon(point, visibleAreaPolygon);
+    }
+    
+    else if (marker.polygonJSON) {
+      const polygonCoords = JSON.parse(marker.polygonJSON);
+      const polygon = turf.polygon(polygonCoords);
+      return turf.booleanOverlap(polygon, visibleAreaPolygon) || 
+            turf.booleanContains(visibleAreaPolygon, polygon) ||
+            turf.booleanWithin(polygon, visibleAreaPolygon);
+    }
+    
+    return false;
+  }
+));

Some files were not shown because too many files changed in this diff