diff --git a/frontend/src/api/channel.api.ts b/frontend/src/api/channel.api.ts index d477ccda..2e63effc 100644 --- a/frontend/src/api/channel.api.ts +++ b/frontend/src/api/channel.api.ts @@ -7,6 +7,7 @@ import { getChannelResEntity, getUserChannelsResEntity, getGuestResEntity, + deleteChannelResEntity, } from '@/api/dto/channel.dto.ts'; import { getApiClient } from '@/api/client.api.ts'; @@ -136,3 +137,28 @@ export const getGuestInfo = ( }; return new Promise(promiseFn); }; + +export const deleteChannel = (channelId: string): Promise> => { + const promiseFn = ( + fnResolve: (value: ResponseDto) => void, + fnReject: (reason?: any) => void, + ) => { + const apiClient = getApiClient(); + apiClient + .delete(`/channel/${channelId}`) + .then(res => { + if (res.status !== 200) { + console.error(res); + fnReject(`msg.${res}`); + } else { + fnResolve(new ResponseDto(res.data)); + } + }) + .catch(err => { + console.log(channelId); + console.error(err); + fnReject('msg.RESULT_FAILED'); + }); + }; + return new Promise(promiseFn); +}; diff --git a/frontend/src/api/dto/channel.dto.ts b/frontend/src/api/dto/channel.dto.ts index b38e3774..1b7161bb 100644 --- a/frontend/src/api/dto/channel.dto.ts +++ b/frontend/src/api/dto/channel.dto.ts @@ -113,3 +113,7 @@ export class getGuestResEntity { guest: guestEntity | undefined; } + +export class deleteChannelResEntity { + id: string | undefined; +} diff --git a/frontend/src/assets/footprint.svg b/frontend/src/assets/footprint.svg index 6f631378..52e50feb 100644 --- a/frontend/src/assets/footprint.svg +++ b/frontend/src/assets/footprint.svg @@ -1 +1,5 @@ - \ No newline at end of file + + + \ No newline at end of file diff --git a/frontend/src/component/authmodal/AuthModal.tsx b/frontend/src/component/authmodal/AuthModal.tsx index 79493a6b..1778664f 100644 --- a/frontend/src/component/authmodal/AuthModal.tsx +++ b/frontend/src/component/authmodal/AuthModal.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Modal } from '@/component/common/modal/Modal'; import { doLogin, doRegister } from '@/api/auth.api.ts'; import { saveLocalData } from '@/utils/common/manageLocalData.ts'; @@ -91,6 +91,10 @@ export const AuthModal = (props: IAuthModalProps) => { }); }; + useEffect(() => { + if (!props.isOpen) switchToLogin(); + }, [props.isOpen]); + return ( {modalType === 'login' ? ( diff --git a/frontend/src/component/canvasWithMap/canvasWithMapForView/MapCanvasForView.tsx b/frontend/src/component/canvasWithMap/canvasWithMapForView/MapCanvasForView.tsx index d30578aa..84c540cf 100644 --- a/frontend/src/component/canvasWithMap/canvasWithMapForView/MapCanvasForView.tsx +++ b/frontend/src/component/canvasWithMap/canvasWithMapForView/MapCanvasForView.tsx @@ -3,6 +3,7 @@ import { ICanvasPoint, IMapCanvasViewProps, IPoint } from '@/lib/types/canvasInt import { useCanvasInteraction } from '@/hooks/useCanvasInteraction'; import { useRedrawCanvas } from '@/hooks/useRedraw'; import { ZoomSlider } from '@/component/zoomslider/ZoomSlider'; +import { ICluster, useCluster } from '@/hooks/useCluster'; export const MapCanvasForView = forwardRef( ({ lat, lng, alpha, otherLocations, guests, width, height }: IMapCanvasViewProps, ref) => { @@ -10,6 +11,8 @@ export const MapCanvasForView = forwardRef(null); const [projection, setProjection] = useState(null); const [map, setMap] = useState(null); + const { createClusters } = useCluster(); + const [clusters, setClusters] = useState(null); useImperativeHandle(ref, () => map as naver.maps.Map); @@ -66,6 +69,7 @@ export const MapCanvasForView = forwardRef { + const updateClusters = () => { + if (map && guests && guests.length > 0) { + const createdClusters = guests + .map(guest => + createClusters([guest.startPoint, guest.endPoint], guest.markerStyle, map), + ) + .flat(); + + setClusters(createdClusters); + } + }; + + // 컴포넌트가 처음 마운트될 때 즉시 실행 + updateClusters(); + + const intervalId = setInterval(() => { + updateClusters(); + }, 100); + + return () => clearInterval(intervalId); // 컴포넌트 언마운트 시 인터벌 클리어 + }, [guests, map]); + useEffect(() => { redrawCanvas(); - }, [guests, otherLocations, lat, lng, alpha, mapRef, handleWheel]); + }, [guests, otherLocations, lat, lng, alpha, clusters, handleWheel]); return (
([]); + useEffect(() => { const updateUser = () => { setCurrentUser(prevUser => { @@ -138,6 +142,7 @@ export const MapCanvasForDraw = ({ startMarker, endMarker, pathPoints, + clusters, }); const handleCanvasClick = (e: React.MouseEvent) => { @@ -238,11 +243,11 @@ export const MapCanvasForDraw = ({ }, [map]); useEffect(() => { - if (startMarker && endMarker) { - const markers = []; - - if (startMarker) markers.push(startMarker); - if (endMarker) markers.push(endMarker); + if (startMarker && endMarker && map) { + const markers = [ + { lat: startMarker.lat, lng: startMarker.lng }, + { lat: endMarker.lat, lng: endMarker.lng }, + ]; zoomMapView(map, markers); } else { @@ -257,9 +262,24 @@ export const MapCanvasForDraw = ({ } }, [startMarker, endMarker]); + useEffect(() => { + const intervalId = setInterval(() => { + if (startMarker && endMarker && map) { + const markers = [ + { lat: startMarker.lat, lng: startMarker.lng }, + { lat: endMarker.lat, lng: endMarker.lng }, + ]; + + const createdClusters = createClusters(markers, { color: '#333C4A' }, map); + setClusters(createdClusters); + } + }, 100); + + return () => clearInterval(intervalId); // 컴포넌트 언마운트 시 인터벌 클리어 + }, [startMarker, endMarker, map]); useEffect(() => { redrawCanvas(); - }, [startMarker, endMarker, pathPoints, map, undoStack, redoStack]); + }, [startMarker, endMarker, clusters, pathPoints, map, undoStack, redoStack]); return (
{ - const [progress, setProgress] = useState(100); + const [animateProgress, setAnimateProgress] = useState(false); useEffect(() => { if (!autoClose) return; - const start = performance.now(); + const timeout = setTimeout(() => { + setAnimateProgress(true); + }, 50); - const updateProgress = (current: number) => { - const elapsed = current - start; - const newProgress = Math.max(0, 100 - (elapsed / duration) * 100); + const timer = setTimeout(() => { + onClose?.(); + }, duration); - setProgress(newProgress); - - if (elapsed < duration) { - requestAnimationFrame(updateProgress); - } else { - onClose?.(); - } + return () => { + clearTimeout(timeout); + clearTimeout(timer); }; - - const animationFrame = requestAnimationFrame(updateProgress); - - return () => cancelAnimationFrame(animationFrame); }, [duration, autoClose, onClose]); return ( <> -
+
{message}
{autoClose ? ( -
+
) : ( diff --git a/frontend/src/component/common/modal/ModalFooter.tsx b/frontend/src/component/common/modal/ModalFooter.tsx index c255754b..acddf835 100644 --- a/frontend/src/component/common/modal/ModalFooter.tsx +++ b/frontend/src/component/common/modal/ModalFooter.tsx @@ -13,7 +13,7 @@ export const ModalFooter = (props: IModalFooterProps) => (
+
+ ) : ( +
+ + +
+ )} +
+
+ ); +}; diff --git a/frontend/src/component/content/Content.tsx b/frontend/src/component/content/Content.tsx index 9eb9d5f3..cb16e560 100644 --- a/frontend/src/component/content/Content.tsx +++ b/frontend/src/component/content/Content.tsx @@ -12,6 +12,7 @@ interface IContentProps { link: string; time: string; channelId: string; + onDelete?: (channelId: string) => void; } /** @@ -41,11 +42,13 @@ export const Content = (props: IContentProps) => { year: 'numeric', month: '2-digit', day: '2-digit', + timeZone: 'UTC', }); const formattedTime = new Date(props.time).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', + timeZone: 'UTC', }); const navigate = useNavigate(); const { channelInfo, setChannelInfo } = useContext(ChannelContext); @@ -60,6 +63,11 @@ export const Content = (props: IContentProps) => { console.error('Failed to get channel info:', error); } }; + + const deleteChannelItem = async () => { + props.onDelete?.(props.channelId); + }; + const goToChannelInfoPage = () => { if (channelInfo?.id) { navigate(`/channelInfo/${channelInfo.id}`); @@ -77,6 +85,10 @@ export const Content = (props: IContentProps) => { goToChannelInfoPage(); }; + const handleDelete = () => { + deleteChannelItem(); + }; + return (
{ > 공유하기 - + 삭제하기 diff --git a/frontend/src/component/header/HeaderLayout.tsx b/frontend/src/component/header/HeaderLayout.tsx index a0cdf5b1..36466eaa 100644 --- a/frontend/src/component/header/HeaderLayout.tsx +++ b/frontend/src/component/header/HeaderLayout.tsx @@ -1,9 +1,14 @@ import React, { ReactNode } from 'react'; import classNames from 'classnames'; +export interface IItem { + id: string; // 고유 식별자 + content: ReactNode; +} + interface IHeaderProps { - leftItems?: ReactNode[]; - rightItems?: ReactNode[]; + leftItems?: IItem[]; + rightItems?: IItem[]; title?: ReactNode; subtitle?: string; subtitleIcons?: React.ComponentType<{}>; @@ -23,7 +28,9 @@ export const HeaderLayout = (props: IHeaderProps) => {
{props.leftItems && props.leftItems.map(item => ( -
{item}
+
+ {item.content} +
))}
{props.userName}
@@ -33,7 +40,9 @@ export const HeaderLayout = (props: IHeaderProps) => {
{props.rightItems && props.rightItems.map(item => ( -
{item}
+
+ {item.content} +
))}
diff --git a/frontend/src/component/layout/constant/HeaderConst.ts b/frontend/src/component/layout/constant/HeaderConst.ts index 752fa5d2..cc8a3a8b 100644 --- a/frontend/src/component/layout/constant/HeaderConst.ts +++ b/frontend/src/component/layout/constant/HeaderConst.ts @@ -1,6 +1,7 @@ import { HeaderBackButton } from '@/component/header/HeaderBackButton'; import { HeaderDropdown } from '@/component/header/HeaderDropdown'; -import React, { ReactNode } from 'react'; +import { IItem } from '@/component/header/HeaderLayout'; +import React from 'react'; import { MdInfo } from 'react-icons/md'; export const HEADER_TITLE: Record = { @@ -15,17 +16,19 @@ export const HEADER_SUBTITLEICONS: Record = { '/add-channel/:user/draw': MdInfo, }; -export const HEADER_LEFTITEMS: Record = { - '/add-channel': [React.createElement(HeaderBackButton)], - '/add-channel/:user': [React.createElement(HeaderBackButton)], - '/add-channel/:user/draw': [React.createElement(HeaderBackButton)], - '/channel/:channelId/host': [React.createElement(HeaderBackButton)], - '/update-channel': [React.createElement(HeaderBackButton)], - '/register': [React.createElement(HeaderBackButton)], - '/channelInfo/:channelId': [React.createElement(HeaderBackButton)], - '/guest-add-channel/:channelId': [React.createElement(HeaderBackButton)], +export const HEADER_LEFTITEMS: Record = { + '/add-channel': [{ id: 'item1', content: React.createElement(HeaderBackButton) }], + '/add-channel/:user': [{ id: 'item1', content: React.createElement(HeaderBackButton) }], + '/add-channel/:user/draw': [{ id: 'item1', content: React.createElement(HeaderBackButton) }], + '/channel/:channelId/host': [{ id: 'item1', content: React.createElement(HeaderBackButton) }], + '/update-channel': [{ id: 'item1', content: React.createElement(HeaderBackButton) }], + '/register': [{ id: 'item1', content: React.createElement(HeaderBackButton) }], + '/channelInfo/:channelId': [{ id: 'item1', content: React.createElement(HeaderBackButton) }], + '/guest-add-channel/:channelId': [ + { id: 'item1', content: React.createElement(HeaderBackButton) }, + ], }; -export const HEADER_RIGHTITEMS: Record = { - '/channel/:channelId/host': [React.createElement(HeaderDropdown)], +export const HEADER_RIGHTITEMS: Record = { + '/channel/:channelId/host': [{ id: 'item1', content: React.createElement(HeaderDropdown) }], }; diff --git a/frontend/src/component/routebutton/RouteResultButton.tsx b/frontend/src/component/routebutton/RouteResultButton.tsx index 984725a9..49a4f06b 100644 --- a/frontend/src/component/routebutton/RouteResultButton.tsx +++ b/frontend/src/component/routebutton/RouteResultButton.tsx @@ -11,6 +11,7 @@ import { Page } from './enum'; interface IRouteResultButtonProps { user: IUser; + setUserName?: (index: number, newName: string) => void; deleteUser?: (index: number) => void; page?: Page; isGuest?: boolean; @@ -32,7 +33,7 @@ export const RouteResultButton = (props: IRouteResultButtonProps) => { .writeText(url) .then(() => { props.showAlert?.( - `${channelInfo.name} 경로의 링크가 복사되었습니다\n사용자에게 링크를 보내주세요!\n\n${url}`, + `${channelInfo.name} 경로의 링크가 복사되었습니다\n${props.user.name}에게 링크를 보내주세요!\n\n${url}`, ); }) .catch(() => { @@ -40,10 +41,24 @@ export const RouteResultButton = (props: IRouteResultButtonProps) => { }); }; + const handleChangeName = (event: React.ChangeEvent) => { + if (props.setUserName) { + props.setUserName(props.user.index, event.target.value); + } + }; + return (
-

{props.user.name}

+ { + handleChangeName(e); + }} + disabled={props.isGuest} + />
)} - {props.isGuest ? ( + {props.isGuest && props.setUserName ? (
) : props.user.index === 1 && props.page === Page.ADD ? (
diff --git a/frontend/src/component/routebutton/RouteSettingButton.tsx b/frontend/src/component/routebutton/RouteSettingButton.tsx index 2082899d..ae1cb526 100644 --- a/frontend/src/component/routebutton/RouteSettingButton.tsx +++ b/frontend/src/component/routebutton/RouteSettingButton.tsx @@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom'; interface IRouteSettingButtonProps { user: IUser; + setUserName: (index: number, newName: string) => void; deleteUser?: (index: number) => void; } @@ -15,10 +16,21 @@ export const RouteSettingButton = (props: IRouteSettingButtonProps) => { navigate(`/add-channel/${user}/draw`); }; + const handleChangeName = (event: React.ChangeEvent) => { + props.setUserName(props.user.index, event.target.value); + }; + return (
-

{props.user.name}

+ { + handleChangeName(e); + }} + />
))}
diff --git a/frontend/src/component/zoomslider/ZoomSlider.css b/frontend/src/component/zoomslider/ZoomSlider.css new file mode 100644 index 00000000..f7098168 --- /dev/null +++ b/frontend/src/component/zoomslider/ZoomSlider.css @@ -0,0 +1,34 @@ +.rangeInput { + width: 90%; + border-radius: 8px; + outline: none; + transition: background 450ms ease-in; + -webkit-appearance: none; + accent-color: #333c4a; + height: 8px; +} + +.rangeInput::-webkit-slider-thumb { + -webkit-appearance: none; + height: 15px; + width: 15px; + background-color: #333c4a; + border-radius: 50%; + cursor: pointer; + transform: translateY(-25%); + position: relative; +} +.rangeInput::-moz-range-thumb { + height: 15px; + width: 15px; + background-color: #333c4a; + border-radius: 50%; + cursor: pointer; + transform: translateY(-25%); + position: relative; +} + +.rangeInput::-webkit-slider-runnable-track { + height: 8px; + border-radius: 8px; +} diff --git a/frontend/src/component/zoomslider/ZoomSlider.tsx b/frontend/src/component/zoomslider/ZoomSlider.tsx index 98071fdd..3b510121 100644 --- a/frontend/src/component/zoomslider/ZoomSlider.tsx +++ b/frontend/src/component/zoomslider/ZoomSlider.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from 'react'; import { MdOutlineAdd, MdRemove } from 'react-icons/md'; +import './ZoomSlider.css'; interface IZoomSliderProps { /** Naver 지도 객체 */ @@ -28,29 +29,41 @@ const ZoomButton = ({ label, onClick }: IZoomButtonProps) => ( ); -const ZoomSliderInput = ({ zoomLevel, onSliderChange }: IZoomSliderInputProps) => ( -
- -
-
-); +const ZoomSliderInput = ({ zoomLevel, onSliderChange }: IZoomSliderInputProps) => { + const minZoom = 6; + const maxZoom = 22; + + const getBackgroundStyle = () => { + const gradientValue = ((zoomLevel - minZoom) / (maxZoom - minZoom)) * 100; + return `linear-gradient(to right, #333C4A 0%, #333C4A ${gradientValue}%, #ececec ${gradientValue}%, #ececec 100%)`; + }; + + return ( +
+ +
+
+ ); +}; export const ZoomSlider = ({ map, redrawCanvas }: IZoomSliderProps) => { const [zoomLevel, setZoomLevel] = useState(map?.getZoom() ?? 10); diff --git a/frontend/src/hooks/useCluster.ts b/frontend/src/hooks/useCluster.ts new file mode 100644 index 00000000..49e0c3e0 --- /dev/null +++ b/frontend/src/hooks/useCluster.ts @@ -0,0 +1,92 @@ +import { IMarkerStyle } from '@/lib/types/canvasInterface'; + +interface IPixel { + x: number; + y: number; +} + +interface IMarker { + lat: number; + lng: number; +} + +export interface ICluster { + markers: IMarker[]; + center: IMarker; + color: IMarkerStyle; +} + +export const useCluster = () => { + const calculateDistance = (pointA: IPixel, pointB: IPixel) => { + // 유클리드 거리 계산 (픽셀 단위) + const dx = pointA.x - pointB.x; + const dy = pointA.y - pointB.y; + return Math.sqrt(dx * dx + dy * dy); + }; + + const createClusters = (markers: IMarker[], color: IMarkerStyle, map: naver.maps.Map) => { + const projection = map.getProjection(); + const latLngToCanvasPoint = (latLng: naver.maps.LatLng): IPixel | null => { + if (!map || !projection) return null; + + const lat = latLng.lat(); + const lng = latLng.lng(); + + const coord = projection.fromCoordToOffset(new naver.maps.LatLng(lat, 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 clusters: ICluster[] = []; + const visited = new Set(); + const clusterRadius = 50; + + if (!projection) { + console.error('Projection을 가져올 수 없습니다.'); + return clusters; + } + + markers.forEach((marker, index) => { + if (visited.has(index)) return; + + // 새로운 클러스터 생성 + const cluster = { markers: [marker], center: marker, color }; + visited.add(index); + + // 현재 마커와 가까운 마커 찾기 + for (let i = index + 1; i < markers.length; i++) { + if (!visited.has(i)) { + const markerPixel = latLngToCanvasPoint(new naver.maps.LatLng(marker.lat, marker.lng)); + const otherMarkerPixel = latLngToCanvasPoint( + new naver.maps.LatLng(markers[i].lat, markers[i].lng), + ); + if (markerPixel !== null && otherMarkerPixel !== null) { + const distance = calculateDistance(markerPixel, otherMarkerPixel); + if (distance < clusterRadius) { + cluster.markers.push(markers[i]); + visited.add(i); + } + } + } + } + // 클러스터의 중심 계산: 마커들의 위도와 경도의 평균값 + const centerLat = cluster.markers.reduce((sum, m) => sum + m.lat, 0) / cluster.markers.length; + const centerLng = cluster.markers.reduce((sum, m) => sum + m.lng, 0) / cluster.markers.length; + + cluster.center = { lat: centerLat, lng: centerLng }; + if (cluster.markers.length > 1) { + clusters.push(cluster); + } + }); + + return clusters; + }; + + return { createClusters }; +}; diff --git a/frontend/src/hooks/useRedraw.ts b/frontend/src/hooks/useRedraw.ts index bcd6530d..36ac929b 100644 --- a/frontend/src/hooks/useRedraw.ts +++ b/frontend/src/hooks/useRedraw.ts @@ -14,6 +14,7 @@ import character1 from '@/assets/character1.png'; import character2 from '@/assets/character2.png'; import { IMarkerStyle } from '@/lib/types/canvasInterface.ts'; import footprint from '@/assets/footprint.svg'; +import { ICluster } from './useCluster'; interface ILatLng { lat: number; @@ -52,6 +53,7 @@ interface IUseRedrawCanvasProps { lat?: number; lng?: number; alpha?: number | null; + clusters?: ICluster[] | null; } enum MARKER_TYPE { @@ -72,6 +74,7 @@ export const useRedrawCanvas = ({ lat, lng, alpha = 0, + clusters = [], }: IUseRedrawCanvasProps) => { const startImageRef = useRef(null); const endImageRef = useRef(null); @@ -125,6 +128,40 @@ export const useRedrawCanvas = ({ return tempCanvas; }; + const checkMarker = (path: string) => { + if (path.includes('startmarker') || path.includes('endmarker') || path.includes('mylocation')) { + return true; + } + return false; + }; + + const drawCluster = ( + ctx: CanvasRenderingContext2D, + cluster: ICluster, + image: HTMLImageElement | null, + zoom: number, + color: string, + ) => { + if (!cluster || !cluster.center || !cluster.markers.length) return; + + // 클러스터 중심을 캔버스 좌표로 변환 + const clusterCenter = latLngToCanvasPoint(cluster.center); + + if (clusterCenter && image) { + const markerSize = zoom < 18 ? Math.min(zoom * 5, 50) : (zoom - 15) * (zoom - 16) * 10; + ctx.fillStyle = color || '#000'; + ctx.strokeStyle = color || '#000'; + ctx.save(); + ctx.translate(clusterCenter.x, clusterCenter.y); + let filteredImage; + if (checkMarker(image.src)) + filteredImage = colorizeImage(image, color, markerSize, markerSize); + else filteredImage = image; + ctx.drawImage(filteredImage, -markerSize / 2, -markerSize / 2, markerSize, markerSize); + ctx.restore(); + } + }; + const drawMarker = ( ctx: CanvasRenderingContext2D, point: { x: number; y: number } | null, @@ -218,9 +255,25 @@ export const useRedrawCanvas = ({ const footprintImage = footprintRef.current; const markerSize = Math.min(map.getZoom() * 2, 20); - + const offsetDistance = markerSize * 0.3; const offscreenCanvas = colorizeImage(footprintImage, color, markerSize, markerSize); + const path = new Path2D(); + + ctx.beginPath(); + ctx.setLineDash([10, 5]); + + if (points.length === 1) { + const point = latLngToCanvasPoint(points[0]); + if (point) { + ctx.save(); + ctx.translate(point.x, point.y); + ctx.drawImage(offscreenCanvas, -markerSize / 2, -markerSize / 2); // 발자국 이미지 그리기 + ctx.restore(); + } + return; + } + for (let i = 0; i < points.length - 1; i++) { const start = latLngToCanvasPoint(points[i]); const end = latLngToCanvasPoint(points[i + 1]); @@ -230,9 +283,11 @@ export const useRedrawCanvas = ({ continue; } - const angle = Math.atan2(end.y - start.y, end.x - start.x); + path.moveTo(start.x, start.y); + path.lineTo(end.x, end.y); - const distance = 30; + const angle = Math.atan2(end.y - start.y, end.x - start.x); + const distance = 25; const totalDistance = Math.sqrt((end.x - start.x) ** 2 + (end.y - start.y) ** 2); const steps = Math.floor(totalDistance / distance); @@ -241,14 +296,24 @@ export const useRedrawCanvas = ({ const x = start.x + progress * (end.x - start.x); const y = start.y + progress * (end.y - start.y); + const isLeftFoot = j % 2 === 0; + const offsetX = + Math.cos(angle + (isLeftFoot ? Math.PI / 2 : -Math.PI / 2)) * offsetDistance; + const offsetY = + Math.sin(angle + (isLeftFoot ? Math.PI / 2 : -Math.PI / 2)) * offsetDistance; + ctx.save(); - ctx.translate(x, y); + ctx.translate(x + offsetX, y + offsetY); ctx.rotate(angle + Math.PI / 2); - ctx.drawImage(offscreenCanvas, -markerSize / 2, -markerSize / 2); + ctx.drawImage(offscreenCanvas, -markerSize / 2, -markerSize / 2); // 발자국 이미지 그리기 ctx.restore(); } - ctx.stroke(); } + + ctx.strokeStyle = hexToRgba(color, 0.1); + ctx.lineWidth = 10; + ctx.stroke(path); + ctx.setLineDash([]); }; const redrawCanvas = () => { @@ -264,64 +329,48 @@ export const useRedrawCanvas = ({ ctx.lineCap = 'round'; ctx.lineJoin = 'round'; - // 호스트가 게스트 경로 그릴때 쓰이는 디자인 + const clusteredMarkerSet = new Set(); + clusters?.forEach(cluster => { + cluster.markers.forEach((marker: any) => + clusteredMarkerSet.add(`${marker.lat.toFixed(6)}_${marker.lng.toFixed(6)}`), + ); + }); + const zoom = map.getZoom(); if (startMarker) { const startPoint = latLngToCanvasPoint(startMarker); - drawMarker( - ctx, - startPoint, - startImageRef.current, - zoom, - 0, - START_MARKER_COLOR, - MARKER_TYPE.START_MARKER, - ); - } - - if (endMarker) { - const endPoint = latLngToCanvasPoint(endMarker); - drawMarker( - ctx, - endPoint, - endImageRef.current, - zoom, - 0, - END_MARKER_COLOR, - MARKER_TYPE.END_MARKER, - ); - } - - if (pathPoints) { - drawPath(ctx, pathPoints, PATH_COLOR); - } - - if (guests) { - guests.forEach(({ startPoint, endPoint, paths, markerStyle }) => { - const startLocation = latLngToCanvasPoint(startPoint); + const markerKey = `${startMarker.lat.toFixed(6)}_${startMarker.lng.toFixed(6)}`; + if (!clusteredMarkerSet.has(markerKey)) { drawMarker( ctx, - startLocation, + startPoint, startImageRef.current, zoom, 0, - markerStyle.color, + START_MARKER_COLOR, MARKER_TYPE.START_MARKER, ); + } + } - const endLocation = latLngToCanvasPoint(endPoint); + if (endMarker) { + const endPoint = latLngToCanvasPoint(endMarker); + const markerKey = `${endMarker.lat.toFixed(6)}_${endMarker.lng.toFixed(6)}`; + if (!clusteredMarkerSet.has(markerKey)) { drawMarker( ctx, - endLocation, + endPoint, endImageRef.current, zoom, 0, - markerStyle.color, + END_MARKER_COLOR, MARKER_TYPE.END_MARKER, ); + } + } - drawPath(ctx, paths, markerStyle.color); - }); + if (pathPoints) { + drawPath(ctx, pathPoints, PATH_COLOR); } if (lat && lng) { @@ -368,6 +417,48 @@ export const useRedrawCanvas = ({ ); }); } + + if (guests) { + guests.forEach(({ startPoint, endPoint, paths, markerStyle }) => { + const startLocationKey = `${startPoint.lat.toFixed(6)}_${startPoint.lng.toFixed(6)}`; + const endLocationKey = `${endPoint.lat.toFixed(6)}_${endPoint.lng.toFixed(6)}`; + if (!clusteredMarkerSet.has(startLocationKey)) { + const startLocation = latLngToCanvasPoint(startPoint); + drawMarker( + ctx, + startLocation, + startImageRef.current, + zoom, + 0, + markerStyle.color, + MARKER_TYPE.START_MARKER, + ); + } + + if (!clusteredMarkerSet.has(endLocationKey)) { + const endLocation = latLngToCanvasPoint(endPoint); + drawMarker( + ctx, + endLocation, + endImageRef.current, + zoom, + 0, + markerStyle.color, + MARKER_TYPE.END_MARKER, + ); + } + + // 경로는 두 포인트 중 하나라도 클러스터에 포함되지 않으면 그리기 + if (!clusteredMarkerSet.has(startLocationKey) || !clusteredMarkerSet.has(endLocationKey)) { + drawPath(ctx, paths, markerStyle.color); + } + }); + } + if (clusters) { + clusters.forEach(cluster => { + drawCluster(ctx, cluster, startImageRef.current, zoom, cluster.color.color); + }); + } }; return { redrawCanvas }; diff --git a/frontend/src/pages/AddChannel.tsx b/frontend/src/pages/AddChannel.tsx index 8c6d291f..131a1140 100644 --- a/frontend/src/pages/AddChannel.tsx +++ b/frontend/src/pages/AddChannel.tsx @@ -47,6 +47,7 @@ export const AddChannel = () => { setFooterOnClick, resetFooterContext, } = useContext(FooterContext); + const navigate = useNavigate(); const goToMainPage = () => { navigate('/'); @@ -94,6 +95,9 @@ export const AddChannel = () => { user.marker_style.color !== '' ); }; + const isChannelNameComplete = (): boolean => { + return channelName.trim() !== ''; + }; /** * 사용자 추가 함수 @@ -119,11 +123,18 @@ export const AddChannel = () => { .map((user, i) => ({ ...user, index: i + 1, - name: `사용자${i + 1}`, + name: user.name || `사용자${i + 1}`, })); setUsers(updatedUsers); }; + const setUserName = (index: number, newName: string) => { + const updatedUsers = users.map(user => + user.index === index ? { ...user, name: newName } : user, + ); + setUsers(updatedUsers); + }; + const handleChangeChannelName = (event: React.ChangeEvent) => { setChannelName(event.target.value); }; @@ -139,21 +150,25 @@ export const AddChannel = () => { addUser(); // users가 비어있다면 기본 사용자 추가 } const allUsersComplete = users.every(isUserDataComplete); - // 모든 사용자가 완전한 데이터라면 Footer를 활성화 - if (allUsersComplete) { + if (allUsersComplete && isChannelNameComplete()) { setFooterActive(buttonActiveType.ACTIVE); } else { setFooterActive(buttonActiveType.PASSIVE); } - }, [users, setFooterActive]); // users가 변경될 때마다 실행 + }, [users, setFooterActive, channelName]); // users가 변경될 때마다 실행 const createChannelAPI = async () => { try { const userId = loadLocalData(AppConfig.KEYS.LOGIN_USER); + + if (!userId) { + console.error('유효하지 않은 사용자 ID입니다.'); + return; + } const channelData: createChannelReqEntity = { name: channelName, - host_id: userId ?? undefined, // 추후 검증 로직 추가 예정 + host_id: userId, // 추후 검증 로직 추가 예정 guests: users.map(user => ({ name: user.name, start_location: { @@ -200,9 +215,14 @@ export const AddChannel = () => { {users.map(user => (
{isUserDataComplete(user) ? ( - + ) : ( - + )}
))} diff --git a/frontend/src/pages/AddGuestPage.tsx b/frontend/src/pages/AddGuestPage.tsx index cdb0e33a..f2de144d 100644 --- a/frontend/src/pages/AddGuestPage.tsx +++ b/frontend/src/pages/AddGuestPage.tsx @@ -118,7 +118,7 @@ export const AddGuestPage = () => { .map((user, i) => ({ ...user, index: guests.length + i + 1, - name: `사용자${guests.length + i + 1}`, + name: user.name || `사용자${guests.length + i + 1}`, })); setUsers(updatedUsers); }; @@ -157,7 +157,7 @@ export const AddGuestPage = () => { }; useEffect(() => { - setFooterTitle('제작 완료'); + setFooterTitle('수정 완료'); setFooterTransparency(false); setFooterActive(buttonActiveType.PASSIVE); if (channelInfo?.guests) { @@ -182,6 +182,13 @@ export const AddGuestPage = () => { } }, [footerOption.active]); + const setUserName = (index: number, newName: string) => { + const updatedUsers = users.map(user => + user.index === index ? { ...user, name: newName } : user, + ); + setUsers(updatedUsers); + }; + return (
@@ -194,16 +201,21 @@ export const AddGuestPage = () => {
{guests.map(guest => (
- +
))} {users && users.map(user => (
{isUserDataComplete(user) ? ( - + ) : ( - + )}
))} diff --git a/frontend/src/pages/ChannelInfoPage.tsx b/frontend/src/pages/ChannelInfoPage.tsx index 285e4ae1..d3f269ac 100644 --- a/frontend/src/pages/ChannelInfoPage.tsx +++ b/frontend/src/pages/ChannelInfoPage.tsx @@ -92,7 +92,7 @@ export const ChannelInfoPage = () => {
{users.map(user => (
- +
))}
diff --git a/frontend/src/pages/HostView.tsx b/frontend/src/pages/HostView.tsx index 0423c540..8382b292 100644 --- a/frontend/src/pages/HostView.tsx +++ b/frontend/src/pages/HostView.tsx @@ -48,6 +48,7 @@ export const HostView = () => { const token = uuidv4(); saveLocalData(AppConfig.KEYS.BROWSER_TOKEN, token); } + const token = loadLocalData(AppConfig.KEYS.BROWSER_TOKEN); const url = `${AppConfig.SOCKET_SERVER}/?token=${token}&channelId=${location.pathname.split('/')[2]}&role=host`; diff --git a/frontend/src/pages/Main.tsx b/frontend/src/pages/Main.tsx index d5764146..1e599d7c 100644 --- a/frontend/src/pages/Main.tsx +++ b/frontend/src/pages/Main.tsx @@ -1,11 +1,11 @@ -import { Fragment, useContext, useEffect, useState } from 'react'; +import { Fragment, useContext, useEffect, useRef, useState, ReactNode } from 'react'; import { MdLogout } from 'react-icons/md'; import { FooterContext } from '@/component/layout/footer/LayoutFooterProvider'; import { useNavigate } from 'react-router-dom'; import { buttonActiveType } from '@/component/layout/enumTypes'; import { loadLocalData, saveLocalData, removeLocalData } from '@/utils/common/manageLocalData.ts'; import { AuthModal } from '@/component/authmodal/AuthModal'; -import { getUserChannels } from '@/api/channel.api.ts'; +import { deleteChannel, getUserChannels } from '@/api/channel.api.ts'; import { BottomSheet } from '@/component/bottomsheet/BottomSheet.tsx'; import { Content } from '@/component/content/Content.tsx'; import { AppConfig } from '@/lib/constants/commonConstants.ts'; @@ -15,6 +15,7 @@ import { MapCanvasForView } from '@/component/canvasWithMap/canvasWithMapForView import { LoadingSpinner } from '@/component/common/loadingSpinner/LoadingSpinner.tsx'; import { UserContext } from '@/context/UserContext'; import { ToggleProvider } from '@/context/DropdownContext.tsx'; +import { Confirm } from '@/component/confirm/Confirm.tsx'; export const Main = () => { const { @@ -31,10 +32,59 @@ export const Main = () => { const [isLoggedIn, setIsLoggedIn] = useState(false); const [showLoginModal, setShowLoginModal] = useState(false); const [channels, setChannels] = useState([]); + const [modalState, setModalState] = useState<'none' | 'confirm' | 'alert'>('none'); + const [modal, setModal] = useState(false); + + const deleteTargetRef = useRef(''); const { resetUsers } = useContext(UserContext); + const handleDeleteChannel = (channelId: string) => { + setModalState('confirm'); + deleteTargetRef.current = channelId; + // setIsDeleted(prev => !prev); + }; + + const handleDeleteModalCancel = () => { + setModalState('none'); + }; + + const handleDeleteModalConfirm = async () => { + try { + await deleteChannel(deleteTargetRef.current); + setModalState('alert'); + console.log(modalState); + } catch (err) { + console.error('Failed to delete channel info:', err); + } + }; + useEffect(() => { + if (modalState === 'confirm') { + setModal( + , + ); + return; + } + if (modalState === 'alert') { + setModal( + { + setModalState('none'); + }} + onCancel={() => {}} + type="alert" + />, + ); + return; + } + const token = loadLocalData(AppConfig.KEYS.LOGIN_TOKEN); setIsLoggedIn(!!token); @@ -52,7 +102,7 @@ export const Main = () => { }); } } - }, []); + }, [modalState]); const navigate = useNavigate(); @@ -80,8 +130,16 @@ export const Main = () => { ? prev.map(loc => (loc.token === data.token ? data : loc)) : [...prev, data], ); + } else if (data.type === 'channel') { + setChannels(prevChannels => { + if (prevChannels.some(channel => channel.id === data.channel.id)) { + return prevChannels; + } + return [...prevChannels, data.channel]; + }); } }; + return () => ws.close(); } return undefined; @@ -165,6 +223,7 @@ export const Main = () => { link={`/channel/${item.id}/host`} person={item.guest_count} time={item.generated_at} + onDelete={handleDeleteChannel} />
@@ -183,6 +242,8 @@ export const Main = () => { )} + {modalState !== 'none' && modal} + {/* 로그인 모달 */}