diff --git a/frontend/src/component/canvasWithMap/canvasWithMapForView/MapCanvasForView.tsx b/frontend/src/component/canvasWithMap/canvasWithMapForView/MapCanvasForView.tsx index 77d1bfbb..fd665a52 100644 --- a/frontend/src/component/canvasWithMap/canvasWithMapForView/MapCanvasForView.tsx +++ b/frontend/src/component/canvasWithMap/canvasWithMapForView/MapCanvasForView.tsx @@ -7,6 +7,7 @@ import { ZoomSlider } from '@/component/zoomslider/ZoomSlider'; export const MapCanvasForView = ({ lat, lng, + alpha, otherLocations, guests, width, @@ -70,6 +71,7 @@ export const MapCanvasForView = ({ guests, lat, lng, + alpha, }); const { @@ -101,7 +103,7 @@ export const MapCanvasForView = ({ useEffect(() => { redrawCanvas(); - }, [guests, otherLocations, lat, lng, map]); + }, [guests, otherLocations, lat, lng, alpha, map]); return (
{ const [location, setLocation] = useState({ lat: null, lng: null, + alpha: null, error: null, }); useEffect(() => { - if (navigator.geolocation) { - const watchId = navigator.geolocation.watchPosition( - position => { - setLocation({ - lat: position.coords.latitude, - lng: position.coords.longitude, - error: null, - }); - }, - error => { - setLocation({ - lat: 37.3595704, - lng: 127.105399, - error: error.message, - }); - }, - { enableHighAccuracy: true, maximumAge: 5000, timeout: 10000 }, - ); + let watchId: number; + + const handlePosition = (position: GeolocationPosition) => { + setLocation(prev => ({ + ...prev, + lat: position.coords.latitude, + lng: position.coords.longitude, + error: null, + })); + }; - return () => navigator.geolocation.clearWatch(watchId); + const handleError = (error: GeolocationPositionError) => { + setLocation({ + lat: 37.3595704, + lng: 127.105399, + alpha: 0, + error: error.message, + }); + }; + + if (navigator.geolocation) { + watchId = navigator.geolocation.watchPosition(handlePosition, handleError, { + enableHighAccuracy: true, + maximumAge: 5000, + timeout: 10000, + }); + } else { + setLocation({ + lat: 37.3595704, + lng: 127.105399, + alpha: 0, + error: '현재 위치를 불러오지 못했습니다', + }); } - setLocation({ - lat: 37.3595704, - lng: 127.105399, - error: '현재 위치를 불러오지 못했습니다', - }); + + const handleOrientation = (event: DeviceOrientationEvent) => { + if (event.alpha !== null) { + setLocation(prev => ({ ...prev, alpha: event.alpha })); + } + }; + + window.addEventListener('deviceorientation', handleOrientation); + + return () => { + if (watchId) navigator.geolocation.clearWatch(watchId); + window.removeEventListener('deviceorientation', handleOrientation); + }; }, []); return location; diff --git a/frontend/src/hooks/useRedraw.ts b/frontend/src/hooks/useRedraw.ts index 908bb6b5..18f95e13 100644 --- a/frontend/src/hooks/useRedraw.ts +++ b/frontend/src/hooks/useRedraw.ts @@ -3,23 +3,32 @@ import { LINE_WIDTH, STROKE_STYLE } from '@/lib/constants/canvasConstants.ts'; import startmarker from '@/assets/startmarker.svg'; import endmarker from '@/assets/endmarker.svg'; +import mylocation from '@/assets/mylocation.svg'; import character1 from '@/assets/character1.png'; import character2 from '@/assets/character2.png'; +import { IMarkerStyle } from '@/lib/types/canvasInterface.ts'; interface ILatLng { lat: number; lng: number; } +interface ILatLngAlpha { + lat: number; + lng: number; + alpha: number; +} + interface IOtherLocation { - location: ILatLng; - token: string; + location: ILatLngAlpha; + color: string; } interface IGuest { startPoint: ILatLng; endPoint: ILatLng; paths: ILatLng[]; + markerStyle: IMarkerStyle; } interface IUseRedrawCanvasProps { @@ -35,6 +44,7 @@ interface IUseRedrawCanvasProps { guests?: IGuest[] | null; lat?: number; lng?: number; + alpha?: number | null; } export const useRedrawCanvas = ({ @@ -48,9 +58,11 @@ export const useRedrawCanvas = ({ guests = [], lat, lng, + alpha = 0, }: IUseRedrawCanvasProps) => { const startImageRef = useRef(null); const endImageRef = useRef(null); + const mylocationRef = useRef(null); const character1Ref = useRef(null); const character2Ref = useRef(null); @@ -61,6 +73,9 @@ export const useRedrawCanvas = ({ endImageRef.current = new Image(); endImageRef.current.src = endmarker; + mylocationRef.current = new Image(); + mylocationRef.current.src = mylocation; + character1Ref.current = new Image(); character1Ref.current.src = character1; @@ -72,11 +87,72 @@ export const useRedrawCanvas = ({ ctx: CanvasRenderingContext2D, point: { x: number; y: number } | null, image: HTMLImageElement | null, + zoom: number, + rotate: number, ) => { if (point && image) { - const markerSize = 32; - ctx.drawImage(image, point.x - markerSize / 2, point.y - markerSize, markerSize, markerSize); + const markerSize = zoom * 5; + ctx.save(); + ctx.translate(point.x, point.y); + ctx.rotate(rotate); + ctx.drawImage(image, -markerSize / 2, -markerSize / 2, markerSize, markerSize); + ctx.restore(); + } + }; + + // eslint-disable-next-line no-shadow + const hexToRgba = (hex: string, alpha: number) => { + // eslint-disable-next-line no-param-reassign + hex = hex.replace(/^#/, ''); + + if (hex.length === 3) { + // eslint-disable-next-line no-param-reassign + hex = hex + .split('') + .map(char => char + char) + .join(''); } + + const bigint = parseInt(hex, 16); + const r = (bigint >> 16) & 255; + const g = (bigint >> 8) & 255; + const b = bigint & 255; + + return `rgba(${r}, ${g}, ${b}, ${alpha})`; + }; + + const drawNeonCircleAndDirection = ( + ctx: CanvasRenderingContext2D, + point: { x: number; y: number } | null, + zoom: number, + color: string, + ) => { + if (!point) return; + + const radius = zoom * 3; + const gradient = ctx.createRadialGradient( + point.x, + point.y + zoom, + 0, + point.x, + point.y + zoom, + radius, + ); + + const alphaStart = 0.75; + const alphaEnd = 0; + + gradient.addColorStop(0, hexToRgba(color || '#3498db', alphaStart)); + gradient.addColorStop(1, hexToRgba(color || '#3498db', alphaEnd)); + + ctx.beginPath(); + ctx.arc(point.x, point.y + zoom + 1, radius, 0, 2 * Math.PI); + ctx.fillStyle = gradient; + ctx.fill(); + + ctx.save(); + + ctx.restore(); }; const drawPath = (ctx: CanvasRenderingContext2D, points: ILatLng[]) => { @@ -96,20 +172,6 @@ export const useRedrawCanvas = ({ } }; - // const getMarkerColor = (token: string): 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 redrawCanvas = () => { if (!canvasRef.current || !map) return; @@ -123,14 +185,15 @@ export const useRedrawCanvas = ({ ctx.lineCap = 'round'; ctx.lineJoin = 'round'; + const zoom = map.getZoom(); if (startMarker) { const startPoint = latLngToCanvasPoint(startMarker); - drawMarker(ctx, startPoint, startImageRef.current); + drawMarker(ctx, startPoint, startImageRef.current, zoom, 0); } if (endMarker) { const endPoint = latLngToCanvasPoint(endMarker); - drawMarker(ctx, endPoint, endImageRef.current); + drawMarker(ctx, endPoint, endImageRef.current, zoom, 0); } if (pathPoints) { @@ -139,24 +202,38 @@ export const useRedrawCanvas = ({ if (lat && lng) { const currentLocation = latLngToCanvasPoint({ lat, lng }); - drawMarker(ctx, currentLocation, character1Ref.current); + if (alpha) { + drawMarker(ctx, currentLocation, character1Ref.current, zoom, (alpha * Math.PI) / 180); + } else { + drawMarker(ctx, currentLocation, character1Ref.current, zoom, 0); + } } if (otherLocations) { - otherLocations.forEach(({ location }) => { - // const markerColor = getMarkerColor(token); - const locationPoint = latLngToCanvasPoint(location); - drawMarker(ctx, locationPoint, character2Ref.current); + otherLocations.forEach(({ location, color }) => { + const locationPoint = latLngToCanvasPoint({ + lat: location.lat ? location.lat : 0, + lng: location.lng ? location.lng : 0, + }); + + drawNeonCircleAndDirection(ctx, locationPoint, zoom, color); + drawMarker( + ctx, + locationPoint, + character2Ref.current, + zoom, + (location.alpha * Math.PI) / 180, + ); }); } if (guests) { guests.forEach(({ startPoint, endPoint, paths }) => { const startLocation = latLngToCanvasPoint(startPoint); - drawMarker(ctx, startLocation, startImageRef.current); + drawMarker(ctx, startLocation, startImageRef.current, zoom, 0); const endLocation = latLngToCanvasPoint(endPoint); - drawMarker(ctx, endLocation, endImageRef.current); + drawMarker(ctx, endLocation, endImageRef.current, zoom, 0); drawPath(ctx, paths); }); diff --git a/frontend/src/hooks/useSocket.ts b/frontend/src/hooks/useSocket.ts new file mode 100644 index 00000000..8857d345 --- /dev/null +++ b/frontend/src/hooks/useSocket.ts @@ -0,0 +1,33 @@ +import { useState, useEffect } from 'react'; + +export const useSocket = (url: string) => { + const [ws, setWs] = useState(null); + + useEffect(() => { + const socket = new WebSocket(url); + setWs(socket); + + socket.onopen = () => { + console.log('Socket connected'); + }; + + socket.onmessage = event => { + const data = JSON.parse(event.data); + console.log(data); + }; + + socket.onclose = () => { + console.log('Socket closed'); + }; + + socket.onerror = err => { + console.error('Socket error:', err); + }; + + return () => { + socket.close(); + }; + }, [url]); + + return ws; +}; diff --git a/frontend/src/lib/types/canvasInterface.ts b/frontend/src/lib/types/canvasInterface.ts index 15445017..f71be6d8 100644 --- a/frontend/src/lib/types/canvasInterface.ts +++ b/frontend/src/lib/types/canvasInterface.ts @@ -10,6 +10,12 @@ export interface IPoint { lng: number; } +export interface IPointWithAlpha { + lat: number; + lng: number; + alpha: number; +} + export interface ICanvasPoint { x: number; y: number; @@ -21,8 +27,9 @@ export interface ICanvasScreenProps { } export interface IOtherLiveLocations { - location: IPoint; + location: IPointWithAlpha; token: string; + color: string; } export interface IMarkerStyle { @@ -41,6 +48,7 @@ export interface IGuestDataInMapProps { export interface IMapCanvasViewProps { lat: number; lng: number; + alpha?: number | null; otherLocations?: IOtherLiveLocations[] | null; guests?: IGuestDataInMapProps[] | null; width: string; diff --git a/frontend/src/pages/GuestView.tsx b/frontend/src/pages/GuestView.tsx index 1d47b191..03638a4c 100644 --- a/frontend/src/pages/GuestView.tsx +++ b/frontend/src/pages/GuestView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; import { IGuest } from '@/types/channel.types.ts'; import { getGuestInfo } from '@/api/channel.api.ts'; import { useLocation } from 'react-router-dom'; @@ -8,9 +8,14 @@ import { guestEntity } from '@/api/dto/channel.dto.ts'; import { GusetMarker } from '@/component/IconGuide/GuestMarker.tsx'; import { LoadingSpinner } from '@/component/common/loadingSpinner/LoadingSpinner.tsx'; import { getUserLocation } from '@/hooks/getUserLocation.ts'; +import { loadLocalData, saveLocalData } from '@/utils/common/manageLocalData.ts'; +import { AppConfig } from '@/lib/constants/commonConstants.ts'; +import { v4 as uuidv4 } from 'uuid'; export const GuestView = () => { - const { lat, lng, error } = getUserLocation(); + const { lat, lng, alpha, error } = getUserLocation(); + const location = useLocation(); + const [guestInfo, setGuestInfo] = useState({ id: '', name: '', @@ -20,7 +25,35 @@ export const GuestView = () => { paths: [], }); - const location = useLocation(); + const wsRef = useRef(null); + + useEffect(() => { + // 소켓 연결 초기화 + const token = loadLocalData(AppConfig.KEYS.BROWSER_TOKEN) || uuidv4(); + saveLocalData(AppConfig.KEYS.BROWSER_TOKEN, token); + + const ws = new WebSocket( + `${AppConfig.SOCKET_SERVER}/?token=${token}&channelId=${location.pathname.split('/')[2]}&role=guest&guestId=${location.pathname.split('/')[4]}`, + ); + + ws.onopen = () => { + console.log('WebSocket connection established'); + }; + + wsRef.current = ws; + }, [location]); + + useEffect(() => { + // 위치 정보가 변경될 때마다 전송 + if (lat && lng && wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send( + JSON.stringify({ + type: 'location', + location: { lat, lng, alpha }, + }), + ); + } + }, [lat, lng, alpha]); const transformTypeGuestEntityToIGuest = (props: guestEntity | undefined): IGuest => { return { @@ -64,7 +97,14 @@ export const GuestView = () => { {/* eslint-disable-next-line no-nested-ternary */} {lat && lng ? ( guestInfo ? ( - + ) : ( ) diff --git a/frontend/src/pages/HostView.tsx b/frontend/src/pages/HostView.tsx index 9c5f5460..481e32b3 100644 --- a/frontend/src/pages/HostView.tsx +++ b/frontend/src/pages/HostView.tsx @@ -4,22 +4,83 @@ import { IGuest, IChannelInfo, IGuestData } from '@/types/channel.types.ts'; import { getChannelInfo } from '@/api/channel.api.ts'; import { useLocation } from 'react-router-dom'; import { MapCanvasForView } from '@/component/canvasWithMap/canvasWithMapForView/MapCanvasForView.tsx'; -import { IGuestDataInMapProps, IPoint } from '@/lib/types/canvasInterface.ts'; +import { IGuestDataInMapProps, IPoint, IPointWithAlpha } from '@/lib/types/canvasInterface.ts'; import { getChannelResEntity, guestEntity } from '@/api/dto/channel.dto.ts'; import { HostMarker } from '@/component/IconGuide/HostMarker.tsx'; import { LoadingSpinner } from '@/component/common/loadingSpinner/LoadingSpinner.tsx'; import { getUserLocation } from '@/hooks/getUserLocation.ts'; - +import { loadLocalData, saveLocalData } from '@/utils/common/manageLocalData.ts'; +import { AppConfig } from '@/lib/constants/commonConstants.ts'; +import { v4 as uuidv4 } from 'uuid'; +import { useSocket } from '@/hooks/useSocket.ts'; + +interface IOtherLocationsInHostView { + guestId: string; + location: IPointWithAlpha; + token: string; + color: string; +} export const HostView = () => { - const { lat, lng, error } = getUserLocation(); + const { lat, lng, alpha, error } = getUserLocation(); + const location = useLocation(); + const [channelInfo, setChannelInfo] = useState(); const [guestsData, setGuestsData] = useState([]); const [mapProps, setMapProps] = useState([]); const [clickedId, setClickedId] = useState(''); + const [otherLocations, setOtherLocations] = useState([]); const headerDropdownContext = useContext(HeaderDropdownContext); + const markerDefaultColor = ['#B4D033', '#22A751', '#2722A7', '#8F22A7', '#A73D22']; - const location = useLocation(); + if (!loadLocalData(AppConfig.KEYS.BROWSER_TOKEN)) { + 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`; + + const ws = useSocket(url); + + useEffect(() => { + if (ws) { + ws.onmessage = event => { + const data = JSON.parse(event.data); + console.log(data); + if (data.type === 'init') { + // 기존 클라이언트들의 위치 초기화 + const updatedLocations = data.clients.map((client: any, index: number) => { + const matchingGuest = channelInfo?.guests?.find(guest => guest.id === client.guestId); + return { + ...client, + color: matchingGuest?.markerStyle.color ?? markerDefaultColor[index], + }; + }); + setOtherLocations(updatedLocations); + } else if (data.type === 'location') { + // 새로 들어온 위치 업데이트 + const matchingGuest = guestsData?.find(guest => guest.id === data.guestId); + const updatedLocation = { + guestId: data.guestId, + location: data.location, + token: data.token, + color: matchingGuest?.markerStyle.color ?? '#ffffff', + }; + + setOtherLocations(prev => { + const index = prev.findIndex(el => el.guestId === data.guestId); + + if (index !== -1) { + const updatedLocations = [...prev]; + updatedLocations[index] = updatedLocation; + return updatedLocations; + } + return [...prev, updatedLocation]; + }); + } + }; + } + }, [ws, guestsData]); const transformTypeGuestEntityToIGuest = (props: guestEntity): IGuest => { return { @@ -74,8 +135,6 @@ export const HostView = () => { }, []); useEffect(() => { - const markerDefaultColor = ['#B4D033', '#22A751', '#2722A7', '#8F22A7', '#A73D22']; - if (channelInfo?.guests) { const data: IGuestData[] = channelInfo.guests.map((guest, index) => ({ name: guest.name, @@ -107,8 +166,21 @@ export const HostView = () => { {/* eslint-disable-next-line no-nested-ternary */} {lat && lng ? ( + // eslint-disable-next-line no-nested-ternary mapProps ? ( - + otherLocations ? ( + + ) : ( + + ) ) : ( ) diff --git a/frontend/src/pages/Main.tsx b/frontend/src/pages/Main.tsx index 5ccbb926..0562fa9d 100644 --- a/frontend/src/pages/Main.tsx +++ b/frontend/src/pages/Main.tsx @@ -23,7 +23,7 @@ export const Main = () => { setFooterActive, resetFooterContext, } = useContext(FooterContext); - const { lat, lng, error } = getUserLocation(); + const { lat, lng, alpha, error } = getUserLocation(); const [otherLocations, setOtherLocations] = useState([]); const MIN_HEIGHT = 0.15; const MAX_HEIGHT = 0.9; @@ -65,7 +65,7 @@ export const Main = () => { const ws = new WebSocket(`${AppConfig.SOCKET_SERVER}/?token=${token}`); // 초기 위치 전송 ws.onopen = () => { - ws.send(JSON.stringify({ type: 'location', location: { lat, lng } })); + ws.send(JSON.stringify({ type: 'location', location: { lat, lng, alpha } })); }; ws.onmessage = event => { const data = JSON.parse(event.data); @@ -84,7 +84,8 @@ export const Main = () => { return () => ws.close(); } return undefined; - }, [lat, lng]); + }, [lat, lng, alpha]); + const goToAddChannel = () => { resetFooterContext(); resetUsers(); @@ -138,6 +139,7 @@ export const Main = () => { height="100%" lat={lat} lng={lng} + alpha={alpha} otherLocations={otherLocations} /> ) : (