Skip to content

Commit

Permalink
Merge pull request #66 from Board-Buddy/Feat/#63
Browse files Browse the repository at this point in the history
Feat/#63 보드게임 카페 찾기 기능 구현
ChaeYubin authored Jul 24, 2024
2 parents e85b45c + bacff31 commit cc0f0e9
Showing 12 changed files with 333 additions and 121 deletions.
4 changes: 2 additions & 2 deletions src/app/(without-nav)/map/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Map from '@/containers/map/Map';
import GeoLocation from '@/containers/map/GeoLocation';

const page = () => {
return <Map />;
return <GeoLocation />;
};

export default page;
1 change: 1 addition & 0 deletions src/constants/map.ts
Original file line number Diff line number Diff line change
@@ -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]; // 지도 레벨을 반경으로 매핑한 값
33 changes: 33 additions & 0 deletions src/containers/map/GeoLocation.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex justify-center items-center h-[calc(100vh-50px)] text-primary">
<LoaderCircleIcon className="animate-spin size-9" />
</div>
);
}

return <Map location={location} />;
};

export default GeoLocation;
196 changes: 99 additions & 97 deletions src/containers/map/Map.tsx
Original file line number Diff line number Diff line change
@@ -1,75 +1,60 @@
'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 {
kakao: any;
}
}

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<HTMLDivElement>(null);
const mapObjectRef = useRef<any>(null);
const markersRef = useRef<any[]>([]); // 마커들을 저장하는 배열

const [mapObject, setMapObject] = useState<any>(null);
const [radius, setRadius] = useState<number>(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<Cafe | null>(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,68 +118,73 @@ 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 <div>위치 정보를 허용해주세요.</div>;
}
}, [cafes, mapObject]);

return (
<>
<div className="relative">
<div
ref={mapRef}
className={cn(
'w-full bg-gray-200 transition-all ease-in',
showInfo ? 'h-[calc(100vh-300px)]' : 'h-[calc(100vh-50px)]',
)}
/>
<ReloadButton
show={showReloadButton}
onClick={onReloadButtonClick}
isPending={isPending}
/>
<MapInfo cafe={cafeInfo} />
</>
</div>
);
};

11 changes: 6 additions & 5 deletions src/containers/map/MapInfo.tsx
Original file line number Diff line number Diff line change
@@ -6,25 +6,26 @@ 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 (
<div className="p-4 bg-white rounded-2xl shadow-[0_-2px_10px_0_rgba(48,48,48,0.1)]">
<div className="flex flex-col gap-4">
<h2 className="font-bold text-lg">{placeName}</h2>
<h2 className="font-bold text-lg">{place_name}</h2>
<div className="flex flex-col text-md text-gray-800 gap-1">
<div className="flex items-center gap-2">
<MapPin className="size-5 text-secondary" />
{roadAddressName}
{road_address_name}
</div>
<div className="flex items-center gap-2">
<Phone className="size-5 text-secondary" />
{phone}
</div>
<div className="flex items-center gap-2">
<LinkIcon className="size-5 text-secondary" />
<Link href={placeUrl} target="_blank">
<span className="underline underline-offset-2">{placeUrl}</span>
<Link href={place_url} target="_blank">
<span className="underline underline-offset-2">{place_url}</span>
</Link>
</div>
</div>
34 changes: 34 additions & 0 deletions src/containers/map/ReloadButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={cn(
'absolute top-4 left-1/2 -ml-[68px] z-10 transition-all',
show || isPending ? 'visible' : 'hidden',
)}
>
<Button
className="border border-gray-300 text-primary font-bold bg-white rounded-3xl px-3 py-0 text-sm shadow-md"
onClick={onClick}
>
{isPending ? (
<LoaderCircleIcon className="animate-spin size-4 mr-1" />
) : (
<RotateCcwIcon className="size-4 mr-1" />
)}
현 지도에서 검색
</Button>
</div>
);
};

export default ReloadButton;
8 changes: 2 additions & 6 deletions src/hooks/useGeoLocation.ts
Original file line number Diff line number Diff line change
@@ -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<ILocation>();
const [location, setLocation] = useState<Location>();
const [error, setError] = useState('');

const handleSuccess = (pos: GeolocationPosition) => {
21 changes: 21 additions & 0 deletions src/hooks/useMap.ts
Original file line number Diff line number Diff line change
@@ -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<Cafe[]>({
queryKey: ['cafe'],
queryFn: () => getBoardCafes({ x, y, radius }),
staleTime: 0,
gcTime: 0,
retry: 0,
});
};
114 changes: 114 additions & 0 deletions src/mocks/boardCafes/index.ts
Original file line number Diff line number Diff line change
@@ -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];
2 changes: 2 additions & 0 deletions src/mocks/handlers.ts
Original file line number Diff line number Diff line change
@@ -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,
];
15 changes: 15 additions & 0 deletions src/services/map.ts
Original file line number Diff line number Diff line change
@@ -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);
15 changes: 4 additions & 11 deletions src/types/map.ts
Original file line number Diff line number Diff line change
@@ -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;
}

0 comments on commit cc0f0e9

Please sign in to comment.