From b05ba6273d03ee242f7f3c4a7d6094c35376ca3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=B8=EB=AF=BC?= <89172499+semnil5202@users.noreply.github.com> Date: Fri, 17 Nov 2023 10:17:41 +0900 Subject: [PATCH] =?UTF-8?q?[FE]=20Refactor/#620=20=EB=AA=A8=EC=95=84?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=ED=81=B4=EB=9F=AC=EC=8A=A4=ED=84=B0?= =?UTF-8?q?=EB=A7=81=20(#622)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 불필요한 인자 제거 * refactor: getDistanceOfPin 훅으로 분리 * refactor: InfoWindow 타입 추가 * refactor: 모아보기 상태에서 클러스터링 및 이벤트 핸들링 적용 * refactor: 사용자에게 노출되는 단어 중 도메인 용어인 토픽을 지도로 변환 * refactor: 혼잡한 dom 구조 및 잘못된 indent 수정 * refactor: 한 토픽에 해당하는 핀이 클러스터링 되었을 땐 무지개색 적용 하지 않기 * refactor: 스크린 사이즈 내 좌표가 아닌 초기 좌표로 모아보기 색 구별하도록 변경 * refactor: 모아보기가 비어있을 때 초기화 하는 로직 함수 분리 * refactor: type 정의 위치 수정 --- frontend/src/components/PinPreview/index.tsx | 2 +- frontend/src/components/PullPin/index.tsx | 6 +- frontend/src/components/TopicCard/index.tsx | 2 +- frontend/src/components/TopicInfo/index.tsx | 4 +- frontend/src/constants/pinImage.ts | 36 ++++-- frontend/src/context/MarkerContext.tsx | 70 ++++++----- frontend/src/hooks/useClickedCoordinate.ts | 2 +- frontend/src/hooks/useRealDistanceOfPin.ts | 26 ++++ frontend/src/pages/NewPin.tsx | 2 +- frontend/src/pages/PinDetail.tsx | 2 +- frontend/src/pages/SeeTogether.tsx | 118 +++++++++++++++---- frontend/src/pages/SelectedTopic.tsx | 26 +--- frontend/src/types/tmap.d.ts | 1 + 13 files changed, 202 insertions(+), 95 deletions(-) create mode 100644 frontend/src/hooks/useRealDistanceOfPin.ts diff --git a/frontend/src/components/PinPreview/index.tsx b/frontend/src/components/PinPreview/index.tsx index 0ec637f3..8b02e467 100644 --- a/frontend/src/components/PinPreview/index.tsx +++ b/frontend/src/components/PinPreview/index.tsx @@ -36,7 +36,7 @@ function PinPreview({ const { pathname } = useLocation(); const { routePage } = useNavigator(); const { tags, setTags } = useContext(TagContext); - const [announceText, setAnnounceText] = useState('토픽 핀 선택'); + const [announceText, setAnnounceText] = useState('지도 핀 선택'); const inputRef = useRef(null); const onAddTagOfTopic = (e: React.ChangeEvent) => { diff --git a/frontend/src/components/PullPin/index.tsx b/frontend/src/components/PullPin/index.tsx index 5330e94b..7ac46050 100644 --- a/frontend/src/components/PullPin/index.tsx +++ b/frontend/src/components/PullPin/index.tsx @@ -46,7 +46,7 @@ function PullPin({ tabIndex={1} aria-label={ confirmButton === '같이보기' - ? `선택된 ${tag.title} 토픽 태그` + ? `선택된 ${tag.title} 지도 태그` : `선택된 ${tag.title} 핀 태그` } > @@ -64,7 +64,7 @@ function PullPin({ onClick={onClickClose} aria-label={ confirmButton === '같이보기' - ? '선택된 토픽들 같이보기 취소하기' + ? '선택된 지도들 같이보기 취소하기' : '선택된 핀들 뽑아오기 취소하기' } > @@ -76,7 +76,7 @@ function PullPin({ onClick={onClickConfirm} aria-label={ confirmButton === '같이보기' - ? '선택된 토픽들 같이보기' + ? '선택된 지도들 같이보기' : '선택된 핀들 뽑아오기' } > diff --git a/frontend/src/components/TopicCard/index.tsx b/frontend/src/components/TopicCard/index.tsx index 3e8f6d3b..41fc3503 100644 --- a/frontend/src/components/TopicCard/index.tsx +++ b/frontend/src/components/TopicCard/index.tsx @@ -101,7 +101,7 @@ function TopicCard({ color="black" $fontSize="default" $fontWeight="bold" - aria-label={`토픽 이름 ${name}`} + aria-label={`지도 이름 ${name}`} > {name} diff --git a/frontend/src/components/TopicInfo/index.tsx b/frontend/src/components/TopicInfo/index.tsx index 4635db81..58be537a 100644 --- a/frontend/src/components/TopicInfo/index.tsx +++ b/frontend/src/components/TopicInfo/index.tsx @@ -60,9 +60,9 @@ function TopicInfo({ try { const topicUrl = window.location.href.split('?')[0]; await navigator.clipboard.writeText(topicUrl); - showToast('info', '토픽 링크가 복사되었습니다.'); + showToast('info', '지도 링크가 복사되었습니다.'); } catch (err) { - showToast('error', '토픽 링크를 복사하는데 실패했습니다.'); + showToast('error', '지도 링크를 복사하는데 실패했습니다.'); } }; diff --git a/frontend/src/constants/pinImage.ts b/frontend/src/constants/pinImage.ts index 25509c78..aed85fbe 100644 --- a/frontend/src/constants/pinImage.ts +++ b/frontend/src/constants/pinImage.ts @@ -9,6 +9,25 @@ export const USER_LOCATION_IMAGE = ` + + + + + + + + + + + + + + + + + `, 1: ` @@ -75,6 +94,7 @@ export const pinImageMap: PinImageMap = { }; export const pinColors: PinImageMap = { + 0: '#454545', 1: '#E1325C', 2: '#F9CB55', 3: '#4B5CFA', @@ -103,20 +123,22 @@ ${ ( pin: any, ) => `
- ${pin.name} -
`, + ${pin.name} + `, ) .join('') : `
- ${pinName} -
+ ${pinName} + ${ pins.length > 1 ? ` -
+${pins.length}
+
+ +${pins.length} +
` : '' } - ` + ` } -`; +`; diff --git a/frontend/src/context/MarkerContext.tsx b/frontend/src/context/MarkerContext.tsx index ec46069c..90260356 100644 --- a/frontend/src/context/MarkerContext.tsx +++ b/frontend/src/context/MarkerContext.tsx @@ -20,6 +20,8 @@ type MarkerContextType = { displayClickedMarker: () => void; }; +type ElementType = 'marker' | 'infoWindow'; + const defaultMarkerContext = () => { throw new Error('MarkerContext가 제공되지 않았습니다.'); }; @@ -49,14 +51,36 @@ function MarkerProvider({ children }: Props): JSX.Element { const { routePage } = useNavigator(); const { pathname } = useLocation(); - const createElementsInScreenSize = () => { + const createElementsColor = (elementType: ElementType = 'marker') => { + let markerType = -1; + let currentTopicId = '-1'; + + const colorMap = elementType === 'marker' ? pinImageMap : pinColors; + + const addedMarkerTypeCoordinates = coordinates.map((coordinate) => { + if (coordinate.topicId === 'clustered') { + markerType = -1; + } else if (coordinate.topicId && currentTopicId !== coordinate.topicId) { + markerType += 1; + currentTopicId = coordinate.topicId; + } + + return { ...coordinate, elementColor: colorMap[markerType + 1] }; + }); + + return addedMarkerTypeCoordinates; + }; + + const createElementsInScreenSize = (elementType: ElementType) => { if (!mapInstance) return; const mapBounds = mapInstance.getBounds(); const northEast = mapBounds._ne; const southWest = mapBounds._sw; - const coordinatesInScreenSize = coordinates.filter( + const addedMarkerTypeCoordinates = createElementsColor(elementType); + + const coordinatesInScreenSize = addedMarkerTypeCoordinates.filter( (coordinate: any) => coordinate.latitude <= northEast._lat && coordinate.latitude >= southWest._lat && @@ -67,13 +91,6 @@ function MarkerProvider({ children }: Props): JSX.Element { return coordinatesInScreenSize; }; - const createMarker = (coordinate: Coordinate, markerType: number) => - new Tmapv3.Marker({ - position: new Tmapv3.LatLng(coordinate.latitude, coordinate.longitude), - iconHTML: pinImageMap[markerType + 1], - map: mapInstance, - }); - // 현재 클릭된 좌표의 마커 생성 const displayClickedMarker = () => { if (clickedMarker) { @@ -93,19 +110,16 @@ function MarkerProvider({ children }: Props): JSX.Element { // coordinates를 받아서 marker를 생성하고, marker를 markers 배열에 추가 const createMarkers = () => { - let markerType = -1; - let currentTopicId = '-1'; - - const markersInScreenSize = createElementsInScreenSize(); + const coordinatesInScreenSize = createElementsInScreenSize('marker'); - if (!markersInScreenSize) return; + if (!coordinatesInScreenSize) return; - const newMarkers = markersInScreenSize.map((coordinate: any) => { - if (currentTopicId !== coordinate.topicId) { - markerType = (markerType + 1) % 7; - currentTopicId = coordinate.topicId; - } - const marker = createMarker(coordinate, markerType); + const newMarkers = coordinatesInScreenSize.map((coordinate: any) => { + const marker = new Tmapv3.Marker({ + position: new Tmapv3.LatLng(coordinate.latitude, coordinate.longitude), + iconHTML: coordinate.elementColor, + map: mapInstance, + }); marker.id = String(coordinate.id); return marker; }); @@ -135,25 +149,17 @@ function MarkerProvider({ children }: Props): JSX.Element { }; const createInfowindows = () => { - let markerType = -1; - let currentTopicId = '-1'; - - const windowsInScreenSize = createElementsInScreenSize(); + const coordinatesInScreenSize = createElementsInScreenSize('infoWindow'); - if (!windowsInScreenSize) return; - - const newInfowindows = windowsInScreenSize.map((coordinate: any) => { - if (currentTopicId !== coordinate.topicId) { - markerType = (markerType + 1) % 7; - currentTopicId = coordinate.topicId; - } + if (!coordinatesInScreenSize) return; + const newInfowindows = coordinatesInScreenSize.map((coordinate: any) => { const infoWindow = new Tmapv3.InfoWindow({ position: new Tmapv3.LatLng(coordinate.latitude, coordinate.longitude), border: 0, background: 'transparent', content: getInfoWindowTemplate({ - backgroundColor: pinColors[markerType + 1], + backgroundColor: coordinate.elementColor, pinName: coordinate.pinName, pins: coordinate.pins, condition: getCondition(coordinate.pins), diff --git a/frontend/src/hooks/useClickedCoordinate.ts b/frontend/src/hooks/useClickedCoordinate.ts index a7f352a9..f0d11fcc 100644 --- a/frontend/src/hooks/useClickedCoordinate.ts +++ b/frontend/src/hooks/useClickedCoordinate.ts @@ -13,7 +13,7 @@ export default function useClickedCoordinate() { useEffect(() => { if (!mapInstance) return; const currentZoom = mapInstance.getZoom(); - if (clickedCoordinate.address) displayClickedMarker(mapInstance); + if (clickedCoordinate.address) displayClickedMarker(); // 선택된 좌표가 있으면 해당 좌표로 지도의 중심을 이동 if (clickedCoordinate.latitude && clickedCoordinate.longitude) { diff --git a/frontend/src/hooks/useRealDistanceOfPin.ts b/frontend/src/hooks/useRealDistanceOfPin.ts new file mode 100644 index 00000000..0f577f35 --- /dev/null +++ b/frontend/src/hooks/useRealDistanceOfPin.ts @@ -0,0 +1,26 @@ +import { PIN_SIZE } from '../constants'; + +const useRealDistanceOfPin = () => { + const { Tmapv3 } = window; + + const getDistanceOfPin = (mapInstance: TMap) => { + const mapBounds = mapInstance.getBounds(); + + const leftWidth = new Tmapv3.LatLng(mapBounds._ne._lat, mapBounds._sw._lng); + const rightWidth = new Tmapv3.LatLng( + mapBounds._ne._lat, + mapBounds._ne._lng, + ); + + const realDistanceOfScreen = leftWidth.distanceTo(rightWidth); + const currentScreenSize = + mapInstance.realToScreen(rightWidth).x - + mapInstance.realToScreen(leftWidth).x; + + return (realDistanceOfScreen / currentScreenSize) * PIN_SIZE; + }; + + return { getDistanceOfPin }; +}; + +export default useRealDistanceOfPin; diff --git a/frontend/src/pages/NewPin.tsx b/frontend/src/pages/NewPin.tsx index d1e30358..18a7110a 100644 --- a/frontend/src/pages/NewPin.tsx +++ b/frontend/src/pages/NewPin.tsx @@ -356,7 +356,7 @@ function NewPin() { - 내 토픽 리스트 + 내 지도 리스트 핀을 저장할 지도를 선택해주세요. diff --git a/frontend/src/pages/PinDetail.tsx b/frontend/src/pages/PinDetail.tsx index 0e909fa3..3ee8866a 100644 --- a/frontend/src/pages/PinDetail.tsx +++ b/frontend/src/pages/PinDetail.tsx @@ -303,7 +303,7 @@ function PinDetail({ - 내 토픽 리스트 + 내 지도 리스트 핀을 저장할 지도를 선택해주세요. diff --git a/frontend/src/pages/SeeTogether.tsx b/frontend/src/pages/SeeTogether.tsx index c13cef55..9102ef4e 100644 --- a/frontend/src/pages/SeeTogether.tsx +++ b/frontend/src/pages/SeeTogether.tsx @@ -1,4 +1,11 @@ -import { Fragment, Suspense, useContext, useEffect, useState } from 'react'; +import { + Fragment, + Suspense, + useContext, + useEffect, + useRef, + useState, +} from 'react'; import { useLocation, useParams, useSearchParams } from 'react-router-dom'; import { styled } from 'styled-components'; @@ -16,11 +23,12 @@ import { CoordinatesContext } from '../context/CoordinatesContext'; import { MarkerContext } from '../context/MarkerContext'; import { SeeTogetherContext } from '../context/SeeTogetherContext'; import useNavigator from '../hooks/useNavigator'; +import useRealDistanceOfPin from '../hooks/useRealDistanceOfPin'; import useResizeMap from '../hooks/useResizeMap'; import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; import useSetNavbarHighlight from '../hooks/useSetNavbarHighlight'; import useTags from '../hooks/useTags'; -import { PinProps } from '../types/Pin'; +import useMapStore from '../store/mapInstance'; import { TopicDetailProps } from '../types/Topic'; import PinDetail from './PinDetail'; @@ -29,7 +37,7 @@ function SeeTogether() { const { topicId } = useParams(); const { routePage } = useNavigator(); - const [searchParams, _] = useSearchParams(); + const [searchParams] = useSearchParams(); const location = useLocation(); const [isOpen, setIsOpen] = useState(true); @@ -39,6 +47,11 @@ function SeeTogether() { null, ); + const zoomTimerIdRef = useRef(null); + const dragTimerIdRef = useRef(null); + + const { mapInstance } = useMapStore((state) => state); + const { getDistanceOfPin } = useRealDistanceOfPin(); const { tags, setTags, onClickInitTags, onClickCreateTopicWithTags } = useTags(); const { setCoordinates } = useContext(CoordinatesContext); @@ -64,34 +77,97 @@ function SeeTogether() { `/topics/ids?ids=${requestTopicIds}`, ); - setTopicDetails([...topics]); - setCoordinatesTopicDetailWithHashMap(topics); + setTopicDetails(topics); }; - const setCoordinatesTopicDetailWithHashMap = (topics: TopicDetailProps[]) => { - if (topicId === '-1' || !topicId) return; + const setClusteredCoordinates = async () => { + if (topicId === '-1' || !topicId || !topicDetails || !mapInstance) return; const newCoordinates: any = []; + const distanceOfPinSize = getDistanceOfPin(mapInstance); + + const diameterPins = await getApi( + `/topics/clusters?ids=${topicId}&image-diameter=${distanceOfPinSize}`, + ); - topics.forEach((topic: TopicDetailProps) => { - topic.pins.forEach((pin: PinProps) => { - newCoordinates.push({ - id: pin.id, - topicId: topic.id, - pinName: pin.name, - latitude: pin.latitude, - longitude: pin.longitude, - }); + diameterPins.forEach((clusterOrPin: any, idx: number) => { + newCoordinates.push({ + topicId: + clusterOrPin.pins.length > 1 && + clusterOrPin.pins.filter( + (pin: any) => pin.topicId !== clusterOrPin.pins[0].topicId, + ).length !== 0 + ? 'clustered' + : clusterOrPin.pins[0].topicId, + id: clusterOrPin.pins[0].id || `cluster ${idx}`, + pinName: clusterOrPin.pins[0].name, + latitude: clusterOrPin.latitude, + longitude: clusterOrPin.longitude, + pins: clusterOrPin.pins, }); }); - setCoordinates(newCoordinates); + const sortedNewCoordinates = newCoordinates.sort((a: any, b: any) => { + if (a.topicId === 'clustered') { + return -1; + } + + return a.topicId - b.topicId; + }); + + setCoordinates(sortedNewCoordinates); + }; + + const setPrevCoordinates = () => { + setCoordinates((prev) => [...prev]); }; const togglePinDetail = () => { setIsOpen(!isOpen); }; + const initMarkerWhenSeeTogetherIsEmpty = () => { + setCoordinates([]); + + if (markers && markers.length > 0) { + removeMarkers(); + removeInfowindows(); + } + }; + + useEffect(() => { + setClusteredCoordinates(); + + const onDragEnd = (evt: evt) => { + if (dragTimerIdRef.current) { + clearTimeout(dragTimerIdRef.current); + } + + dragTimerIdRef.current = setTimeout(() => { + setPrevCoordinates(); + }, 100); + }; + const onZoomEnd = (evt: evt) => { + if (zoomTimerIdRef.current) { + clearTimeout(zoomTimerIdRef.current); + } + + zoomTimerIdRef.current = setTimeout(() => { + setClusteredCoordinates(); + }, 100); + }; + + if (!mapInstance) return; + + mapInstance.on('DragEnd', onDragEnd); + mapInstance.on('ZoomEnd', onZoomEnd); + + return () => { + mapInstance.off('DragEnd', onDragEnd); + mapInstance.off('ZoomEnd', onZoomEnd); + }; + }, [topicDetails]); + useEffect(() => { const queryParams = new URLSearchParams(location.search); @@ -106,15 +182,9 @@ function SeeTogether() { useEffect(() => { getAndSetDataFromServer(); - }, [topicId]); - - useEffect(() => { setTags([]); - if (markers && markers.length > 0) { - removeMarkers(); - removeInfowindows(); - } + initMarkerWhenSeeTogetherIsEmpty(); }, []); if (!seeTogetherTopics || !topicId) return <>; diff --git a/frontend/src/pages/SelectedTopic.tsx b/frontend/src/pages/SelectedTopic.tsx index 28801531..8c92cd84 100644 --- a/frontend/src/pages/SelectedTopic.tsx +++ b/frontend/src/pages/SelectedTopic.tsx @@ -7,8 +7,8 @@ import Space from '../components/common/Space'; import PullPin from '../components/PullPin'; import PinsOfTopicSkeleton from '../components/Skeletons/PinsOfTopicSkeleton'; import { LAYOUT_PADDING, PIN_SIZE, SIDEBAR } from '../constants'; -import { 붕어빵지도 } from '../constants/cluster'; import { CoordinatesContext } from '../context/CoordinatesContext'; +import useRealDistanceOfPin from '../hooks/useRealDistanceOfPin'; import useResizeMap from '../hooks/useResizeMap'; import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; import useSetNavbarHighlight from '../hooks/useSetNavbarHighlight'; @@ -32,6 +32,7 @@ function SelectedTopic() { const zoomTimerIdRef = useRef(null); const dragTimerIdRef = useRef(null); const { mapInstance } = useMapStore((state) => state); + const { getDistanceOfPin } = useRealDistanceOfPin(); const { tags, setTags, onClickInitTags, onClickCreateTopicWithTags } = useTags(); @@ -47,30 +48,11 @@ function SelectedTopic() { setTopicDetail(topic); }; - const getDistanceOfPin = () => { - if (!mapInstance) return; - - const mapBounds = mapInstance.getBounds(); - - const leftWidth = new Tmapv3.LatLng(mapBounds._ne._lat, mapBounds._sw._lng); - const rightWidth = new Tmapv3.LatLng( - mapBounds._ne._lat, - mapBounds._ne._lng, - ); - - const realDistanceOfScreen = leftWidth.distanceTo(rightWidth); - const currentScreenSize = - mapInstance.realToScreen(rightWidth).x - - mapInstance.realToScreen(leftWidth).x; - - return (realDistanceOfScreen / currentScreenSize) * PIN_SIZE; - }; - const setClusteredCoordinates = async () => { - if (!topicDetail) return; + if (!topicDetail || !mapInstance) return; const newCoordinates: any = []; - const distanceOfPinSize = getDistanceOfPin(); + const distanceOfPinSize = getDistanceOfPin(mapInstance); const diameterPins = await getApi( `/topics/clusters?ids=${topicId}&image-diameter=${distanceOfPinSize}`, diff --git a/frontend/src/types/tmap.d.ts b/frontend/src/types/tmap.d.ts index dad4436b..fee0861d 100644 --- a/frontend/src/types/tmap.d.ts +++ b/frontend/src/types/tmap.d.ts @@ -59,6 +59,7 @@ interface InfoWindow { map?: Map; setMap(mapOrNull?: Map | null): void; setPosition(positionOrLatLng?: Position | LatLng): void; + getPosition(): LatLng; setContent(contentOrString?: Content | string): void; open(map?: Map, marker?: Marker, latlng?: LatLng): void; close(): void;