diff --git a/src/app/(without-nav)/map/page.tsx b/src/app/(without-nav)/map/page.tsx index 2acaba2..9b6fdb8 100644 --- a/src/app/(without-nav)/map/page.tsx +++ b/src/app/(without-nav)/map/page.tsx @@ -1,7 +1,7 @@ -import Map from '@/containers/map/Map'; +import GeoLocation from '@/containers/map/GeoLocation'; const page = () => { - return ; + return ; }; export default page; diff --git a/src/constants/map.ts b/src/constants/map.ts index 67c8dee..2b72f9d 100644 --- a/src/constants/map.ts +++ b/src/constants/map.ts @@ -1,3 +1,4 @@ export const MAP_INITIAL_RADIUS = 300; // 지도 초기 반경 값(임의)) export const MAP_MAX_LEVEL = 8; // 지도 줌아웃 최대 레벨 값 export const MAP_INITIAL_LEVEL = 3; // 지도 초기 레벨 값 +export const LEVEL_TO_RADIUS = [0, 70, 150, 300, 600, 1300, 2700, 5500, 12000]; // 지도 레벨을 반경으로 매핑한 값 diff --git a/src/containers/map/GeoLocation.tsx b/src/containers/map/GeoLocation.tsx new file mode 100644 index 0000000..b0fc266 --- /dev/null +++ b/src/containers/map/GeoLocation.tsx @@ -0,0 +1,33 @@ +'use client'; + +import useGeoLocation from '@/hooks/useGeoLocation'; +import { LoaderCircleIcon } from 'lucide-react'; +import Map from './Map'; + +declare global { + interface Window { + kakao: any; + } +} + +const geolocationOptions = { + enableHighAccuracy: true, + timeout: 1000 * 10, + maximumAge: 1000 * 3600 * 24, +}; + +const GeoLocation = () => { + const { location, error } = useGeoLocation(geolocationOptions); + + if (!location || error) { + return ( +
+ +
+ ); + } + + return ; +}; + +export default GeoLocation; diff --git a/src/containers/map/Map.tsx b/src/containers/map/Map.tsx index 147c4e6..ae084c9 100644 --- a/src/containers/map/Map.tsx +++ b/src/containers/map/Map.tsx @@ -1,16 +1,18 @@ 'use client'; import { KAKAO_APP_KEY } from '@/constants/env'; -import useGeoLocation from '@/hooks/useGeoLocation'; import { useEffect, useRef, useState } from 'react'; import { cn } from '@/utils/tailwind'; -import { Cafe } from '@/types/map'; +import { Cafe, Location } from '@/types/map'; import { + LEVEL_TO_RADIUS, MAP_INITIAL_LEVEL, MAP_INITIAL_RADIUS, MAP_MAX_LEVEL, } from '@/constants/map'; +import { useGetBoardCafes } from '@/hooks/useMap'; import MapInfo from './MapInfo'; +import ReloadButton from './ReloadButton'; declare global { interface Window { @@ -18,58 +20,41 @@ declare global { } } -const geolocationOptions = { - enableHighAccuracy: true, - timeout: 1000 * 10, - maximumAge: 1000 * 3600 * 24, -}; - -const Map = () => { - const cafes = [ - { - addressName: '서울 종로구 내수동 73', - distance: '1867', - id: '841664157', - phone: '070-8627-1688', - placeName: '보드게임101 광화문점 24시간 무인카페', - placeUrl: 'http://place.map.kakao.com/841664157', - roadAddressName: '서울 종로구 새문안로3길 23', - x: '126.972438244896', - y: '37.5725658604431', - }, - - { - addressName: '서울 종로구 관철동 19-11', - distance: '2613', - id: '621777615', - phone: '070-4247-4562', - placeName: '보드게임카페 주사위왕국', - placeUrl: 'http://place.map.kakao.com/621777615', - roadAddressName: '서울 종로구 우정국로2길 42', - x: '126.985386588519', - y: '37.5694300068762', - }, - { - addressName: '서울 종로구 관철동 13-1', - distance: '2677', - id: '2055835737', - phone: '02-733-3799', - placeName: '레드버튼 종로점', - placeUrl: 'http://place.map.kakao.com/2055835737', - roadAddressName: '서울 종로구 삼일대로19길 15', - x: '126.986720016475', - y: '37.569449085306', - }, - ]; - +const Map = ({ location }: { location: Location }) => { const mapRef = useRef(null); - const mapObjectRef = useRef(null); + const markersRef = useRef([]); // 마커들을 저장하는 배열 + + const [mapObject, setMapObject] = useState(null); + const [radius, setRadius] = useState(MAP_INITIAL_RADIUS); + const [center, setCenter] = useState<{ x: number; y: number }>({ + x: location.latitude, + y: location.longitude, + }); + + const { + data: cafes, + isPending, + isError, + refetch, + } = useGetBoardCafes({ + x: center.x, + y: center.y, + radius, + }); const [showInfo, setShowInfo] = useState(false); const [cafeInfo, setCafeInfo] = useState(null); - const [radius, setRadius] = useState(MAP_INITIAL_RADIUS); + const [showReloadButton, setShowReloadButton] = useState(false); - const { location, error } = useGeoLocation(geolocationOptions); + const onReloadButtonClick = () => { + // 보드게임 카페 조회 요청 + refetch(); + setShowReloadButton(false); + + if (isError) { + console.log('error'); + } + }; useEffect(() => { const script = document.createElement('script'); @@ -79,17 +64,19 @@ const Map = () => { script.onload = () => { window.kakao.maps.load(() => { + setCenter({ x: location.latitude, y: location.latitude }); + const container = mapRef.current; const options = { center: new window.kakao.maps.LatLng( - location?.latitude, - location?.longitude, + location.latitude, + location.longitude, ), level: MAP_INITIAL_LEVEL, maxLevel: MAP_MAX_LEVEL, }; const map = new window.kakao.maps.Map(container, options); - mapObjectRef.current = map; + setMapObject(map); // 지도 클릭 이벤트 등록 window.kakao.maps.event.addListener(map, 'click', () => { @@ -98,9 +85,19 @@ const Map = () => { // 지도 확대/축소 이벤트 등록 window.kakao.maps.event.addListener(map, 'zoom_changed', () => { + setShowReloadButton(true); const level = map.getLevel(); - const radiusValue = [0, 70, 150, 300, 600, 1300, 2700, 5500, 12000]; - setRadius(radiusValue[level]); + setRadius(LEVEL_TO_RADIUS[level]); + }); + + // 지도 이동(드래그) 이벤트 등록 + window.kakao.maps.event.addListener(map, 'dragend', () => { + const centerPoint = map.getCenter(); + setCenter({ + x: centerPoint.getLat(), + y: centerPoint.getLng(), + }); + setShowReloadButton(true); }); // 현재 위치 마커 생성 @@ -111,8 +108,8 @@ const Map = () => { ); const currentMarkerPosition = new window.kakao.maps.LatLng( - location?.latitude, - location?.longitude, + location.latitude, + location.longitude, ); const currentMarker = new window.kakao.maps.Marker({ @@ -121,59 +118,59 @@ const Map = () => { }); currentMarker.setMap(map); - - // 주변 보드게임 카페 마커 설정 - cafes.forEach((cafe) => { - const markerPosition = new window.kakao.maps.LatLng(cafe.y, cafe.x); - - const marker = new window.kakao.maps.Marker({ - map, - position: markerPosition, - clickable: true, // 마커를 클릭했을 때 지도의 클릭 이벤트가 발생하지 않도록 설정 - }); - - // 상세 정보를 표시하는 클로저를 만드는 함수 - function makeClickListener() { - return function () { - setShowInfo(true); - setCafeInfo(cafe); - }; - } - - // 마커에 click 이벤트 등록 - // 이벤트 리스너로는 클로저를 만들어 등록합니다 - // for문에서 클로저를 만들어 주지 않으면 마지막 마커에만 이벤트가 등록됩니다 - window.kakao.maps.event.addListener( - marker, - 'click', - makeClickListener(), - ); - }); }); }; return () => { document.head.removeChild(script); }; - }, [location]); + }, [location.latitude, location.longitude]); useEffect(() => { - if (mapObjectRef.current && cafeInfo) { - mapObjectRef.current.relayout(); + if (cafes && mapObject) { + console.log('cafes useEffect'); + // 기존 마커들을 지도에서 제거 + markersRef.current.forEach((marker) => marker.setMap(null)); + markersRef.current = []; + + // 새로운 마커들을 지도에 추가 + cafes.forEach((cafe) => { + const markerPosition = new window.kakao.maps.LatLng(cafe.y, cafe.x); + + const marker = new window.kakao.maps.Marker({ + map: mapObject, + position: markerPosition, + clickable: true, // 마커를 클릭했을 때 지도의 클릭 이벤트가 발생하지 않도록 설정 + }); - const moveLatLon = new window.kakao.maps.LatLng(cafeInfo.y, cafeInfo.x); + // 상세 정보를 표시하는 클로저를 만드는 함수 + function makeClickListener() { + return function () { + setShowInfo(true); + setCafeInfo(cafe); + setShowReloadButton(false); + + const moveLatLon = new window.kakao.maps.LatLng(cafe.y, cafe.x); + mapObject.panTo(moveLatLon); // 지도 중심을 부드럽게 이동한다. + }; + } + + // 마커에 click 이벤트 등록 + // 이벤트 리스너로는 클로저를 만들어 등록합니다 + // for문에서 클로저를 만들어 주지 않으면 마지막 마커에만 이벤트가 등록됩니다 + window.kakao.maps.event.addListener( + marker, + 'click', + makeClickListener(), + ); - mapObjectRef.current.setLevel(3, { anchor: moveLatLon, animate: true }); // 지도 레벨을 3으로 설정한다. - mapObjectRef.current.panTo(moveLatLon); // 지도 중심을 부드럽게 이동한다. + markersRef.current.push(marker); // 마커를 배열에 저장 + }); } - }, [showInfo, cafeInfo]); - - if (error) { - return
위치 정보를 허용해주세요.
; - } + }, [cafes, mapObject]); return ( - <> +
{ showInfo ? 'h-[calc(100vh-300px)]' : 'h-[calc(100vh-50px)]', )} /> + - +
); }; diff --git a/src/containers/map/MapInfo.tsx b/src/containers/map/MapInfo.tsx index 2ff7e71..690d13f 100644 --- a/src/containers/map/MapInfo.tsx +++ b/src/containers/map/MapInfo.tsx @@ -6,16 +6,17 @@ import Link from 'next/link'; const MapInfo = ({ cafe }: { cafe: Cafe | null }) => { if (cafe === null) return null; - const { phone, placeName, placeUrl, roadAddressName } = cafe; + // eslint-disable-next-line @typescript-eslint/naming-convention + const { phone, place_name, place_url, road_address_name } = cafe; return (
-

{placeName}

+

{place_name}

- {roadAddressName} + {road_address_name}
@@ -23,8 +24,8 @@ const MapInfo = ({ cafe }: { cafe: Cafe | null }) => {
- - {placeUrl} + + {place_url}
diff --git a/src/containers/map/ReloadButton.tsx b/src/containers/map/ReloadButton.tsx new file mode 100644 index 0000000..896262b --- /dev/null +++ b/src/containers/map/ReloadButton.tsx @@ -0,0 +1,34 @@ +import { Button } from '@/components/ui/button'; +import { cn } from '@/utils/tailwind'; +import { LoaderCircleIcon, RotateCcwIcon } from 'lucide-react'; + +interface Props { + show: boolean; + onClick: () => void; + isPending: boolean; +} + +const ReloadButton = ({ show, onClick, isPending }: Props) => { + return ( +
+ +
+ ); +}; + +export default ReloadButton; diff --git a/src/hooks/useGeoLocation.ts b/src/hooks/useGeoLocation.ts index e943473..c34d43e 100644 --- a/src/hooks/useGeoLocation.ts +++ b/src/hooks/useGeoLocation.ts @@ -1,14 +1,10 @@ 'use client'; +import { Location } from '@/types/map'; import { useEffect, useState } from 'react'; -interface ILocation { - latitude: number; - longitude: number; -} - const useGeoLocation = (options = {}) => { - const [location, setLocation] = useState(); + const [location, setLocation] = useState(); const [error, setError] = useState(''); const handleSuccess = (pos: GeolocationPosition) => { diff --git a/src/hooks/useMap.ts b/src/hooks/useMap.ts new file mode 100644 index 0000000..30dafd6 --- /dev/null +++ b/src/hooks/useMap.ts @@ -0,0 +1,21 @@ +import { getBoardCafes } from '@/services/map'; +import { Cafe } from '@/types/map'; +import { useQuery } from '@tanstack/react-query'; + +export const useGetBoardCafes = ({ + x, + y, + radius, +}: { + x: number; + y: number; + radius: number; +}) => { + return useQuery({ + queryKey: ['cafe'], + queryFn: () => getBoardCafes({ x, y, radius }), + staleTime: 0, + gcTime: 0, + retry: 0, + }); +}; diff --git a/src/mocks/boardCafes/index.ts b/src/mocks/boardCafes/index.ts new file mode 100644 index 0000000..4610639 --- /dev/null +++ b/src/mocks/boardCafes/index.ts @@ -0,0 +1,114 @@ +import { API_BASE_URL } from '@/constants/env'; +import { http, HttpResponse } from 'msw'; + +export const boardCafes = http.get( + `${API_BASE_URL}/api/boardCafes`, + ({ request }) => { + const url = new URL(request.url); + const x = url.searchParams.get('x'); + const y = url.searchParams.get('y'); + const radius = url.searchParams.get('radius'); + + if (radius === '12000') { + return HttpResponse.json( + { + status: 'success', + data: { + cafes: [ + { + address_name: '서울 종로구 내수동 73', + distance: '1867', + id: '841664157', + phone: '070-8627-1688', + place_name: '보드게임101 광화문점 24시간 무인카페', + place_url: 'http://place.map.kakao.com/841664157', + road_address_name: '서울 종로구 새문안로3길 23', + x: '126.972438244896', + y: '37.5725658604431', + }, + + { + address_name: '서울 종로구 관철동 19-11', + distance: '2613', + id: '621777615', + phone: '070-4247-4562', + place_name: '보드게임카페 주사위왕국', + place_url: 'http://place.map.kakao.com/621777615', + road_address_name: '서울 종로구 우정국로2길 42', + x: '126.985386588519', + y: '37.5694300068762', + }, + { + address_name: '서울 종로구 관철동 13-1', + distance: '2677', + id: '2055835737', + phone: '02-733-3799', + place_name: '레드버튼 종로점', + place_url: 'http://place.map.kakao.com/2055835737', + road_address_name: '서울 종로구 삼일대로19길 15', + x: '126.986720016475', + y: '37.569449085306', + }, + ], + }, + message: '보드 게임 카페 조회를 성공하였습니다.', + }, + { status: 200 }, + ); + } + + if (radius === '300') { + return HttpResponse.json( + { + status: 'success', + data: { + cafes: [ + { + address_name: '서울 종로구 관철동 13-1', + distance: '2677', + id: '2055835737', + phone: '02-733-3799', + place_name: '레드버튼 종로점', + place_url: 'http://place.map.kakao.com/2055835737', + road_address_name: '서울 종로구 삼일대로19길 15', + x: '127.07840761344235', + y: '37.569449085306', + }, + { + address_name: '서울 종로구 관철동 13-1', + distance: '2677', + id: '2055835737', + phone: '02-733-3799', + place_name: '레드버튼 종로점', + place_url: 'http://place.map.kakao.com/2055835737', + road_address_name: '서울 종로구 삼일대로19길 15', + x: '127.0786123396573', + y: '37.622066776269925', + }, + { + address_name: '서울 종로구 관철동 13-1', + distance: '2677', + id: '2055835737', + phone: '02-733-3799', + place_name: '레드버튼 종로점', + place_url: 'http://place.map.kakao.com/2055835737', + road_address_name: '서울 종로구 삼일대로19길 15', + x: '127.07787884529003', + y: '37.61931024105776', + }, + ], + }, + message: '보드 게임 카페 조회를 성공하였습니다.', + }, + { status: 200 }, + ); + } + + return HttpResponse.json( + { status: 'error', data: null, message: '올바른 요청이 아닙니다.' }, + { status: 404 }, + ); + }, +); + +export const boardCafeHandlers = [boardCafes]; diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 38fa03f..969cbb7 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -1,4 +1,5 @@ import { authHandlers } from './auth'; +import { boardCafeHandlers } from './boardCafes'; import { articleHandlers } from './gatherArticles'; import { locationHandlers } from './locations'; import { radiusHandlers } from './radius'; @@ -8,4 +9,5 @@ export const handlers = [ ...articleHandlers, ...locationHandlers, ...radiusHandlers, + ...boardCafeHandlers, ]; diff --git a/src/services/map.ts b/src/services/map.ts new file mode 100644 index 0000000..3b1b50a --- /dev/null +++ b/src/services/map.ts @@ -0,0 +1,15 @@ +import api from '@/services'; + +/** 보드게임 카페 조회 API */ +export const getBoardCafes = ({ + x, + y, + radius, +}: { + x: number; + y: number; + radius: number; +}) => + api + .get(`/api/boardCafes`, { params: { x, y, radius } }) + .then((response) => response.data.data.cafes); diff --git a/src/types/map.ts b/src/types/map.ts index 5a8538b..5c340a0 100644 --- a/src/types/map.ts +++ b/src/types/map.ts @@ -1,4 +1,4 @@ -export interface CafeResponse { +export interface Cafe { address_name: string; distance: string; id: string; @@ -10,14 +10,7 @@ export interface CafeResponse { y: string; } -export interface Cafe { - addressName: string; - distance: string; - id: string; - phone: string; - placeName: string; - placeUrl: string; - roadAddressName: string; - x: string; - y: string; +export interface Location { + latitude: number; + longitude: number; }