From 59b997bff8c2d3d255c0e4ba2b797d036a067c96 Mon Sep 17 00:00:00 2001 From: Jaewon Lee <58386334+jaewonLeeKOR@users.noreply.github.com> Date: Thu, 22 Jan 2026 18:10:38 +0900 Subject: [PATCH 01/33] =?UTF-8?q?[#36]=20feat=20:=20useLocation=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/index.tsx | 4 +-- app/(tabs)/myRoute.tsx | 2 -- app/(tabs)/story/spotLocationSelected.tsx | 3 ++- app/_layout.tsx | 15 ++++++++++- src/components/map/Map.tsx | 5 +++- src/components/map/RouteMap.tsx | 5 +++- src/components/map/StorySpotMap.tsx | 5 +++- .../myRoute/sight/MyRouteSightCell.tsx | 5 ++-- src/hooks/useLocation.ts | 26 ++++++++----------- src/store/useLocationStore.ts | 22 ++++++++++++++++ 10 files changed, 65 insertions(+), 27 deletions(-) create mode 100644 src/store/useLocationStore.ts diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 833923a..cff134a 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -12,12 +12,12 @@ import CustomBottomSheet from "@/components/bottomSheet/CustomBottomSheet"; import Map from "@/components/map/Map"; import SightDetailCard from "@/components/sight/SightDetailCard"; -import { useLocation } from "@/hooks/useLocation"; import { useSightMap } from "@/hooks/useSightMap"; import { MapRef } from "@/types/map"; import { SightInfo } from "@/types/sight"; +import {useLocationStore} from "@/store/useLocationStore"; import { RouteCartItem, useRouteCartStore } from "@/store/useRouteCartStore"; export default function Index() { @@ -25,7 +25,7 @@ export default function Index() { const animatedPosition = useSharedValue(0); const mapRef = useRef(null); - const { location } = useLocation(); + const location = useLocationStore(state => state.location); const { insertRouteCartItem, removeRouteCartItem, routeCartItems } = useRouteCartStore(); diff --git a/app/(tabs)/myRoute.tsx b/app/(tabs)/myRoute.tsx index 539fe46..a98c0ff 100644 --- a/app/(tabs)/myRoute.tsx +++ b/app/(tabs)/myRoute.tsx @@ -15,7 +15,6 @@ import OnTourButton from "@/components/myRoute/button/OnTourButton"; import PreTourButton from "@/components/myRoute/button/PreTourButton"; import MyRouteSightList from "@/components/myRoute/sight/MyRouteSightList"; -import { useLocation } from "@/hooks/useLocation"; import { useSightMap } from "@/hooks/useSightMap"; import { MapRef } from "@/types/map"; @@ -32,7 +31,6 @@ export default function MyRoute() { const bottomSheetRef = useRef(null); const animatedPosition = useSharedValue(0); const mapRef = useRef(null); - const { location } = useLocation(); const routeCartItems = useRouteCartStore((state) => state.routeCartItems); const isOnTour = useMyRouteBottomSheetStore((state) => state.isOnTour); const isPreTour = useMyRouteBottomSheetStore((state) => state.isPreTour); diff --git a/app/(tabs)/story/spotLocationSelected.tsx b/app/(tabs)/story/spotLocationSelected.tsx index 7355212..339aef3 100644 --- a/app/(tabs)/story/spotLocationSelected.tsx +++ b/app/(tabs)/story/spotLocationSelected.tsx @@ -26,6 +26,7 @@ import MapPin from "@/assets/icons/map/MapPin.svg"; import { useStoryAddStore } from "@/store/story/useStoryAddStore"; import { useStoryStore } from "@/store/story/useStoryStore"; import { useAuthStore } from "@/store/useAuthStore"; +import {useLocationStore} from "@/store/useLocationStore"; export default function SpotLocationSelected() { const { spotLocationInMap, mainStoryMapRequest } = useStoryStore(); @@ -52,7 +53,7 @@ export default function SpotLocationSelected() { } = useStoryAddStore(); const { isLogined } = useAuthStore(); - const { location } = useLocation(); + const location = useLocationStore(state => state.location); const { sights, selectedSight } = useSightMap(); const Ref = useRef(null); diff --git a/app/_layout.tsx b/app/_layout.tsx index 57f2667..1409f88 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -10,6 +10,8 @@ import * as TaskManager from "expo-task-manager"; import * as Updates from "expo-updates"; import { ThemeProvider } from "styled-components"; +import {useLocation} from "@/hooks/useLocation"; + import { theme } from "@/styles/theme"; import { GEOFENCE_TASK } from "@/constants/taskManagerTaskKeys"; @@ -38,6 +40,7 @@ export default function RootLayout() { // EAS ota Upadte useEffect(() => { + if(__DEV__) return; const checkForUpdates = async () => { try { const update = await Updates.checkForUpdateAsync(); @@ -62,19 +65,29 @@ export default function RootLayout() { }); }, []); + // 클라이언트 위치 초기화 + const {getCurrentLocation} = useLocation(); + useEffect(() => { if (fontsLoaded) { (Text as any).defaultProps = (Text as any).defaultProps || {}; (Text as any).defaultProps.style = { fontFamily: "Pretendard-Regular", }; - SplashScreen.hideAsync(); } }, [fontsLoaded]); if (!fontsLoaded) { return null; } + else { + getCurrentLocation().finally(() => { + // 클라이언트 위치 초기화 & 맵 로딩 완료 후 스플래시 스크린 제거 + setTimeout(() => { + SplashScreen.hideAsync(); + }, 500); + }) + } return ( diff --git a/src/components/map/Map.tsx b/src/components/map/Map.tsx index 582b193..0656acc 100644 --- a/src/components/map/Map.tsx +++ b/src/components/map/Map.tsx @@ -21,6 +21,8 @@ import { SightInfo } from "@/types/sight"; import { theme } from "@/styles/theme"; +import {useLocationStore} from "@/store/useLocationStore"; + const LOCATION_BUTTON_SIZE = 48; const LOCATION_BUTTON_MARGIN = 16; @@ -59,7 +61,8 @@ const Map = forwardRef( ref, ) => { const mapRef = useRef(null); - const { location, isLoading, getCurrentLocation } = useLocation(); + const { isLoading, getCurrentLocation } = useLocation(); + const location = useLocationStore(state => state.location); useImperativeHandle(ref, () => ({ getBoundaries: async () => { diff --git a/src/components/map/RouteMap.tsx b/src/components/map/RouteMap.tsx index 8fe3c61..247abff 100644 --- a/src/components/map/RouteMap.tsx +++ b/src/components/map/RouteMap.tsx @@ -21,6 +21,8 @@ import { SightInfo } from "@/types/sight"; import { theme } from "@/styles/theme"; +import {useLocationStore} from "@/store/useLocationStore"; + const LOCATION_BUTTON_SIZE = 48; const LOCATION_BUTTON_MARGIN = 16; @@ -46,7 +48,8 @@ const RouteMap = forwardRef(function RouteMapComponent( ref, ) { const mapRef = useRef(null); - const { location, isLoading, getCurrentLocation } = useLocation(); + const { isLoading, getCurrentLocation } = useLocation(); + const location = useLocationStore(state => state.location); useImperativeHandle(ref, () => ({ getBoundaries: async () => { diff --git a/src/components/map/StorySpotMap.tsx b/src/components/map/StorySpotMap.tsx index 5ccea08..5b8b1ec 100644 --- a/src/components/map/StorySpotMap.tsx +++ b/src/components/map/StorySpotMap.tsx @@ -22,6 +22,8 @@ import { MapSpotInfoItem } from "@/types/storySpot"; import { theme } from "@/styles/theme"; +import {useLocationStore} from "@/store/useLocationStore"; + const LOCATION_BUTTON_SIZE = 48; const LOCATION_BUTTON_MARGIN = 16; @@ -69,7 +71,8 @@ const StorySpotMap = forwardRef( ref ) => { const mapRef = useRef(null); - const { location, isLoading, getCurrentLocation } = useLocation(); + const { isLoading, getCurrentLocation } = useLocation(); + const location = useLocationStore(state => state.location); useImperativeHandle(ref, () => ({ getBoundaries: async () => { diff --git a/src/components/myRoute/sight/MyRouteSightCell.tsx b/src/components/myRoute/sight/MyRouteSightCell.tsx index ba3f856..2093cdb 100644 --- a/src/components/myRoute/sight/MyRouteSightCell.tsx +++ b/src/components/myRoute/sight/MyRouteSightCell.tsx @@ -6,14 +6,13 @@ import { Feather } from "@expo/vector-icons"; import { getDistance } from "geolib"; import { MapPinPlusIcon } from "lucide-react-native"; -import { useLocation } from "@/hooks/useLocation"; - import { theme } from "@/styles/theme"; import { useMyRouteBottomSheetStore } from "@/store/useMyRouteBottomSheetStore"; import { RouteCartItem } from "@/store/useRouteCartStore"; import { distanceToString } from "@/util/locationUtil"; import View = Animated.View; +import {useLocationStore} from "@/store/useLocationStore"; interface MyRouteSightCellProps { routeCartItem: RouteCartItem; @@ -22,7 +21,7 @@ interface MyRouteSightCellProps { const MyRouteSightCell: React.FC = ({ routeCartItem, }) => { - const { location } = useLocation(); + const location = useLocationStore(state => state.location); const distance = getDistance( { latitude: location.latitude, longitude: location.longitude }, { diff --git a/src/hooks/useLocation.ts b/src/hooks/useLocation.ts index e0570bd..1c531ce 100644 --- a/src/hooks/useLocation.ts +++ b/src/hooks/useLocation.ts @@ -1,7 +1,11 @@ import { useCallback, useEffect, useState } from "react"; +import {Region} from "react-native-maps"; + import * as Location from "expo-location"; +import {useLocationStore} from "@/store/useLocationStore"; + const SEOUL_CITY_HALL = { latitude: 37.5666805, longitude: 126.9784147, @@ -10,21 +14,12 @@ const SEOUL_CITY_HALL = { }; export interface CurrentLocation { - location: { - latitude: number; - longitude: number; - }; isLoading: boolean; - getCurrentLocation: () => Promise<{ - latitude: number; - longitude: number; - latitudeDelta: number; - longitudeDelta: number; - }>; + getCurrentLocation: () => Promise; } export const useLocation = (): CurrentLocation => { - const [location, setLocation] = useState(SEOUL_CITY_HALL); + const {setLocation} = useLocationStore(); const [isLoading, setIsLoading] = useState(true); const getCurrentLocation = useCallback(async () => { @@ -36,15 +31,17 @@ export const useLocation = (): CurrentLocation => { } const current = await Location.getCurrentPositionAsync({ - accuracy: Location.Accuracy.Balanced, + accuracy: Location.Accuracy.BestForNavigation, }); - return { + const currentRegion = { latitude: current.coords.latitude, longitude: current.coords.longitude, latitudeDelta: 0.01, longitudeDelta: 0.01, - }; + } as Region; + setLocation(currentRegion); + return currentRegion; } catch (error) { return SEOUL_CITY_HALL; } @@ -60,7 +57,6 @@ export const useLocation = (): CurrentLocation => { }, [getCurrentLocation]); return { - location, isLoading, getCurrentLocation, }; diff --git a/src/store/useLocationStore.ts b/src/store/useLocationStore.ts new file mode 100644 index 0000000..eab9e5a --- /dev/null +++ b/src/store/useLocationStore.ts @@ -0,0 +1,22 @@ +import {LatLng} from "react-native-maps"; + +import {create} from "zustand"; + +const SEOUL_CITY_HALL = { + latitude: 37.5666805, + longitude: 126.9784147, + latitudeDelta: 0.01, + longitudeDelta: 0.01, +}; + +interface LocationStore { + location: LatLng; + setLocation: (location: LatLng) => void; +} + +export const useLocationStore = create((set,get) => ({ + location: SEOUL_CITY_HALL, + setLocation: (location) => { + set({location}); + }, +})) From 34cc44682c7152a9079cd944f6e2b6e572ca18ba Mon Sep 17 00:00:00 2001 From: Jaewon Lee <58386334+jaewonLeeKOR@users.noreply.github.com> Date: Thu, 22 Jan 2026 20:53:29 +0900 Subject: [PATCH 02/33] =?UTF-8?q?[#36]=20feat=20:=20=EC=A7=80=EB=8F=84=20S?= =?UTF-8?q?afeAreaView=20=ED=95=B4=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/docent.tsx | 14 ++++++++++---- app/(tabs)/myPage/changePassword.tsx | 2 +- app/(tabs)/myPage/editProfile.tsx | 6 +++--- app/(tabs)/myPage/forgotPassword.tsx | 2 +- app/(tabs)/myPage/index.tsx | 2 +- app/(tabs)/myPage/login.tsx | 2 +- app/(tabs)/myPage/notice.tsx | 2 +- app/(tabs)/myPage/signup.tsx | 2 +- app/(tabs)/myPage/socialSignup.tsx | 8 ++++---- app/(tabs)/myPage/terms.tsx | 2 +- app/(tabs)/story/spotLocationSelected.tsx | 3 +-- app/(tabs)/story/storyAdd.tsx | 2 +- app/_layout.tsx | 16 +++++++--------- 13 files changed, 33 insertions(+), 30 deletions(-) diff --git a/app/(tabs)/docent.tsx b/app/(tabs)/docent.tsx index d5d14c7..8be8ec6 100644 --- a/app/(tabs)/docent.tsx +++ b/app/(tabs)/docent.tsx @@ -1,3 +1,6 @@ +import {StyleSheet} from "react-native"; +import {SafeAreaView} from "react-native-safe-area-context"; + import EmptyTour from "@/components/docent/emptyTour/EmptyTour"; import OnTour from "@/components/docent/onTour/OnTour"; @@ -6,8 +9,11 @@ import { useRouteStore } from "@/store/useRouteStore"; export default function Docent() { const { routeItems } = useRouteStore(); - if (routeItems === undefined) { - return ; - } - return ; + return ( + + { + routeItems === undefined ? : + } + + ) } diff --git a/app/(tabs)/myPage/changePassword.tsx b/app/(tabs)/myPage/changePassword.tsx index b94c896..e4f474d 100644 --- a/app/(tabs)/myPage/changePassword.tsx +++ b/app/(tabs)/myPage/changePassword.tsx @@ -159,7 +159,7 @@ const ApiErrorText = styled.Text` margin-top: 15px; text-align: center; `; -const Container = styled.View` +const Container = styled.SafeAreaView` flex: 1; background-color: ${theme.colors.white}; `; diff --git a/app/(tabs)/myPage/editProfile.tsx b/app/(tabs)/myPage/editProfile.tsx index e6edff2..3b8cf80 100644 --- a/app/(tabs)/myPage/editProfile.tsx +++ b/app/(tabs)/myPage/editProfile.tsx @@ -67,8 +67,8 @@ export default function EditProfile() { /> ) : ( - - )} + + )} {profileImageError && {profileImageError}} @@ -223,7 +223,7 @@ export default function EditProfile() { ); } -const Container = styled.View` +const Container = styled.SafeAreaView` flex: 1; background-color: ${theme.colors.white}; `; diff --git a/app/(tabs)/myPage/forgotPassword.tsx b/app/(tabs)/myPage/forgotPassword.tsx index cb6e602..5937325 100644 --- a/app/(tabs)/myPage/forgotPassword.tsx +++ b/app/(tabs)/myPage/forgotPassword.tsx @@ -259,7 +259,7 @@ export default function ForgotPassword() { ); } -const Container = styled.View` +const Container = styled.SafeAreaView` flex: 1; background-color: ${theme.colors.white}; `; diff --git a/app/(tabs)/myPage/index.tsx b/app/(tabs)/myPage/index.tsx index 13089ba..253198b 100644 --- a/app/(tabs)/myPage/index.tsx +++ b/app/(tabs)/myPage/index.tsx @@ -141,7 +141,7 @@ function LoggedInView({ ); } -const Container = styled.View` +const Container = styled.SafeAreaView` flex: 1; background-color: ${theme.colors.white}; `; diff --git a/app/(tabs)/myPage/login.tsx b/app/(tabs)/myPage/login.tsx index 482409c..985da42 100644 --- a/app/(tabs)/myPage/login.tsx +++ b/app/(tabs)/myPage/login.tsx @@ -114,7 +114,7 @@ const ErrorText = styled.Text` margin-top: 10px; `; -const Container = styled.View` +const Container = styled.SafeAreaView` flex: 1; background-color: ${theme.colors.white}; justify-content: center; diff --git a/app/(tabs)/myPage/notice.tsx b/app/(tabs)/myPage/notice.tsx index f246bbc..548de48 100644 --- a/app/(tabs)/myPage/notice.tsx +++ b/app/(tabs)/myPage/notice.tsx @@ -8,7 +8,7 @@ export default function Notice() { ); } -const Container = styled.View` +const Container = styled.SafeAreaView` font-family: ${({ theme }) => theme.typography.fontFamily.regular}; flex: 1; justify-content: "center"; diff --git a/app/(tabs)/myPage/signup.tsx b/app/(tabs)/myPage/signup.tsx index fe366b7..89607ed 100644 --- a/app/(tabs)/myPage/signup.tsx +++ b/app/(tabs)/myPage/signup.tsx @@ -244,7 +244,7 @@ export default function SignUp() { ); } -const Container = styled.View` +const Container = styled.SafeAreaView` flex: 1; background-color: ${theme.colors.white}; `; diff --git a/app/(tabs)/myPage/socialSignup.tsx b/app/(tabs)/myPage/socialSignup.tsx index 5d7e816..a5a3b52 100644 --- a/app/(tabs)/myPage/socialSignup.tsx +++ b/app/(tabs)/myPage/socialSignup.tsx @@ -15,9 +15,9 @@ import { useAuthStore } from "@/store/useAuthStore"; export default function SocialSignUp() { const router = useRouter(); - const params = useLocalSearchParams<{ - email: string; - provider: string; + const params = useLocalSearchParams<{ + email: string; + provider: string; tempToken: string; }>(); const { socialSignup } = useAuthStore(); @@ -77,7 +77,7 @@ export default function SocialSignUp() { ); } -const Container = styled.View` +const Container = styled.SafeAreaView` flex: 1; background-color: ${theme.colors.white}; `; diff --git a/app/(tabs)/myPage/terms.tsx b/app/(tabs)/myPage/terms.tsx index 54d83c9..5e6f976 100644 --- a/app/(tabs)/myPage/terms.tsx +++ b/app/(tabs)/myPage/terms.tsx @@ -8,7 +8,7 @@ export default function Terms() { ); } -const Container = styled.View` +const Container = styled.SafeAreaView` font-family: ${({ theme }) => theme.typography.fontFamily.regular}; flex: 1; justify-content: "center"; diff --git a/app/(tabs)/story/spotLocationSelected.tsx b/app/(tabs)/story/spotLocationSelected.tsx index 339aef3..82b3af8 100644 --- a/app/(tabs)/story/spotLocationSelected.tsx +++ b/app/(tabs)/story/spotLocationSelected.tsx @@ -17,7 +17,6 @@ import SpotNameAdd from "@/components/storyAdd/SpotNameAdd"; import { useCustomPinNavigation } from "@/hooks/story/useCustomPinNavigation"; import { useStorySpotMap } from "@/hooks/story/useStorySpotMap"; -import { useLocation } from "@/hooks/useLocation"; import { useSightMap } from "@/hooks/useSightMap"; import { theme } from "@/styles/theme"; @@ -222,7 +221,7 @@ const styles = StyleSheet.create({ }, }); -const Header = styled.View` +const Header = styled.SafeAreaView` position: absolute; top: 5px; left: 0; diff --git a/app/(tabs)/story/storyAdd.tsx b/app/(tabs)/story/storyAdd.tsx index acda6c9..b7fd7f4 100644 --- a/app/(tabs)/story/storyAdd.tsx +++ b/app/(tabs)/story/storyAdd.tsx @@ -187,7 +187,7 @@ export default function StoryAdd() { ); } -const Container = styled.View` +const Container = styled.SafeAreaView` flex: 1; gap: 10px; background-color: ${theme.colors.background.background300}; diff --git a/app/_layout.tsx b/app/_layout.tsx index 1409f88..bd2be41 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -92,15 +92,13 @@ export default function RootLayout() { return ( - - - - + + From bbd972b5c9273d36d4219dd22258db48a22d6d8d Mon Sep 17 00:00:00 2001 From: Jaewon Lee <58386334+jaewonLeeKOR@users.noreply.github.com> Date: Tue, 3 Feb 2026 16:13:57 +0900 Subject: [PATCH 03/33] =?UTF-8?q?[#36]=20feat=20:=20=ED=81=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EC=9C=84=EC=B9=98=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EB=B0=A9=EC=8B=9D=20watch=20=EB=B0=A9=EC=8B=9D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/index.tsx | 2 +- app/_layout.tsx | 35 ++++++++--- src/components/curation/CurationDetail.tsx | 4 +- src/components/map/Map.tsx | 10 +--- src/components/map/RouteMap.tsx | 10 +--- src/components/map/StorySpotMap.tsx | 14 ++--- src/hooks/sight/useCurationDetail.ts | 3 +- src/hooks/useLocation.ts | 68 +++++++--------------- src/store/useLocationStore.ts | 5 +- 9 files changed, 63 insertions(+), 88 deletions(-) diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 9c9b447..e8e2e83 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -20,9 +20,9 @@ import { useSightMap } from "@/hooks/useSightMap"; import { MapRef } from "@/types/map"; import { SightInfo } from "@/types/sight"; -import {useLocationStore} from "@/store/useLocationStore"; import { theme } from "@/styles/theme"; +import { useLocationStore } from "@/store/useLocationStore"; import { RouteCartItem, useRouteCartStore } from "@/store/useRouteCartStore"; export default function Index() { diff --git a/app/_layout.tsx b/app/_layout.tsx index bd2be41..50cfe99 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -4,6 +4,7 @@ import { GestureHandlerRootView } from "react-native-gesture-handler"; import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; import { useFonts } from "expo-font"; +import {LocationSubscription} from "expo-location"; import { Stack } from "expo-router"; import * as SplashScreen from "expo-splash-screen"; import * as TaskManager from "expo-task-manager"; @@ -65,8 +66,24 @@ export default function RootLayout() { }); }, []); - // 클라이언트 위치 초기화 - const {getCurrentLocation} = useLocation(); + // 클라이언트 위치 초기화 및 구독 + const {isPositionLoading, watchPositionAsync} = useLocation(); + useEffect(() => { + let subscription: LocationSubscription | undefined; + + (async () => { + subscription = await watchPositionAsync().finally(() => { + // 클라이언트 위치 초기화 & 맵 로딩 완료 후 스플래시 스크린 제거 + setTimeout(() => { + SplashScreen.hideAsync(); + }, 500); + }) + })(); + + return () => { + subscription?.remove(); + } + }, []); useEffect(() => { if (fontsLoaded) { @@ -77,16 +94,16 @@ export default function RootLayout() { } }, [fontsLoaded]); - if (!fontsLoaded) { - return null; - } - else { - getCurrentLocation().finally(() => { - // 클라이언트 위치 초기화 & 맵 로딩 완료 후 스플래시 스크린 제거 + useEffect(() => { + if(!isPositionLoading && fontsLoaded) { setTimeout(() => { SplashScreen.hideAsync(); }, 500); - }) + } + }, [isPositionLoading, fontsLoaded]); + + if (!fontsLoaded) { + return null; } return ( diff --git a/src/components/curation/CurationDetail.tsx b/src/components/curation/CurationDetail.tsx index 9f61bff..bd6653f 100644 --- a/src/components/curation/CurationDetail.tsx +++ b/src/components/curation/CurationDetail.tsx @@ -11,11 +11,11 @@ import { CurationSightList, SightInfo } from "@/types/sight"; import { theme } from "@/styles/theme"; import { useHeaderButtonStore } from "@/store/useHeaderButtonStore"; +import {useLocationStore} from "@/store/useLocationStore"; import { useRouteCartStore } from "@/store/useRouteCartStore"; import HeaderButton from "../common/HeaderButton"; import SightCard from "../common/SightCard"; -import {useLocationStore} from "@/store/useLocationStore"; type CurationDetailProps = { curationSightList: CurationSightList[]; @@ -46,7 +46,7 @@ const CurationDetail: React.FC = ({ } = useHeaderButtonStore(); const { isInCart } = useCurationDetail(); const { insertRouteCartItem, removeRouteCartItem } = useRouteCartStore(); - const { getCurrentLocation } = useLocation(); + const location = useLocationStore(state => state.location); //헤더 렌더링 useEffect(() => { diff --git a/src/components/map/Map.tsx b/src/components/map/Map.tsx index 0656acc..eea2aa3 100644 --- a/src/components/map/Map.tsx +++ b/src/components/map/Map.tsx @@ -61,7 +61,6 @@ const Map = forwardRef( ref, ) => { const mapRef = useRef(null); - const { isLoading, getCurrentLocation } = useLocation(); const location = useLocationStore(state => state.location); useImperativeHandle(ref, () => ({ @@ -84,9 +83,8 @@ const Map = forwardRef( })); const moveToCurrentLocation = useCallback(async () => { - const coords = await getCurrentLocation(); - mapRef.current?.animateToRegion(coords, 300); - }, [getCurrentLocation]); + mapRef.current?.animateToRegion({...location, latitudeDelta: 0.01, longitudeDelta: 0.01}, 300); + }, [location]); const handleRegionChangeComplete = useCallback(async () => { if (!mapRef.current || !onRegionChangeComplete) return; @@ -113,10 +111,6 @@ const Map = forwardRef( return { top: 0, transform: [{ translateY }] }; }); - if (isLoading) { - return null; - } - return ( <> (function RouteMapComponent( ref, ) { const mapRef = useRef(null); - const { isLoading, getCurrentLocation } = useLocation(); const location = useLocationStore(state => state.location); useImperativeHandle(ref, () => ({ @@ -71,9 +70,8 @@ const RouteMap = forwardRef(function RouteMapComponent( })); const moveToCurrentLocation = useCallback(async () => { - const coords = await getCurrentLocation(); - mapRef.current?.animateToRegion(coords, 300); - }, [getCurrentLocation]); + mapRef.current?.animateToRegion({...location, latitudeDelta: 0.01, longitudeDelta: 0.01}, 300); + }, [location]); const buttonAnimatedStyle = useAnimatedStyle(() => { if (!animatedPosition) { @@ -85,10 +83,6 @@ const RouteMap = forwardRef(function RouteMapComponent( return { top: 0, transform: [{ translateY }] }; }); - if (isLoading) { - return null; - } - return ( <> ( ref ) => { const mapRef = useRef(null); - const { isLoading, getCurrentLocation } = useLocation(); const location = useLocationStore(state => state.location); useImperativeHandle(ref, () => ({ @@ -94,15 +93,16 @@ const StorySpotMap = forwardRef( })); const moveToCurrentLocation = useCallback(async () => { - const coords = await getCurrentLocation(); mapRef.current?.animateToRegion( { - ...coords, - latitude: coords.latitude - 0.003, + ...location, + latitude: location.latitude - 0.003, + longitudeDelta: 0.01, + latitudeDelta: 0.01, }, 300 ); - }, [getCurrentLocation]); + }, [location]); const handleRegionChangeComplete = useCallback(async () => { if (!mapRef.current || !onRegionChangeComplete) return; @@ -129,10 +129,6 @@ const StorySpotMap = forwardRef( return { top: 0, transform: [{ translateY }] }; }); - if (isLoading) { - return null; - } - return ( <> { - const { getCurrentLocation } = useLocation(); + const location = useLocationStore(state => state.location); const { insertRouteCartItem } = useRouteCartStore(); const router = useRouter(); diff --git a/src/hooks/useLocation.ts b/src/hooks/useLocation.ts index 1c531ce..ea7fcdc 100644 --- a/src/hooks/useLocation.ts +++ b/src/hooks/useLocation.ts @@ -1,63 +1,37 @@ -import { useCallback, useEffect, useState } from "react"; - -import {Region} from "react-native-maps"; +import {useCallback, useState} from "react"; import * as Location from "expo-location"; +import {LocationAccuracy, LocationSubscription} from "expo-location"; import {useLocationStore} from "@/store/useLocationStore"; -const SEOUL_CITY_HALL = { - latitude: 37.5666805, - longitude: 126.9784147, - latitudeDelta: 0.01, - longitudeDelta: 0.01, -}; - export interface CurrentLocation { - isLoading: boolean; - getCurrentLocation: () => Promise; + isPositionLoading: boolean; + watchPositionAsync: () => Promise; } export const useLocation = (): CurrentLocation => { - const {setLocation} = useLocationStore(); - const [isLoading, setIsLoading] = useState(true); - - const getCurrentLocation = useCallback(async () => { - try { - const { status } = await Location.requestForegroundPermissionsAsync(); + const [isPositionLoading, setIsPositionLoading] = useState(true); - if (status !== "granted") { - return SEOUL_CITY_HALL; - } - - const current = await Location.getCurrentPositionAsync({ - accuracy: Location.Accuracy.BestForNavigation, - }); - - const currentRegion = { - latitude: current.coords.latitude, - longitude: current.coords.longitude, - latitudeDelta: 0.01, - longitudeDelta: 0.01, - } as Region; - setLocation(currentRegion); - return currentRegion; - } catch (error) { - return SEOUL_CITY_HALL; + const watchPositionAsync = useCallback(async () => { + let {status} = await Location.requestForegroundPermissionsAsync(); + if(status !== 'granted') { + setIsPositionLoading(false); + return; } - }, []); - useEffect(() => { - const init = async () => { - const coords = await getCurrentLocation(); - setLocation(coords); - setIsLoading(false); - }; - init(); - }, [getCurrentLocation]); + return await Location.watchPositionAsync({ + accuracy: LocationAccuracy.BestForNavigation, + timeInterval: 1000, + distanceInterval: 5, + }, (newLocation) => { + setIsPositionLoading(false); + useLocationStore.getState().setLocation(newLocation.coords); + }) + }, []); return { - isLoading, - getCurrentLocation, + isPositionLoading, + watchPositionAsync, }; }; diff --git a/src/store/useLocationStore.ts b/src/store/useLocationStore.ts index eab9e5a..7e987d5 100644 --- a/src/store/useLocationStore.ts +++ b/src/store/useLocationStore.ts @@ -1,17 +1,16 @@ import {LatLng} from "react-native-maps"; +import {LocationObjectCoords} from "expo-location/src/Location.types"; import {create} from "zustand"; const SEOUL_CITY_HALL = { latitude: 37.5666805, longitude: 126.9784147, - latitudeDelta: 0.01, - longitudeDelta: 0.01, }; interface LocationStore { location: LatLng; - setLocation: (location: LatLng) => void; + setLocation: (location: LocationObjectCoords) => void; } export const useLocationStore = create((set,get) => ({ From 227e4be9a796a12296ca9a1bc1ec97e52fa859e0 Mon Sep 17 00:00:00 2001 From: Jaewon Lee <58386334+jaewonLeeKOR@users.noreply.github.com> Date: Tue, 3 Feb 2026 16:15:40 +0900 Subject: [PATCH 04/33] =?UTF-8?q?[#36]=20feat=20:=20=EB=B0=94=ED=85=80=20?= =?UTF-8?q?=EB=84=A4=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20=EB=B0=94=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=EC=BD=98=20=EB=86=92=EC=9D=B4=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=20=ED=95=B4=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/_layout.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 2a5c0ca..c4186a3 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -13,7 +13,6 @@ export default function TabLayout() { screenOptions={{ headerShown: false, tabBarStyle: { - height: 15, paddingTop: 15, paddingHorizontal: 10, borderTopWidth: 1, From a7c342f9d8619499ad1a1c2f208dc02d9a4dae73 Mon Sep 17 00:00:00 2001 From: Jaewon Lee <58386334+jaewonLeeKOR@users.noreply.github.com> Date: Tue, 3 Feb 2026 16:45:50 +0900 Subject: [PATCH 05/33] =?UTF-8?q?[#36]=20feat=20:=20=ED=81=B4=EB=9F=AC?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=EB=A7=81=20=EB=B2=A0=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=A7=80=EB=8F=84=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 68 +++++ package.json | 4 +- src/components/map/BaseMap.tsx | 155 ++++++++++++ .../map/clustering/ClusteredMapView.jsx | 233 ++++++++++++++++++ .../map/clustering/ClusteredMarker.tsx | 121 +++++++++ src/components/map/clustering/helpers.js | 160 ++++++++++++ src/hooks/useBaseMap.ts | 165 +++++++++++++ src/store/useBaseMapStore.ts | 91 +++++++ src/store/useBottomSheetStore.ts | 40 +++ 9 files changed, 1036 insertions(+), 1 deletion(-) create mode 100644 src/components/map/BaseMap.tsx create mode 100644 src/components/map/clustering/ClusteredMapView.jsx create mode 100644 src/components/map/clustering/ClusteredMarker.tsx create mode 100644 src/components/map/clustering/helpers.js create mode 100644 src/hooks/useBaseMap.ts create mode 100644 src/store/useBaseMapStore.ts create mode 100644 src/store/useBottomSheetStore.ts diff --git a/package-lock.json b/package-lock.json index 85cfc70..21d0a34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@expo/vector-icons": "^15.0.3", "@gorhom/bottom-sheet": "^5.2.6", + "@mapbox/geo-viewport": "^0.5.0", "@react-native-community/datetimepicker": "8.4.4", "@react-native-picker/picker": "2.11.1", "@react-navigation/bottom-tabs": "^7.4.0", @@ -59,6 +60,7 @@ "react-native-web": "~0.21.0", "react-native-worklets": "0.5.1", "styled-components": "^6.1.19", + "supercluster": "^8.0.1", "zustand": "^5.0.8" }, "devDependencies": { @@ -119,6 +121,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1500,6 +1503,7 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -4085,6 +4089,26 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mapbox/geo-viewport": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@mapbox/geo-viewport/-/geo-viewport-0.5.0.tgz", + "integrity": "sha512-h0b10JU+lSxw8/TLXGdzcVTPxMN9Ikv0os8sCo0OAHXUiSDkQs5fx4WWLJeQTnC++qaGFl6/Ssr+H5N6NIvE5g==", + "license": "BSD-2-Clause", + "dependencies": { + "@mapbox/sphericalmercator": "^1.2.0" + } + }, + "node_modules/@mapbox/sphericalmercator": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/sphericalmercator/-/sphericalmercator-1.2.0.tgz", + "integrity": "sha512-ZTOuuwGuMOJN+HEmG/68bSEw15HHaMWmQ5gdTsWdWsjDe56K1kGvLOK6bOSC8gWgIvEO0w6un/2Gvv1q5hJSkQ==", + "bin": { + "bbox": "bin/bbox.js", + "to4326": "bin/to4326.js", + "to900913": "bin/to900913.js", + "xyz": "bin/xyz.js" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -4849,6 +4873,7 @@ "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.26.tgz", "integrity": "sha512-RhKmeD0E2ejzKS6z8elAfdfwShpcdkYY8zJzvHYLq+wv183BBcElTeyMLcIX6wIn7QutXeI92Yi21t7aUWfqNQ==", "license": "MIT", + "peer": true, "dependencies": { "@react-navigation/core": "^7.13.7", "escape-string-regexp": "^4.0.0", @@ -5128,6 +5153,7 @@ "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -5407,6 +5433,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.6.tgz", "integrity": "sha512-NNu0sjyNxpoiW3YuVFfNz7mxSQ+S4X2G28uqg2s+CzoqoQjLPsWSbsFFyztIAqt2vb8kfEAsJNepMGPTxFDx3Q==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -5417,6 +5444,7 @@ "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -5483,6 +5511,7 @@ "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", @@ -6029,6 +6058,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6075,6 +6105,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -6831,6 +6862,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -8612,6 +8644,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8824,6 +8857,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -9213,6 +9247,7 @@ "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.31.tgz", "integrity": "sha512-kQ3RDqA/a59I7y+oqQGyrPbbYlgPMUdKBOgvFLpoHbD2bCM+F75i4N0mUijy7dG5F/CUCu2qHmGGUCXBbMDkCg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "54.0.21", @@ -9275,6 +9310,7 @@ "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.12.tgz", "integrity": "sha512-CsXFCQbx2fElSMn0lyTdRIyKlSXOal6ilLJd+yeZ6xaC7I9AICQgscY5nj0QcwgA+KYYCCEQEBndMsmj7drOWQ==", "license": "MIT", + "peer": true, "dependencies": { "@expo/image-utils": "^0.8.8", "expo-constants": "~18.0.12" @@ -9341,6 +9377,7 @@ "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", "integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==", "license": "MIT", + "peer": true, "dependencies": { "@expo/config": "~12.0.13", "@expo/env": "~2.0.8" @@ -9611,6 +9648,7 @@ "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.10.tgz", "integrity": "sha512-UqyNaaLKRpj4pKAP4HZSLnuDQqueaO5tB1c/NWu5vh1/LF9ulItyyg2kF/IpeOp0DeOLk0GY0HrIXaKUMrwB+Q==", "license": "MIT", + "peer": true, "dependencies": { "fontfaceobserver": "^2.1.0" }, @@ -9699,6 +9737,7 @@ "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.11.tgz", "integrity": "sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA==", "license": "MIT", + "peer": true, "dependencies": { "expo-constants": "~18.0.12", "invariant": "^2.2.4" @@ -12271,6 +12310,7 @@ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz", "integrity": "sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==", "license": "MIT", + "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -13615,6 +13655,12 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, "node_modules/keychain": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/keychain/-/keychain-1.5.0.tgz", @@ -15879,6 +15925,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -15898,6 +15945,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -15934,6 +15982,7 @@ "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.5.tgz", "integrity": "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw==", "license": "MIT", + "peer": true, "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.81.5", @@ -16015,6 +16064,7 @@ "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz", "integrity": "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==", "license": "MIT", + "peer": true, "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", @@ -16111,6 +16161,7 @@ "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.6.tgz", "integrity": "sha512-F+ZJBYiok/6Jzp1re75F/9aLzkgoQCOh4yxrnwATa8392RvM3kx+fiXXFvwcgE59v48lMwd9q0nzF1oJLXpfxQ==", "license": "MIT", + "peer": true, "dependencies": { "react-native-is-edge-to-edge": "^1.2.1", "semver": "7.7.2" @@ -16139,6 +16190,7 @@ "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", "integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -16149,6 +16201,7 @@ "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.16.0.tgz", "integrity": "sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q==", "license": "MIT", + "peer": true, "dependencies": { "react-freeze": "^1.0.0", "react-native-is-edge-to-edge": "^1.2.1", @@ -16164,6 +16217,7 @@ "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.12.1.tgz", "integrity": "sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g==", "license": "MIT", + "peer": true, "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", @@ -16251,6 +16305,7 @@ "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", "integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", @@ -16283,6 +16338,7 @@ "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.5.1.tgz", "integrity": "sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w==", "license": "MIT", + "peer": true, "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.0.0-0", "@babel/plugin-transform-class-properties": "^7.0.0-0", @@ -16421,6 +16477,7 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -17811,6 +17868,15 @@ "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "license": "MIT" }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -18192,6 +18258,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -18488,6 +18555,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index cb182b5..72d46f4 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "dependencies": { "@expo/vector-icons": "^15.0.3", "@gorhom/bottom-sheet": "^5.2.6", + "@mapbox/geo-viewport": "^0.5.0", "@react-native-community/datetimepicker": "8.4.4", "@react-native-picker/picker": "2.11.1", "@react-navigation/bottom-tabs": "^7.4.0", @@ -74,6 +75,7 @@ "react-native-web": "~0.21.0", "react-native-worklets": "0.5.1", "styled-components": "^6.1.19", + "supercluster": "^8.0.1", "zustand": "^5.0.8" }, "devDependencies": { @@ -92,4 +94,4 @@ "typescript": "~5.9.2" }, "private": true -} \ No newline at end of file +} diff --git a/src/components/map/BaseMap.tsx b/src/components/map/BaseMap.tsx new file mode 100644 index 0000000..ee5cc54 --- /dev/null +++ b/src/components/map/BaseMap.tsx @@ -0,0 +1,155 @@ +import React, { useEffect, useRef } from "react"; + +import {Dimensions, StyleSheet, TouchableOpacity} from "react-native"; +import MapView, {PROVIDER_DEFAULT, Region} from "react-native-maps"; +import type {PanDragEvent} from "react-native-maps/dist/src/MapView.types"; +import Animated, { + useAnimatedProps, + useAnimatedStyle, +} from "react-native-reanimated"; + +import {Locate, LocateFixed} from "lucide-react-native"; + +import ClusterMapView from "@/components/map/clustering/ClusteredMapView"; + +import {useBaseMap} from "@/hooks/useBaseMap"; + +import {theme} from "@/styles/theme"; + +import {useBaseMapStore} from "@/store/useBaseMapStore"; +import {useBottomSheetStore} from "@/store/useBottomSheetStore"; +import {useLocationStore} from "@/store/useLocationStore"; + +const LOCATION_BUTTON_SIZE = 48; +const LOCATION_BUTTON_MARGIN = 16; +const SCREEN_HEIGHT = Dimensions.get('window').height; + +const AnimatedClusterMapView = React.memo(Animated.createAnimatedComponent(ClusterMapView)); +const AnimatedTouchable = React.memo(Animated.createAnimatedComponent(TouchableOpacity)); + +interface BaseMapProps { + initialRegion?: Region; +} + +const BaseMap: React.FC = ({initialRegion}) => { + const mapRef = useRef(null); + const location = useLocationStore(state => state.location); + const {setCameraFollow, onPressLocateButton, onRegionChange, onRegionChangeCompleteWithRegion, fitToPoints, initBaseMapBottomSheetCallbacks, onRegionChangeCompleteWithBoundingBox} = useBaseMap(); + const {setMapRef, setIsMapFollowingUser} = useBaseMapStore(); + const enableCluster = useBaseMapStore((state) => state.enableCluster); + const isMapFollowingUser = useBaseMapStore((state) => state.isMapFollowingUser); + const locationButtonVisible = useBaseMapStore((state) => state.locationButtonVisible); + const mapComponent = useBaseMapStore((state) => state.mapComponent); + const bottomSheetPosition = useBottomSheetStore((state) => state.bottomSheetPosition); + + const buttonAnimatedStyle = useAnimatedStyle(() => { + if (!bottomSheetPosition) { + return {bottom: LOCATION_BUTTON_MARGIN}; + } + + const translateY = bottomSheetPosition.value - (LOCATION_BUTTON_SIZE + LOCATION_BUTTON_MARGIN); + return {top: 0, transform: [{translateY}]}; + }); + + const animatedProps = useAnimatedProps(() => ({ + mapPadding: { + // top: 50, // 검색바 + bottom: SCREEN_HEIGHT - ((bottomSheetPosition?.value ?? 0) + 70), // 바텀시트 + 네비게이션바 + } + })); + + useEffect(() => { + setMapRef(mapRef); + // 화면 로드 후 사용자 위치로 이동 + setTimeout(() => { + useBaseMapStore.getState().setIsMapFollowingUser(true); + setCameraFollow(true); + onRegionChangeCompleteWithBoundingBox(); + }, 500); + // 바텀시트의 위치 상태에 따라 버튼 랜더링 및 마커 로드 + initBaseMapBottomSheetCallbacks(); + }, []); + + return ( + <> + { + setMapRef(mapRef); + useBaseMapStore.getState().mapRef = mapRef; + }} + style={[StyleSheet.absoluteFill]} + provider={PROVIDER_DEFAULT} + initialRegion={initialRegion || {...location, latitudeDelta: 0.01, longitudeDelta: 0.01}} + showsUserLocation={true} + showsMyLocationButton={false} + showsCompass={false} + pointsOfInterestFilter={['airport', 'publicTransport']} // POI 공항, 대중교통만 활성화 + scrollEnabled={true} + rotateEnabled={false} + pitchEnabled={false} + toolbarEnabled={false} + clusterColor={theme.colors.main.primary} + onRegionChangeStart={onRegionChange} + onRegionChange={onRegionChange} + onRegionChangeComplete={onRegionChangeCompleteWithRegion} + userLocationUpdateInterval={1000} + userLocationFastestInterval={1000} + extent={1000} // 화면을 몇개의 타일로 나눌지 + radius={100} // 몇개의 타일로 클러스터를 형성할지 + minPoints={2} // 클러스터 형성 최소 개수 + // @ts-ignore + onClusterPress={(cluster: Marker, markers: Marker[] | undefined) => { + if (markers) + fitToPoints(markers?.map((marker) => marker.properties.coordinate)); + }} + onPanDrag={(event: PanDragEvent) => { + if (isMapFollowingUser) { + setIsMapFollowingUser(false); + } + }} + followsUserLocation={isMapFollowingUser} + spiderLineColor={"#00000000"} + clusterFontFamily={theme.typography.fontFamily.bold} + > + {mapComponent} + + + { + isMapFollowingUser ? + + : + + } + + + ) +} + +const styles = StyleSheet.create({ + locationButton: { + position: "absolute", + right: 16, + width: LOCATION_BUTTON_SIZE, + height: LOCATION_BUTTON_SIZE, + borderRadius: 24, + backgroundColor: theme.colors.white, + justifyContent: "center", + alignItems: "center", + shadowColor: "#000", + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.15, + shadowRadius: 4, + zIndex: 5, + }, +}); + +export default React.memo(BaseMap); diff --git a/src/components/map/clustering/ClusteredMapView.jsx b/src/components/map/clustering/ClusteredMapView.jsx new file mode 100644 index 0000000..8ba34a6 --- /dev/null +++ b/src/components/map/clustering/ClusteredMapView.jsx @@ -0,0 +1,233 @@ +import React, { + memo, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +import { Dimensions, LayoutAnimation, Platform } from "react-native"; +import MapView, {Polyline} from "react-native-maps"; + +import SuperCluster from "supercluster"; + +import ClusterMarker from "./ClusteredMarker"; +import { + calculateBBox, + generateSpiral, + isMarker, + markerToGeoJSONFeature, + returnMapZoom, +} from "./helpers"; + +const ClusteredMapView = ( + ( + { + radius = Dimensions.get("window").width * 0.06, + maxZoom = 20, + minZoom = 1, + minPoints = 2, + extent = 512, + nodeSize = 64, + edgePadding = { top: 50, left: 50, right: 50, bottom: 50 }, + children, + onClusterPress = (cluster, markers) => {}, + onRegionChangeComplete = (region, details) => {}, + onMarkersChange = (region, details) => {}, + preserveClusterPressBehavior = false, + clusteringEnabled = true, + clusterColor = "#00B386", + clusterTextColor = "#FFFFFF", + clusterFontFamily, + spiderLineColor = "#FF0000", + layoutAnimationConf = LayoutAnimation.Presets.spring, + animationEnabled = true, + renderCluster = undefined, + tracksViewChanges = false, + spiralEnabled = true, + superClusterRef = {}, + mapRef: mapRefProps, + ...restProps + } + ) => { + const [markers, updateMarkers] = useState([]); + const [spiderMarkers, updateSpiderMarker] = useState([]); + const [otherChildren, updateChildren] = useState([]); + const [superCluster, setSuperCluster] = useState(null); + const [currentRegion, updateRegion] = useState(() => restProps.region || restProps.initialRegion); + + const [isSpiderfier, updateSpiderfier] = useState(false); + const [clusterChildren, updateClusterChildren] = useState(null); + const mapRef = useRef(); + + const propsChildren = useMemo( + () => React.Children.toArray(children), + [children] + ); + + useEffect(() => { + const rawData = []; + const otherChildren = []; + + if (!clusteringEnabled) { + updateSpiderMarker([]); + updateMarkers([]); + updateChildren(propsChildren); + setSuperCluster(null); + return; + } + + propsChildren.forEach((child, index) => { + if (isMarker(child)) { + rawData.push(markerToGeoJSONFeature(child, index)); + } else { + otherChildren.push(child); + } + }); + + const superCluster = new SuperCluster({ + radius, + maxZoom, + minZoom, + minPoints, + extent, + nodeSize, + }); + superCluster.load(rawData); + + const bBox = calculateBBox(currentRegion); + const zoom = returnMapZoom(currentRegion, bBox, minZoom); + const markers = superCluster.getClusters(bBox, zoom); + + updateMarkers(markers); + updateChildren(otherChildren); + setSuperCluster(superCluster); + + superClusterRef.current = superCluster; + }, [propsChildren, clusteringEnabled]); + + useEffect(() => { + if (!spiralEnabled) return; + + if (isSpiderfier && markers.length > 0) { + let allSpiderMarkers = []; + let spiralChildren = []; + markers.map((marker, i) => { + if (marker.properties.cluster) { + spiralChildren = superCluster.getLeaves( + marker.properties.cluster_id, + Infinity + ); + } + let positions = generateSpiral(marker, spiralChildren, markers, i); + allSpiderMarkers.push(...positions); + }); + + updateSpiderMarker(allSpiderMarkers); + } else { + updateSpiderMarker([]); + } + }, [isSpiderfier, markers]); + + const _onRegionChangeComplete = (region, details) => { + if (superCluster && region) { + const bBox = calculateBBox(region); + const zoom = returnMapZoom(region, bBox, minZoom); + const markers = superCluster.getClusters(bBox, zoom); + if (animationEnabled && Platform.OS === "ios") { + LayoutAnimation.configureNext(layoutAnimationConf); + } + if (zoom >= 18 && markers.length > 0 && clusterChildren) { + if (spiralEnabled) updateSpiderfier(true); + } else { + if (spiralEnabled) updateSpiderfier(false); + } + updateMarkers(markers); + onMarkersChange(markers); + onRegionChangeComplete && onRegionChangeComplete(region, details, markers); + updateRegion(region); + } else { + onRegionChangeComplete && onRegionChangeComplete(region, details); + } + }; + + const _onClusterPress = (cluster) => () => { + const children = superCluster.getLeaves(cluster.id, Infinity); + updateClusterChildren(children); + + if (preserveClusterPressBehavior) { + onClusterPress(cluster, children); + return; + } + + const coordinates = children.map(({ geometry }) => ({ + latitude: geometry.coordinates[1], + longitude: geometry.coordinates[0], + })); + + mapRef.current.fitToCoordinates(coordinates, { edgePadding }); + + onClusterPress(cluster, children); + }; + + return ( + { + mapRef.current = map; + mapRefProps.current = map; + }} + initialRegion={restProps.initialRegion} + onRegionChangeComplete={_onRegionChangeComplete} + > + {markers.map((marker) => + marker.properties.point_count === 0 ? ( + propsChildren[marker.properties.index] + ) : !isSpiderfier ? ( + renderCluster ? ( + renderCluster({ + onPress: _onClusterPress(marker), + clusterColor, + clusterTextColor, + clusterFontFamily, + ...marker, + }) + ) : ( + + ) + ) : null + )} + {otherChildren} + {spiderMarkers.map((marker) => { + return propsChildren[marker.index] + ? React.cloneElement(propsChildren[marker.index], { + coordinate: { ...marker }, + }) + : null; + })} + {spiderMarkers.map((marker, index) => ( + + ))} + + ); + } +); + +export default memo(ClusteredMapView); diff --git a/src/components/map/clustering/ClusteredMarker.tsx b/src/components/map/clustering/ClusteredMarker.tsx new file mode 100644 index 0000000..6e135fd --- /dev/null +++ b/src/components/map/clustering/ClusteredMarker.tsx @@ -0,0 +1,121 @@ +// ref. https://github.com/tomekvenits/react-native-map-clustering.git + +import React, { memo } from "react"; + +import {StyleSheet, Text, TouchableOpacity, View} from "react-native"; +import {Marker, MarkerPressEvent} from "react-native-maps"; + +import { returnMarkerStyle } from "./helpers"; + +interface ClusterGeometry { + type: "Point"; + coordinates: [number, number]; // [longitude, latitude] +} + +interface ClusterProperties { + cluster: boolean; + cluster_id: number; + point_count: number; + point_count_abbreviated: string; +} + +interface ClusteredMarkerProps { + geometry: ClusterGeometry; + properties: ClusterProperties; + onPress: (event: MarkerPressEvent) => void; + clusterColor: string; + clusterTextColor: string; + clusterFontFamily?: string; + tracksViewChanges?: boolean; +} + +const ClusteredMarker = ({ + geometry, + properties, + onPress, + clusterColor, + clusterTextColor, + clusterFontFamily, + tracksViewChanges, + }: ClusteredMarkerProps) => { + const points = properties.point_count; + const { width, height, fontSize, size } = returnMarkerStyle(points); + + return ( + + + + + + {points} + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + display: "flex", + justifyContent: "center", + alignItems: "center", + }, + wrapper: { + position: "absolute", + opacity: 0.5, + zIndex: 0, + }, + cluster: { + display: "flex", + justifyContent: "center", + alignItems: "center", + zIndex: 1, + }, + text: { + fontWeight: "bold", + }, +}); + +export default memo(ClusteredMarker); diff --git a/src/components/map/clustering/helpers.js b/src/components/map/clustering/helpers.js new file mode 100644 index 0000000..cb64fe3 --- /dev/null +++ b/src/components/map/clustering/helpers.js @@ -0,0 +1,160 @@ +// ref. https://github.com/tomekvenits/react-native-map-clustering.git + +import { Dimensions } from "react-native"; + +import GeoViewport from "@mapbox/geo-viewport"; + +const { width, height } = Dimensions.get("window"); + +export const isMarker = (child) => + child && + child.props && + child.props.coordinate && + child.props.cluster !== false; + +export const calculateBBox = (region) => { + let lngD; + if (region.longitudeDelta < 0) lngD = region.longitudeDelta + 360; + else lngD = region.longitudeDelta; + + return [ + region.longitude - lngD, // westLng - min lng + region.latitude - region.latitudeDelta, // southLat - min lat + region.longitude + lngD, // eastLng - max lng + region.latitude + region.latitudeDelta, // northLat - max lat + ]; +}; + +export const returnMapZoom = (region, bBox, minZoom) => { + const viewport = + region.longitudeDelta >= 40 + ? { zoom: minZoom } + : GeoViewport.viewport(bBox, [width, height]); + + return viewport.zoom; +}; + +export const markerToGeoJSONFeature = (marker, index) => { + return { + type: "Feature", + geometry: { + coordinates: [ + marker.props.coordinate.longitude, + marker.props.coordinate.latitude, + ], + type: "Point", + }, + properties: { + point_count: 0, + index, + ..._removeChildrenFromProps(marker.props), + }, + }; +}; + +export const generateSpiral = (marker, clusterChildren, markers, index) => { + const { properties, geometry } = marker; + const count = properties.point_count; + const centerLocation = geometry.coordinates; + + let res = []; + let angle = 0; + let start = 0; + + for (let i = 0; i < index; i++) { + start += markers[i].properties.point_count || 0; + } + + for (let i = 0; i < count; i++) { + angle = 0.25 * (i * 0.5); + let latitude = centerLocation[1] + 0.0002 * angle * Math.cos(angle); + let longitude = centerLocation[0] + 0.0002 * angle * Math.sin(angle); + + if (clusterChildren[i + start]) { + res.push({ + index: clusterChildren[i + start].properties.index, + longitude, + latitude, + centerPoint: { + latitude: centerLocation[1], + longitude: centerLocation[0], + }, + }); + } + } + + return res; +}; + +export const returnMarkerStyle = (points) => { + if (points >= 50) { + return { + width: 84, + height: 84, + size: 64, + fontSize: 20, + }; + } + + if (points >= 25) { + return { + width: 78, + height: 78, + size: 58, + fontSize: 19, + }; + } + + if (points >= 15) { + return { + width: 72, + height: 72, + size: 54, + fontSize: 18, + }; + } + + if (points >= 10) { + return { + width: 66, + height: 66, + size: 50, + fontSize: 17, + }; + } + + if (points >= 8) { + return { + width: 60, + height: 60, + size: 46, + fontSize: 17, + }; + } + + if (points >= 4) { + return { + width: 54, + height: 54, + size: 40, + fontSize: 16, + }; + } + + return { + width: 48, + height: 48, + size: 36, + fontSize: 15, + }; +}; + +const _removeChildrenFromProps = (props) => { + const newProps = {}; + Object.keys(props).forEach((key) => { + if (key !== "children") { + newProps[key] = props[key]; + } + }); + return newProps; +}; diff --git a/src/hooks/useBaseMap.ts b/src/hooks/useBaseMap.ts new file mode 100644 index 0000000..7787a50 --- /dev/null +++ b/src/hooks/useBaseMap.ts @@ -0,0 +1,165 @@ +import {useCallback, useEffect} from "react"; + +import {GestureResponderEvent} from "react-native"; +import {BoundingBox, Details, Region} from "react-native-maps"; + +import {SNAP_POINT_TYPE} from "@gorhom/bottom-sheet"; + +import {Point} from "@/types/geom"; + +import {useBaseMapStore} from "@/store/useBaseMapStore"; +import {useBottomSheetStore} from "@/store/useBottomSheetStore"; +import {useLocationStore} from "@/store/useLocationStore"; + +export const useBaseMap = () => { + const {setIsMapFollowingUser, setLocationButtonVisible} = useBaseMapStore(); + + const getCurMapRef = useCallback(() => { + return useBaseMapStore.getState().mapRef; + }, []); + + const moveToRegion = useCallback(async (region: Region, duration = 300) => { + const mapRef = getCurMapRef(); + if (!mapRef?.current) return; + setIsMapFollowingUser(false); + await new Promise(resolve => setTimeout(resolve, 50)); + mapRef.current.animateToRegion(region, duration); + }, []); + + const getBoundaries = useCallback(async () => { + const mapRef = getCurMapRef(); + if (!mapRef?.current) return null; + return await mapRef.current.getMapBoundaries(); + }, []); + + const fitToPoints = useCallback((points: Point[]) => { + const mapRef = getCurMapRef(); + if (!mapRef?.current || points.length === 0) return; + mapRef.current.fitToCoordinates(points, { + edgePadding: {top: 100, right: 15, bottom: 15, left: 15}, + animated: true, + }); + }, []); + + const moveToCurrentLocation = useCallback(async (duration: number = 300) => { + const mapRef = getCurMapRef(); + const location = useLocationStore.getState().location; + mapRef?.current?.animateCamera({ + center: location + }, {duration}); + }, []); + + const setCameraFollow = useCallback((shouldFollow: boolean) => { + if(shouldFollow) { + useBaseMapStore.getState().setIsMapFollowingUser(true); + moveToCurrentLocation(); + } + else { + useBaseMapStore.getState().setIsMapFollowingUser(false); + } + }, []); + + const onPressLocateButton = useCallback((event: GestureResponderEvent) => { + event.stopPropagation(); + const shouldFollow = !useBaseMapStore.getState().isMapFollowingUser; + setCameraFollow(shouldFollow); + }, [moveToCurrentLocation]); + + const onRegionChange = useCallback((region: Region) => { + useBaseMapStore.getState().setCurrentMapRegion(region); + }, []); + + // 지도의 RegionChangeComplete 이벤트 기준 마커 로드 + // @ts-ignore + const onRegionChangeCompleteWithRegion = useCallback((region: Region, details: Details, markers: Marker[] | undefined, delay = 300) => { + useBaseMapStore.getState().startRegionChangeDebounce(async () => { + const regionChangeCompleteMethod = useBaseMapStore.getState().regionChangeCompleteMethod; + const currentMapRegion = useBaseMapStore.getState().currentMapRegion; + if (currentMapRegion && regionChangeCompleteMethod) { + regionChangeCompleteMethod(regionToBoundingBox(currentMapRegion)); + } + }, delay); + }, []); + + // 현재 지도의 MapBoundary 기준 마커 로드 + const onRegionChangeCompleteWithBoundingBox = useCallback((delay = 300) => { + useBaseMapStore.getState().startRegionChangeDebounce(async () => { + const regionChangeCompleteMethod = useBaseMapStore.getState().regionChangeCompleteMethod; + const mapBoundaries = await getCurMapRef()?.current?.getMapBoundaries(); + if (mapBoundaries && regionChangeCompleteMethod) { + regionChangeCompleteMethod(mapBoundaries); + useBaseMapStore.getState().setCurrentMapRegion(boundingBoxToRegion(mapBoundaries)); + } + }, delay); + }, []); + + const {setOnBottomSheetChange, setOnBottomSheetAnimate} = useBottomSheetStore(); + const initBaseMapBottomSheetCallbacks = useCallback(() => { + setOnBottomSheetChange((index: number, position: number, type: SNAP_POINT_TYPE) => { // 간헐적으로 이벤트가 발생하지 않는 이슈 있음 + if (useBaseMapStore.getState().isMapFollowingUser) { + setIsMapFollowingUser(false); + } + if(index > 1) { + setLocationButtonVisible(false); + } + else { + setLocationButtonVisible(true); + } + onRegionChangeCompleteWithBoundingBox(); + }) + setOnBottomSheetAnimate((fromIndex, toIndex) => { // 간헐적으로 index가 반대로 찍히는 이슈 있음 + if (useBaseMapStore.getState().isMapFollowingUser) { + setIsMapFollowingUser(false); + } + if(toIndex > 1) { + setLocationButtonVisible(false); + return; + } + setLocationButtonVisible(true); + onRegionChangeCompleteWithBoundingBox(); + }) + }, []); + + useEffect(() => { + return () => { + useBaseMapStore.getState().clearRegionChangeDebound(); + } + }, []); + + return { + moveToRegion, + getBoundaries, + fitToPoints, + moveToCurrentLocation, + setCameraFollow, + onPressLocateButton, + onRegionChange, + onRegionChangeCompleteWithRegion, + onRegionChangeCompleteWithBoundingBox, + initBaseMapBottomSheetCallbacks, + }; +} + +const regionToBoundingBox = (region: Region): BoundingBox => { + return { + northEast: { + latitude: region.latitude + region.latitudeDelta / 2, + longitude: region.longitude + region.longitudeDelta / 2, + }, + southWest: { + latitude: region.latitude - region.latitudeDelta / 2, + longitude: region.longitude - region.longitudeDelta / 2, + }, + } as BoundingBox; +}; + +const boundingBoxToRegion = (boundingBox: BoundingBox): Region => { + const {northEast, southWest} = boundingBox; + + return { + latitude: (northEast.latitude + southWest.latitude) / 2, + longitude: (northEast.longitude + southWest.longitude) / 2, + latitudeDelta: Math.abs(northEast.latitude - southWest.latitude), + longitudeDelta: Math.abs(northEast.longitude - southWest.longitude), + } as Region; +}; diff --git a/src/store/useBaseMapStore.ts b/src/store/useBaseMapStore.ts new file mode 100644 index 0000000..6b57353 --- /dev/null +++ b/src/store/useBaseMapStore.ts @@ -0,0 +1,91 @@ +import MapView, {BoundingBox, Region} from "react-native-maps"; + +import {create} from "zustand"; + +const SEOUL_CITY_HALL = { + latitude: 37.5666805, + longitude: 126.9784147, + latitudeDelta: 0.01, + longitudeDelta: 0.01, +} as Region; + +interface BaseMapStore { + mapRef: React.RefObject | undefined; + setMapRef: (mapRef: React.RefObject) => void; + + mapComponent: React.ReactNode; + setMapComponent: (mapComponent: React.ReactNode) => void; + + enableCluster: boolean; + setEnableCluster: (enableCluster: boolean) => void; + + isMapFollowingUser: boolean; + setIsMapFollowingUser: (isMapFollowingUser: boolean) => void; + + locationButtonVisible: boolean; + setLocationButtonVisible: (locationButtonVisible: boolean) => void; + + currentMapRegion: Region; + setCurrentMapRegion: (currentMapRegion: Region) => void; + + regionChangeCompleteMethod: ((boundingBox: BoundingBox) => void) | undefined; + setRegionChangeCompleteMethod: (regionChangeCompleteMethod: (boundingBox: BoundingBox) => void) => void; + + regionChangeDebounceTimer: NodeJS.Timeout | null; + startRegionChangeDebounce: (callback: () => void, delay: number) => void; + clearRegionChangeDebound: () => void; +} + +export const useBaseMapStore = create((set, get) => ({ + mapRef: undefined, + setMapRef: (mapRef: React.RefObject) => { + set({mapRef}); + }, + + mapComponent: undefined, + setMapComponent: (mapComponent: React.ReactNode) => { + set({mapComponent}); + }, + + enableCluster: true, + setEnableCluster: (enableCluster: boolean) => { + set({enableCluster}); + }, + + isMapFollowingUser: false, + setIsMapFollowingUser: (isMapFollowingUser: boolean) => { + set({isMapFollowingUser}); + }, + + locationButtonVisible: true, + setLocationButtonVisible: (locationButtonVisible: boolean) => { + set({locationButtonVisible}) + }, + + currentMapRegion: SEOUL_CITY_HALL, + setCurrentMapRegion: (currentMapRegion: Region) => { + set({currentMapRegion}); + }, + + regionChangeCompleteMethod: undefined, + setRegionChangeCompleteMethod: (regionChangeCompleteMethod: (boundingBox: BoundingBox) => void) => { + set({regionChangeCompleteMethod}); + }, + + regionChangeDebounceTimer: null, + startRegionChangeDebounce: (callback, delay) => { + const {regionChangeDebounceTimer} = get(); + if(regionChangeDebounceTimer) { + clearTimeout(regionChangeDebounceTimer); + } + const timer = setTimeout(callback, delay); + set({regionChangeDebounceTimer: timer}); + }, + clearRegionChangeDebound: () => { + const {regionChangeDebounceTimer} = get(); + if(regionChangeDebounceTimer) { + clearTimeout(regionChangeDebounceTimer); + set({regionChangeDebounceTimer: null}); + } + }, +})) diff --git a/src/store/useBottomSheetStore.ts b/src/store/useBottomSheetStore.ts new file mode 100644 index 0000000..7942f17 --- /dev/null +++ b/src/store/useBottomSheetStore.ts @@ -0,0 +1,40 @@ +import {SharedValue} from "react-native-reanimated"; + +import {SNAP_POINT_TYPE} from "@gorhom/bottom-sheet"; +import {create} from "zustand"; + +interface BottomSheetStore { + bottomSheetPosition: SharedValue | undefined; + setBottomSheetPosition: (bottomSheetPosition: SharedValue) => void; + + onBottomSheetChange: ((index: number, position: number, type: SNAP_POINT_TYPE) => void) | undefined; + setOnBottomSheetChange: (onBottomSheetChange: (index: number, position: number, type: SNAP_POINT_TYPE) => void) => void; + + onBottomSheetAnimate: ((fromIndex: number, toIndex: number) => void) | undefined; + setOnBottomSheetAnimate: (onBottomSheetAnimate: (fromIndex: number, toIndex: number) => void) => void; + + snapPoints: string[] | undefined; + setSnapPoints: (snapPoints?: string[]) => void; +} + +export const useBottomSheetStore = create((set, get) => ({ + bottomSheetPosition: undefined, + setBottomSheetPosition: (bottomSheetPosition: SharedValue) => { + set({bottomSheetPosition}); + }, + + onBottomSheetChange: undefined, + setOnBottomSheetChange: (onBottomSheetChange) => { + set({onBottomSheetChange}); + }, + + onBottomSheetAnimate: undefined, + setOnBottomSheetAnimate: (onBottomSheetAnimate) => { + set({onBottomSheetAnimate}); + }, + + snapPoints: undefined, + setSnapPoints: (snapPoints?: string[]) => { + set({snapPoints: snapPoints}); + }, +})) From 63a434130bc27ce48d3be852af6906dd017b86e7 Mon Sep 17 00:00:00 2001 From: Jaewon Lee <58386334+jaewonLeeKOR@users.noreply.github.com> Date: Sun, 15 Mar 2026 20:20:37 +0900 Subject: [PATCH 06/33] =?UTF-8?q?[#36]=20feat=20:=20=EC=A7=80=EB=8F=84?= =?UTF-8?q?=ED=83=AD=20=ED=86=B5=ED=95=A9=20=EC=A4=91=EA=B0=84=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 바텀 네비게이션바 통합 방식으로 전환 커스텀 인터페이스 Position -> react-native-maps LatLng 으로 전환 지도 헤더 영역 분리 지도 중심 마커 및 마커기반 위경도 추출 메서드 추가 주스탠드 스토어 기반 지도상 마커 컴포넌트 추가 주스탠드 관측성 도구 추가 --- app/(tabs)/(index,story,route)/[mapType].tsx | 115 +++++++++++++ app/(tabs)/_layout.tsx | 46 +++++- app/_layout.tsx | 10 ++ app/index.tsx | 2 +- package-lock.json | 13 +- package.json | 1 + .../bottomSheet/CurationBottomSheet.tsx | 76 +++++++++ .../bottomSheet/CustomBottomSheet.tsx | 93 +++++++---- .../bottomSheet/RouteBottomSheet.tsx | 76 +++++++++ src/components/curation/CurationDetail.tsx | 8 +- src/components/map/BaseMap.tsx | 156 ++++++++++++++++-- src/components/map/header/MapHeader.tsx | 33 ++++ src/components/map/header/MapSearchBar.tsx | 51 ++++++ src/hooks/story/useStorySpotMap.ts | 22 ++- src/hooks/useBaseMap.ts | 37 ++++- src/hooks/useSightMap.ts | 13 ++ src/store/story/useStoryAddStore.ts | 8 + src/store/story/useStoryStore.ts | 13 ++ src/store/useBaseMapStore.ts | 118 ++++++++++--- src/store/useBottomSheetStore.ts | 33 ++-- src/store/useMapHeaderStore.ts | 15 ++ src/store/useMyRouteBottomSheetStore.ts | 6 +- src/store/useRouteCartStore.ts | 6 +- src/store/useRouteStore.ts | 15 +- src/types/bottomSheet.ts | 2 +- src/types/geofence.ts | 4 +- src/types/geom.ts | 4 - src/types/route.ts | 8 +- src/types/sight.ts | 4 +- 29 files changed, 869 insertions(+), 119 deletions(-) create mode 100644 app/(tabs)/(index,story,route)/[mapType].tsx create mode 100644 src/components/bottomSheet/CurationBottomSheet.tsx create mode 100644 src/components/bottomSheet/RouteBottomSheet.tsx create mode 100644 src/components/map/header/MapHeader.tsx create mode 100644 src/components/map/header/MapSearchBar.tsx create mode 100644 src/store/useMapHeaderStore.ts delete mode 100644 src/types/geom.ts diff --git a/app/(tabs)/(index,story,route)/[mapType].tsx b/app/(tabs)/(index,story,route)/[mapType].tsx new file mode 100644 index 0000000..1f5fa9d --- /dev/null +++ b/app/(tabs)/(index,story,route)/[mapType].tsx @@ -0,0 +1,115 @@ +import React, {useEffect, useRef} from "react"; + +import {useSharedValue} from "react-native-reanimated"; + +import {BottomSheetMethods} from "@gorhom/bottom-sheet/src/types"; +import {useGlobalSearchParams} from "expo-router"; + +import CurationBottomSheet from "@/components/bottomSheet/CurationBottomSheet"; +import CustomBottomSheet from "@/components/bottomSheet/CustomBottomSheet"; +import RouteBottomSheet from "@/components/bottomSheet/RouteBottomSheet"; +import BaseMap from "@/components/map/BaseMap"; +import MapHeader from "@/components/map/header/MapHeader"; +import MapSearchBar from "@/components/map/header/MapSearchBar"; + +import {useStorySpotMap} from "@/hooks/story/useStorySpotMap"; +import {useBaseMap} from "@/hooks/useBaseMap"; +import {useSightMap} from "@/hooks/useSightMap"; + +import {useStoryAddStore} from "@/store/story/useStoryAddStore"; +import {useStoryStore} from "@/store/story/useStoryStore"; +import {useBaseMapStore} from "@/store/useBaseMapStore"; +import {useBottomSheetStore} from "@/store/useBottomSheetStore"; +import {useMapHeaderStore} from "@/store/useMapHeaderStore"; +import {useRouteStore} from "@/store/useRouteStore"; +import {useSightStore} from "@/store/useSightStore"; + +function MapTabScreen() { + const {mapType} = useGlobalSearchParams(); + const bottomSheetRef = useRef(null); + const bottomSheetPosition = useSharedValue(0); + const {setSnapPoints, setBottomSheetContent} = useBottomSheetStore(); + const {setMapHeaderContent} = useMapHeaderStore(); + const {setMapMovable, setEnableCluster ,setRegionChangeCompleteMethod} = useBaseMapStore(); + const {onRegionChangeCompleteWithBoundingBox} = useBaseMap(); + const {fetchSightInBoundingBox} = useSightMap(); + const {fetchStorySpotInBoundingBox} = useStorySpotMap(); + const {resetSpotLocationInMap} = useStoryStore(); + const {setSights} = useSightStore(); + const {setPathVisibility} = useRouteStore(); + const {onStoryAdd} = useStoryAddStore(); + const storyLocation = useStoryAddStore(state => state.storyLocation); + + useEffect(() => { + /**TODO + * 화면 별 요소 추가 + * - 초기 마커 컴포넌트 + * - regionChange 시 마커 로드 함수 + * - 검색바 컴포넌트 + * - 바텀시트 내부 컴포넌트 + * - 바텀시트 위치 조정 + * + * TODO issue: param이 변경될때마다 세번씩 호출됨 + */ + if (!mapType) return; + if(onStoryAdd) { + } + else if(mapType === "route") { + //TODO 경로 지도용 요소 추가 + setSnapPoints(["45%", "75%"]); + setTimeout(() => bottomSheetRef.current?.snapToIndex(0), 200); + setSights([]); + resetSpotLocationInMap(); + setEnableCluster(false); + setPathVisibility(true); + setBottomSheetContent(); + } + else if(mapType === "sight") { + //TODO 관광지 지도용 요소 추가 + setTimeout(() => bottomSheetRef.current?.snapToIndex(1), 200); + setMapHeaderContent( { + //TODO 관광지 검색 모달 + console.log("sight map search bar pressed") + }} />); + setRegionChangeCompleteMethod(fetchSightInBoundingBox); + //TODO 관광지 큐레이션 바텀시트 추가 + setBottomSheetContent(); + } + else if(mapType === "story") { + //TODO 이야기 지도용 요소 추가 + setTimeout(() => bottomSheetRef.current?.snapToIndex(1), 200); + setMapHeaderContent( { + //TODO 스팟 검색 모달 + console.log("story map search bar pressed") + }} />); + setRegionChangeCompleteMethod(fetchStorySpotInBoundingBox); + //TODO 이야기 바텀시트 추가 + setBottomSheetContent(undefined); + } + setTimeout(() => onRegionChangeCompleteWithBoundingBox(), 100); + + return () => { + // store 기반 공유 컴포넌트 데이터 초기화 + bottomSheetRef.current?.snapToPosition("10%"); + setSnapPoints(undefined); + setMapHeaderContent(undefined); + setMapMovable(true); + setEnableCluster(true); + setRegionChangeCompleteMethod(undefined); + setPathVisibility(false); + setSights([]); + resetSpotLocationInMap(); + setBottomSheetContent(undefined); + } + }, [mapType, onStoryAdd]); + + return ( + <> + + + + + ); +}; + +export default React.memo(MapTabScreen); diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index c4186a3..b0d0d6c 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,5 +1,5 @@ import { Ionicons } from "@expo/vector-icons"; -import { Tabs } from "expo-router"; +import {Tabs, usePathname} from "expo-router"; import CourseIcon from "@/components/icons/tabs/CourseIcon"; import MyRouteIcon from "@/components/icons/tabs/MyRouteIcon"; @@ -7,6 +7,11 @@ import MyRouteIcon from "@/components/icons/tabs/MyRouteIcon"; import { theme } from "@/styles/theme"; export default function TabLayout() { + const pathname = usePathname(); + const currentMapType = pathname.startsWith('/sight') ? 'sight' + : pathname.startsWith('/route') ? 'route' + : pathname.startsWith('/story') ? 'story' + : null return ( + {/*@deprecated*/} + {/*@deprecated*/} + {/*@deprecated*/} + null, + tabBarIcon: ({ color }) => { + color = (currentMapType === 'route') ? theme.colors.main.primary : theme.colors.black; + return ; + }, + }} + /> + null, + tabBarIcon: ({ color }) => { + color = (currentMapType === 'sight') ? theme.colors.main.primary : theme.colors.black; + return + }, + }} + /> + null, + tabBarIcon: ({ color }) => { + color = (currentMapType === 'story') ? theme.colors.main.primary : theme.colors.black; + return + }, + }} + /> { + if (state.mapRef !== prevState.mapRef) { + console.log('[참조 변경 감지]', new Date().toISOString()); + console.log('이전:', prevState.mapRef); + console.log('이후:', state.mapRef); + console.trace(); // 호출 스택 출력 + } +}) + export default function RootLayout() { const [fontsLoaded] = useFonts({ "Pretendard-Bold": require("../src/assets/fonts/Pretendard-Bold.otf"), diff --git a/app/index.tsx b/app/index.tsx index e50a388..4a57931 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -2,5 +2,5 @@ import { Redirect } from "expo-router"; export default function Index() { - return ; + return ; } diff --git a/package-lock.json b/package-lock.json index 21d0a34..f0c632b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "front", "version": "0.1.0", "dependencies": { + "@csark0812/zustand-expo-devtools": "^2.1.6", "@expo/vector-icons": "^15.0.3", "@gorhom/bottom-sheet": "^5.2.6", "@mapbox/geo-viewport": "^0.5.0", @@ -1572,6 +1573,16 @@ "node": ">=6.9.0" } }, + "node_modules/@csark0812/zustand-expo-devtools": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@csark0812/zustand-expo-devtools/-/zustand-expo-devtools-2.1.6.tgz", + "integrity": "sha512-KRs1GXhmjwR8dIbbNlVRORmTx/04GSEl+nHviljfUm7xcWyE2W28vu6UUoUX1iiobEU4wWismz8cc3bMEy97nw==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "zustand": "^5.0.5" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -16338,7 +16349,6 @@ "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.5.1.tgz", "integrity": "sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w==", "license": "MIT", - "peer": true, "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.0.0-0", "@babel/plugin-transform-class-properties": "^7.0.0-0", @@ -19508,6 +19518,7 @@ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz", "integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==", "license": "MIT", + "peer": true, "engines": { "node": ">=12.20.0" }, diff --git a/package.json b/package.json index 72d46f4..53a396b 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "ios": "expo run:ios" }, "dependencies": { + "@csark0812/zustand-expo-devtools": "^2.1.6", "@expo/vector-icons": "^15.0.3", "@gorhom/bottom-sheet": "^5.2.6", "@mapbox/geo-viewport": "^0.5.0", diff --git a/src/components/bottomSheet/CurationBottomSheet.tsx b/src/components/bottomSheet/CurationBottomSheet.tsx new file mode 100644 index 0000000..ac8f1e2 --- /dev/null +++ b/src/components/bottomSheet/CurationBottomSheet.tsx @@ -0,0 +1,76 @@ +import React, {useEffect} from "react"; + +import Button from "@/components/common/Button"; +import CurationDetail from "@/components/curation/CurationDetail"; +import CurationList from "@/components/curation/CurationList"; + +import {useCurationDetail} from "@/hooks/sight/useCurationDetail"; +import {useSightMap} from "@/hooks/useSightMap"; + +import {theme} from "@/styles/theme"; + +import {useBottomSheetStore} from "@/store/useBottomSheetStore"; + +const CurationBottomSheet = () => { + const {fetchSightDetail} = useSightMap(); + const {setBottomSheetAbsoluteBottom} = useBottomSheetStore(); + + const { + curations, + isCurationLoading, + fetchCurations, + fetchCurationDetail, + curationSightList, + selectedCurationTitle, + selectedCurationDescription, + handleAddSightListToMy, + setCurationSightList, + } = useCurationDetail(); + + useEffect(() => { + fetchCurations(); + }, []); + + useEffect(() => { + const button = + curationSightList !== undefined ? ( +