From f61412833f24d5cda71171f34999695a167bf56c Mon Sep 17 00:00:00 2001 From: Hyein Jeong Date: Mon, 25 Nov 2024 19:03:39 +0900 Subject: [PATCH] =?UTF-8?q?[FE][Feat]=20#258=20:=20=EC=A7=80=EB=8F=84-?= =?UTF-8?q?=EC=BA=94=EB=B2=84=EC=8A=A4=20=EC=97=B0=EB=8F=99=EB=90=9C=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20(=EB=B3=B4=EA=B8=B0=20=EC=A0=84=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사용자 현재 위치 canvas에 띄우기 - 다른 사용자들 현재 위치 canvas에 띄우기 - 그려진 다른 게스트들의 출발점,도착점,경로 canvas에 띄우기 --- .../canvasWithMapForView/MapCanvasForView.tsx | 343 ++++++++++++++++++ .../MapCanvasForDraw.tsx | 2 +- .../MapProviderForDraw.tsx | 2 +- frontend/src/lib/constants/mapConstants.ts | 3 + frontend/src/lib/types/canvasInterface.ts | 22 ++ frontend/src/pages/DrawRoute.tsx | 2 +- 6 files changed, 371 insertions(+), 3 deletions(-) create mode 100644 frontend/src/component/canvasWithMap/canvasWithMapForView/MapCanvasForView.tsx rename frontend/src/component/canvasWithMap/{ => canvasWithMapforDraw}/MapCanvasForDraw.tsx (99%) rename frontend/src/component/canvasWithMap/{ => canvasWithMapforDraw}/MapProviderForDraw.tsx (92%) diff --git a/frontend/src/component/canvasWithMap/canvasWithMapForView/MapCanvasForView.tsx b/frontend/src/component/canvasWithMap/canvasWithMapForView/MapCanvasForView.tsx new file mode 100644 index 00000000..45596aeb --- /dev/null +++ b/frontend/src/component/canvasWithMap/canvasWithMapForView/MapCanvasForView.tsx @@ -0,0 +1,343 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { ICanvasPoint, IMapCanvasViewProps, IPoint } from '@/lib/types/canvasInterface.ts'; +import startmarker from '@/assets/startmarker.png'; +import endmarker from '@/assets/endmarker.png'; +import { LINE_WIDTH, STROKE_STYLE } from '@/lib/constants/canvasConstants.ts'; + +export const MapCanvasForView = ({ + lat, + lng, + otherLocations, + guests, + width, + height, +}: IMapCanvasViewProps) => { + const mapRef = useRef(null); + const canvasRef = useRef(null); + const [projection, setProjection] = useState(null); + + const [map, setMap] = useState(null); + + const [isDragging, setIsDragging] = useState(false); + const [dragStartPos, setDragStartPos] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); + const [dragStartTime, setDragStartTime] = useState(null); + + const [isTouching, setIsTouching] = useState(false); + const [isTouchZooming, setIsTouchZooming] = useState(false); + const [touchStartDistance, setTouchStartDistance] = useState(null); + const [touchCenter, setTouchCenter] = useState<{ x: number; y: number } | null>(null); + + const startImageRef = useRef(null); + const endImageRef = useRef(null); + + useEffect(() => { + startImageRef.current = new Image(); + startImageRef.current.src = startmarker; + + endImageRef.current = new Image(); + endImageRef.current.src = endmarker; + }, []); + + useEffect(() => { + if (!mapRef.current) return; + + const mapInstance = new naver.maps.Map(mapRef.current, { + center: new naver.maps.LatLng(lat, lng), + zoom: 10, + minZoom: 7, + maxBounds: new naver.maps.LatLngBounds( + new naver.maps.LatLng(33.0, 124.5), + new naver.maps.LatLng(38.9, 131.9), + ), + }); + + setMap(mapInstance); + setProjection(mapInstance.getProjection()); + + // eslint-disable-next-line consistent-return + return () => { + mapInstance.destroy(); + }; + }, []); + + const getMarkerColor = (token: string) => { + // 문자열 해싱을 통해 고유 숫자 생성 + let hash = 0; + for (let i = 0; i < token.length; i++) { + hash = token.charCodeAt(i) + ((hash << 5) - hash); + } + + // 해시 값을 기반으로 RGB 값 생성 + const r = (hash >> 16) & 0xff; + const g = (hash >> 8) & 0xff; + const b = hash & 0xff; + + // RGB를 HEX 코드로 변환 + return `rgb(${r}, ${g}, ${b})`; + }; + + const latLngToCanvasPoint = (latLng: IPoint): ICanvasPoint | null => { + if (!map || !projection || !canvasRef.current) return null; + const coord = projection.fromCoordToOffset(new naver.maps.LatLng(latLng.lat, latLng.lng)); + const mapSize = map.getSize(); + const mapCenter = map.getCenter(); + const centerPoint = projection.fromCoordToOffset(mapCenter); + return { + x: coord.x - (centerPoint.x - mapSize.width / 2), + y: coord.y - (centerPoint.y - mapSize.height / 2), + }; + }; + + const updateCanvasSize = () => { + if (!map || !canvasRef.current) return; + const mapSize = map.getSize(); + const canvas = canvasRef.current; + canvas.width = mapSize.width; + canvas.height = mapSize.height; + canvas.style.width = `${mapSize.width}px`; + canvas.style.height = `${mapSize.height}px`; + }; + + const redrawCanvas = () => { + if (!canvasRef.current || !map) return; + + const canvas = canvasRef.current; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.clearRect(0, 0, canvas.width, canvas.height); + + ctx.lineWidth = (map.getZoom() / LINE_WIDTH) * 5; + ctx.strokeStyle = STROKE_STYLE; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + // TODO: 사용자 현재 위치 디자인 변경 + if (lat && lng) { + const currentLocation = latLngToCanvasPoint({ lat, lng }); + if (currentLocation) { + ctx.beginPath(); + ctx.arc(currentLocation.x, currentLocation.y, 10, 0, 2 * Math.PI); + ctx.fillStyle = 'blue'; + ctx.fill(); + } + } + + if (otherLocations) { + otherLocations.forEach(({ location, token }) => { + const markerColor = getMarkerColor(token); + const currentLocation = latLngToCanvasPoint(location); + if (currentLocation) { + ctx.beginPath(); + ctx.arc(currentLocation.x, currentLocation.y, 10, 0, 2 * Math.PI); + ctx.fillStyle = markerColor; + ctx.fill(); + } + }); + } + + if (guests) { + guests.forEach(({ startPoint, endPoint, paths }) => { + const startLoctaion = latLngToCanvasPoint(startPoint); + if (startLoctaion && startImageRef.current) { + const markerSize = map.getZoom() * 2; + ctx.drawImage( + startImageRef.current, + startLoctaion.x - markerSize / 2, + startLoctaion.y - markerSize, + markerSize, + markerSize, + ); + } + const endLocation = latLngToCanvasPoint(endPoint); + if (endLocation && endImageRef.current) { + const markerSize = map.getZoom() * 2; + ctx.drawImage( + endImageRef.current, + endLocation.x - markerSize / 2, + endLocation.y - markerSize, + markerSize, + markerSize, + ); + } + if (paths?.length > 0) { + ctx.beginPath(); + const firstPoint = latLngToCanvasPoint(paths[0]); + + if (firstPoint) { + ctx.moveTo(firstPoint.x, firstPoint.y); + for (let i = 1; i < paths?.length; i++) { + const point = latLngToCanvasPoint(paths[i]); + if (point) { + ctx.lineTo(point.x, point.y); + } + } + ctx.stroke(); + } + } + }); + } + }; + + const handleWheel = (e: React.WheelEvent) => { + if (!map) return; + + const zoomChange = e.deltaY < 0 ? 1 : -1; + + const currentZoom = map.getZoom(); + map.setZoom(currentZoom + zoomChange); + + redrawCanvas(); + }; + + /** + * @description 마우스 클릭을 시작했을 때 이벤트 (onMouseDown) + */ + const handleMouseDown = (e: React.MouseEvent) => { + if (!map || !canvasRef.current) return; + + setDragStartTime(Date.now()); + const rect = canvasRef.current.getBoundingClientRect(); + setDragStartPos({ x: e.clientX - rect.left, y: e.clientY - rect.top }); + }; + /** + * @description 마우스가 움직일 때 이벤트 (onMouseMove) + */ + const handleMouseMove = () => { + if (!dragStartTime) return; + + // TODO: 클릭 후 0.3초 이상이 경과했으면 dragging 시작, 이동 관련 로직 개선 필요 + const timeElapsed = Date.now() - dragStartTime; + if (timeElapsed > 300 && !isDragging) { + setIsDragging(true); + } + + if (isDragging) { + redrawCanvas(); + } + }; + /** + * @description 마우스를 손에서 떼서 클릭이 끝났을 때 이벤트 (onMouseUp) + */ + const handleMouseUp = () => { + setIsDragging(false); + setDragStartTime(null); + }; + + /** + * @description 터치 시작될 때 이벤트 (onTouchStart) + */ + const handleTouchStart = (e: React.TouchEvent) => { + if (e.touches.length === 2) { + setIsTouchZooming(true); + + const distance = Math.sqrt( + (e.touches[0].clientX - e.touches[1].clientX) ** 2 + + (e.touches[0].clientY - e.touches[1].clientY) ** 2, + ); + + setTouchStartDistance(distance); + + const centerX = (e.touches[0].clientX + e.touches[1].clientX) / 2; + const centerY = (e.touches[0].clientY + e.touches[1].clientY) / 2; + setTouchCenter({ x: centerX, y: centerY }); + } else if (e.touches.length === 1) { + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return; + + setDragStartPos({ + x: e.touches[0].clientX - rect.left, + y: e.touches[0].clientY - rect.top, + }); + setIsTouching(true); + } + }; + + /** + * @description 터치한 채로 화면을 움직일 (끌어당길) 때 이벤트 (onTouchMove) + */ + const handleTouchMove = (e: React.TouchEvent) => { + if (isTouchZooming && e.touches.length === 2 && touchStartDistance) { + const newDistance = Math.sqrt( + (e.touches[0].clientX - e.touches[1].clientX) ** 2 + + (e.touches[0].clientY - e.touches[1].clientY) ** 2, + ); + + const zoomChange = (newDistance - touchStartDistance) / 30; // TODO: 스케일링 비율 조정 + const currentZoom = map?.getZoom() ?? 10; + + map?.setOptions({ zoomOrigin: touchCenter }); + map?.setZoom(currentZoom + zoomChange); + + setTouchStartDistance(newDistance); + } else if (isTouching && e.touches.length === 1) { + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return; + + const newX = e.touches[0].clientX - rect.left; + const newY = e.touches[0].clientY - rect.top; + + const deltaX = dragStartPos.x - newX; + const deltaY = dragStartPos.y - newY; + + map?.panBy(new naver.maps.Point(deltaX, deltaY)); + setDragStartPos({ x: newX, y: newY }); + } + redrawCanvas(); + }; + + /** + * @description 화면에서 터치를 종료할 때 (손을 뗐을 때) 이벤트 (onTouchEnd) + */ + const handleTouchEnd = (e: React.TouchEvent) => { + if (e.touches.length === 0) { + setIsTouchZooming(false); + setTouchStartDistance(null); + setTouchCenter(null); + setIsTouching(false); + } + }; + + useEffect(() => { + if (isDragging) { + if (canvasRef.current) { + canvasRef.current.style.pointerEvents = 'none'; + } + redrawCanvas(); + } else if (canvasRef.current) { + canvasRef.current.style.pointerEvents = 'auto'; + } + }, [isDragging]); + + useEffect(() => { + if (!canvasRef.current || !map) return; + updateCanvasSize(); + }, [map]); + + useEffect(() => { + redrawCanvas(); + }, [guests, otherLocations, lat, lng, map]); + + return ( +
+
+ +
+ ); +}; diff --git a/frontend/src/component/canvasWithMap/MapCanvasForDraw.tsx b/frontend/src/component/canvasWithMap/canvasWithMapforDraw/MapCanvasForDraw.tsx similarity index 99% rename from frontend/src/component/canvasWithMap/MapCanvasForDraw.tsx rename to frontend/src/component/canvasWithMap/canvasWithMapforDraw/MapCanvasForDraw.tsx index f7e068c0..e6601602 100644 --- a/frontend/src/component/canvasWithMap/MapCanvasForDraw.tsx +++ b/frontend/src/component/canvasWithMap/canvasWithMapforDraw/MapCanvasForDraw.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from 'react'; -import { ButtonState } from '@/component/common/enums'; +import { ButtonState } from '@/component/common/enums.ts'; import classNames from 'classnames'; import { MdArrowCircleLeft, MdArrowCircleRight } from 'react-icons/md'; import { FloatingButton } from '@/component/common/floatingbutton/FloatingButton.tsx'; diff --git a/frontend/src/component/canvasWithMap/MapProviderForDraw.tsx b/frontend/src/component/canvasWithMap/canvasWithMapforDraw/MapProviderForDraw.tsx similarity index 92% rename from frontend/src/component/canvasWithMap/MapProviderForDraw.tsx rename to frontend/src/component/canvasWithMap/canvasWithMapforDraw/MapProviderForDraw.tsx index 11e6ffb5..e0cd5f8a 100644 --- a/frontend/src/component/canvasWithMap/MapProviderForDraw.tsx +++ b/frontend/src/component/canvasWithMap/canvasWithMapforDraw/MapProviderForDraw.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { MapCanvasForDraw } from '@/component/canvasWithMap/MapCanvasForDraw.tsx'; +import { MapCanvasForDraw } from '@/component/canvasWithMap/canvasWithMapforDraw/MapCanvasForDraw.tsx'; import { DEFAULT_CENTER } from '@/lib/constants/mapConstants.ts'; import { ICanvasScreenProps } from '@/lib/types/canvasInterface.ts'; diff --git a/frontend/src/lib/constants/mapConstants.ts b/frontend/src/lib/constants/mapConstants.ts index ff0c3825..9ea59a6e 100644 --- a/frontend/src/lib/constants/mapConstants.ts +++ b/frontend/src/lib/constants/mapConstants.ts @@ -9,3 +9,6 @@ export const DEFAULT_CENTER = { lat: (KOREA_BOUNDS.sw.lat + KOREA_BOUNDS.ne.lat) / 2, lng: (KOREA_BOUNDS.sw.lng + KOREA_BOUNDS.ne.lng) / 2, }; + +export const MIN_ZOOM = 7; // 대한민국 전체가 보이는 최소 줌 레벨 +export const MAX_ZOOM = 19; // 네이버 지도 최대 줌 레벨 diff --git a/frontend/src/lib/types/canvasInterface.ts b/frontend/src/lib/types/canvasInterface.ts index 156d814e..924592a5 100644 --- a/frontend/src/lib/types/canvasInterface.ts +++ b/frontend/src/lib/types/canvasInterface.ts @@ -19,3 +19,25 @@ export interface ICanvasScreenProps { width: number; height: number; } + +export interface IOtherLiveLocations { + location: IPoint; + token: string; +} + +export interface IGuestDataInMapProps { + guestName: string; + guestUUID: string; + startPoint: IPoint; + endPoint: IPoint; + paths: IPoint[]; +} + +export interface IMapCanvasViewProps { + lat: number; + lng: number; + otherLocations?: IOtherLiveLocations[] | null; + guests?: IGuestDataInMapProps[] | null; + width: string; + height: string; +} diff --git a/frontend/src/pages/DrawRoute.tsx b/frontend/src/pages/DrawRoute.tsx index 629fe2a5..23a30e2a 100644 --- a/frontend/src/pages/DrawRoute.tsx +++ b/frontend/src/pages/DrawRoute.tsx @@ -2,7 +2,7 @@ import { useContext, useEffect } from 'react'; import { FooterContext } from '@/component/layout/footer/LayoutFooterProvider'; import { useParams } from 'react-router-dom'; import { UserContext } from '@/context/UserContext'; -import { MapProviderForDraw } from '@/component/canvasWithMap/MapProviderForDraw.tsx'; +import { MapProviderForDraw } from '@/component/canvasWithMap/canvasWithMapforDraw/MapProviderForDraw.tsx'; export const DrawRoute = () => { const { users, setUsers } = useContext(UserContext);