From 06c5aa7865107f87d9916419d19999c551bcb8d5 Mon Sep 17 00:00:00 2001 From: sooloin Date: Mon, 15 Jun 2026 15:29:36 +0900 Subject: [PATCH 01/12] =?UTF-8?q?:sparkles:=20Feat:=20=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EB=B2=84=20=EC=A7=80=EB=8F=84(NCP=20Maps)=20=EC=97=B0=EB=8F=99?= =?UTF-8?q?=20=EB=B0=8F=20=EC=82=B0=EC=B1=85=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NCP Maps 스크립트 로더 추가 (중복 로드 방지) - 네이버 지도 전역 타입 최소 선언 - WalkMap 컴포넌트로 지도 렌더링 및 언마운트 시 정리 - env에 VITE_NAVER_MAP_CLIENT_ID 추가 `#62` --- src/features/walk-fence/index.ts | 1 + src/features/walk-fence/ui/WalkMap.tsx | 52 ++++++++++++++++++++++++ src/pages/walk/WalkPage.tsx | 8 +++- src/shared/config/env.ts | 3 ++ src/shared/lib/naver-map/loadNaverMap.ts | 26 ++++++++++++ src/shared/lib/naver-map/naver.d.ts | 23 +++++++++++ 6 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 src/features/walk-fence/index.ts create mode 100644 src/features/walk-fence/ui/WalkMap.tsx create mode 100644 src/shared/lib/naver-map/loadNaverMap.ts create mode 100644 src/shared/lib/naver-map/naver.d.ts diff --git a/src/features/walk-fence/index.ts b/src/features/walk-fence/index.ts new file mode 100644 index 0000000..d1a0fb7 --- /dev/null +++ b/src/features/walk-fence/index.ts @@ -0,0 +1 @@ +export { WalkMap } from './ui/WalkMap'; diff --git a/src/features/walk-fence/ui/WalkMap.tsx b/src/features/walk-fence/ui/WalkMap.tsx new file mode 100644 index 0000000..30c1e52 --- /dev/null +++ b/src/features/walk-fence/ui/WalkMap.tsx @@ -0,0 +1,52 @@ +import { useEffect, useRef, useState } from 'react'; + +import { loadNaverMap } from '@/shared/lib/naver-map/loadNaverMap'; + +// 지도 기본 중심 좌표 (서울시청) — 나중에 펫 위치로 교체 +const DEFAULT_CENTER = { lat: 37.5666103, lng: 126.9783882 }; + +export function WalkMap() { + // 지도를 그릴 빈 div 참조 + const mapElementRef = useRef(null); + const [error, setError] = useState(null); + + useEffect(() => { + // useEffect 내에서 loadNaverMap() 호출 → new naver.maps.Map() 생성 + let map: naver.maps.Map | null = null; + let canceled = false; + + loadNaverMap() + .then(() => { + // 스크립트 로드 사이에 언마운트됐거나 div가 없으면 중단 + if (canceled || !mapElementRef.current) return; + + map = new naver.maps.Map(mapElementRef.current, { + center: new naver.maps.LatLng(DEFAULT_CENTER.lat, DEFAULT_CENTER.lng), + zoom: 15, + }); + }) + .catch((e: unknown) => { + if (!canceled) { + setError(e instanceof Error ? e.message : '지도를 불러오지 못했습니다.'); + } + }); + + // 언마운트 시 정리 + return () => { + // canceled 플래그 + map.destroy()로 페이지 나갈 때 깔끔하게 정리 + canceled = true; + map?.destroy(); + }; + }, []); + + if (error) { + return ( +
+ {error} +
+ ); + } + + // 지도는 부모에 높이가 있어야 보여요 → 여기서 높이 지정 + return
; +} diff --git a/src/pages/walk/WalkPage.tsx b/src/pages/walk/WalkPage.tsx index 60e0061..c788e1c 100644 --- a/src/pages/walk/WalkPage.tsx +++ b/src/pages/walk/WalkPage.tsx @@ -1,3 +1,9 @@ +import { WalkMap } from '@/features/walk-fence'; + export function WalkPage() { - return
산책 (뼈대)
; + return ( +
+ +
+ ); } diff --git a/src/shared/config/env.ts b/src/shared/config/env.ts index 33dac8a..41cc547 100644 --- a/src/shared/config/env.ts +++ b/src/shared/config/env.ts @@ -2,6 +2,7 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID; const NAVER_CLIENT_ID = import.meta.env.VITE_NAVER_CLIENT_ID; const OAUTH_REDIRECT_URI = import.meta.env.VITE_OAUTH_REDIRECT_URI; +const NAVER_MAP_CLIENT_ID = import.meta.env.VITE_NAVER_MAP_CLIENT_ID; // 환경 변수 누락 방지 const requiredEnv = { @@ -9,6 +10,7 @@ const requiredEnv = { VITE_GOOGLE_CLIENT_ID: GOOGLE_CLIENT_ID, VITE_NAVER_CLIENT_ID: NAVER_CLIENT_ID, VITE_OAUTH_REDIRECT_URI: OAUTH_REDIRECT_URI, + VITE_NAVER_MAP_CLIENT_ID: NAVER_MAP_CLIENT_ID, }; Object.entries(requiredEnv).forEach(([key, value]) => { @@ -22,4 +24,5 @@ export const env = { GOOGLE_CLIENT_ID, NAVER_CLIENT_ID, OAUTH_REDIRECT_URI, + NAVER_MAP_CLIENT_ID, }; diff --git a/src/shared/lib/naver-map/loadNaverMap.ts b/src/shared/lib/naver-map/loadNaverMap.ts new file mode 100644 index 0000000..04fee39 --- /dev/null +++ b/src/shared/lib/naver-map/loadNaverMap.ts @@ -0,0 +1,26 @@ +import { env } from '@/shared/config'; + +let loadPromise: Promise | null = null; + +/** 네이버 지도 스크립트를 한 번만 로드하고, 완료 시 resolve */ +export function loadNaverMap(): Promise { + // 이미 로드 완료된 경우 + if (window.naver?.maps) return Promise.resolve(); + + // 로딩 중이면 같은 Promise 재사용 (중복 로드 방지) + if (loadPromise) return loadPromise; + + loadPromise = new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = `https://oapi.map.naver.com/openapi/v3/maps.js?ncpKeyId=${env.NAVER_MAP_CLIENT_ID}`; + script.async = true; + script.onload = () => resolve(); + script.onerror = () => { + loadPromise = null; // 실패 시 다음에 재시도 가능하도록 초기화 + reject(new Error('네이버 지도 스크립트를 불러오지 못했습니다.')); + }; + document.head.appendChild(script); + }); + + return loadPromise; +} diff --git a/src/shared/lib/naver-map/naver.d.ts b/src/shared/lib/naver-map/naver.d.ts new file mode 100644 index 0000000..192c230 --- /dev/null +++ b/src/shared/lib/naver-map/naver.d.ts @@ -0,0 +1,23 @@ +// 네이버 지도(NCP Maps) 전역 타입 — 우선 지도 표시에 필요한 최소만 선언 +declare namespace naver.maps { + class LatLng { + constructor(lat: number, lng: number); + } + + interface MapOptions { + center: LatLng; + zoom?: number; + } + + class Map { + constructor(element: string | HTMLElement, options: MapOptions); + setCenter(latlng: LatLng): void; + setZoom(zoom: number): void; + destroy(): void; + } +} + +// window.naver 로 접근할 수 있게 augment +interface Window { + naver: typeof naver; +} From e8cb2ba7e6706c3c239c591552e1e640005ef81f Mon Sep 17 00:00:00 2001 From: sooloin Date: Mon, 15 Jun 2026 15:59:56 +0900 Subject: [PATCH 02/12] =?UTF-8?q?:sparkles:=20Feat:=20=EC=9A=B8=ED=83=80?= =?UTF-8?q?=EB=A6=AC(=EC=A7=80=EC=98=A4=ED=8E=9C=EC=8A=A4)=20REST=20API=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fence REST API 타입 및 axios 함수 6종 추가 (생성/상태조회/토글/범위수정/경계조회/경계목록) - 조회·뮤테이션 react-query 훅 추가 (useFenceBoundaries/useFenceStatus/useCreateFence /useToggleFence/useUpdateFenceRange) - queryKey에 fence 키 추가 `#62` --- src/features/walk-fence/api/fence.ts | 50 ++++++++++++++ src/features/walk-fence/index.ts | 24 +++++++ src/features/walk-fence/model/types.ts | 67 +++++++++++++++++++ .../walk-fence/model/useCreateFence.ts | 16 +++++ .../walk-fence/model/useFenceBoundaries.ts | 12 ++++ .../walk-fence/model/useFenceStatus.ts | 13 ++++ .../walk-fence/model/useToggleFence.ts | 22 ++++++ .../walk-fence/model/useUpdateFenceRange.ts | 23 +++++++ src/shared/lib/react-query/queryKey.ts | 5 ++ 9 files changed, 232 insertions(+) create mode 100644 src/features/walk-fence/api/fence.ts create mode 100644 src/features/walk-fence/model/types.ts create mode 100644 src/features/walk-fence/model/useCreateFence.ts create mode 100644 src/features/walk-fence/model/useFenceBoundaries.ts create mode 100644 src/features/walk-fence/model/useFenceStatus.ts create mode 100644 src/features/walk-fence/model/useToggleFence.ts create mode 100644 src/features/walk-fence/model/useUpdateFenceRange.ts diff --git a/src/features/walk-fence/api/fence.ts b/src/features/walk-fence/api/fence.ts new file mode 100644 index 0000000..0344d35 --- /dev/null +++ b/src/features/walk-fence/api/fence.ts @@ -0,0 +1,50 @@ +import { apiClient } from '@/shared/api/axios'; + +import type { + CreateFenceRequest, + FenceBoundariesResponse, + FenceBoundaryResponse, + FenceMessageResponse, + FenceStatusResponse, + ToggleFenceRequest, + UpdateFenceRangeRequest, +} from '../model/types'; + +/** 1. 울타리 생성 */ +export async function createFence(payload: CreateFenceRequest): Promise { + const response = await apiClient.post('/fence/range', payload); + return response.data; +} + +/** 2. 특정 반려동물의 울타리 활성화 상태 조회 */ +export async function getFenceStatus(petId: number): Promise { + const response = await apiClient.get(`/fence/${petId}/status`); + return response.data; +} + +/** 3. 울타리 ON/OFF 변경 */ +export async function toggleFence(fenceId: number, payload: ToggleFenceRequest): Promise { + const response = await apiClient.patch(`/fence/${fenceId}/toggle`, payload); + return response.data; +} + +/** 4. 울타리 이름/중심/반경 수정 */ +export async function updateFenceRange( + fenceId: number, + payload: UpdateFenceRangeRequest, +): Promise { + const response = await apiClient.patch(`/fence/${fenceId}/range`, payload); + return response.data; +} + +/** 5. 지도에 표시할 단일 울타리 경계 조회 */ +export async function getFenceBoundary(fenceId: number): Promise { + const response = await apiClient.get(`/fence/${fenceId}/boundary`); + return response.data; +} + +/** 6. 접근 가능한 모든 울타리 경계 목록 조회 */ +export async function getFenceBoundaries(): Promise { + const response = await apiClient.get('/fence/boundaries'); + return response.data; +} diff --git a/src/features/walk-fence/index.ts b/src/features/walk-fence/index.ts index d1a0fb7..316914c 100644 --- a/src/features/walk-fence/index.ts +++ b/src/features/walk-fence/index.ts @@ -1 +1,25 @@ +export { + createFence, + getFenceBoundaries, + getFenceBoundary, + getFenceStatus, + toggleFence, + updateFenceRange, +} from './api/fence'; +export { useCreateFence } from './model/useCreateFence'; +export { useFenceBoundaries } from './model/useFenceBoundaries'; +export { useFenceStatus } from './model/useFenceStatus'; +export { useToggleFence } from './model/useToggleFence'; +export { useUpdateFenceRange } from './model/useUpdateFenceRange'; export { WalkMap } from './ui/WalkMap'; +export type { + CreateFenceRequest, + FenceBoundariesResponse, + FenceBoundary, + FenceBoundaryResponse, + FenceCenter, + FenceMessageResponse, + FenceStatusResponse, + ToggleFenceRequest, + UpdateFenceRangeRequest, +} from './model/types'; diff --git a/src/features/walk-fence/model/types.ts b/src/features/walk-fence/model/types.ts new file mode 100644 index 0000000..e0c3807 --- /dev/null +++ b/src/features/walk-fence/model/types.ts @@ -0,0 +1,67 @@ +// 울타리(지오펜스) REST API 요청/응답 타입 + +/** 좌표 (위도/경도) */ +export interface FenceCenter { + latitude: number; + longitude: number; +} + +/** 공통 메시지 응답 */ +export interface FenceMessageResponse { + message: string; +} + +/** 1. 울타리 생성 — POST /fence/range */ +export interface CreateFenceRequest { + petId: number; + centerLatitude: number; + centerLongitude: number; + /** 반경(미터) */ + radius: number; + fenceName: string; +} + +/** 2. 울타리 상태 조회 — GET /fence/{petId}/status */ +export interface FenceStatusResponse { + message: string; + isActive: boolean; +} + +/** 3. 울타리 ON/OFF — PATCH /fence/{fenceId}/toggle */ +export interface ToggleFenceRequest { + fenceIsActive: boolean; +} + +/** 4. 울타리 범위 수정 — PATCH /fence/{fenceId}/range (모든 필드 선택) */ +export interface UpdateFenceRangeRequest { + centerLatitude?: number; + centerLongitude?: number; + fenceName?: string; + radius?: number; +} + +/** 5. 울타리 경계 조회 — GET /fence/{fenceId}/boundary */ +export interface FenceBoundaryResponse { + message: string; + center: FenceCenter; + radius: number; + fenceId: number; +} + +/** 6-1. 울타리 경계 목록의 단일 항목 */ +export interface FenceBoundary { + fenceId: number; + fenceName: string; + center: FenceCenter; + radius: number; + isActive: boolean; + petId: number; + petName: string; + petImageUrl: string; +} + +/** 6. 울타리 경계 목록 조회 — GET /fence/boundaries */ +export interface FenceBoundariesResponse { + message: string; + boundaries: FenceBoundary[]; +} diff --git a/src/features/walk-fence/model/useCreateFence.ts b/src/features/walk-fence/model/useCreateFence.ts new file mode 100644 index 0000000..5940b09 --- /dev/null +++ b/src/features/walk-fence/model/useCreateFence.ts @@ -0,0 +1,16 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { createFence } from '../api/fence'; +import { queryKeys } from '@/shared/lib/react-query/queryKey'; + +/** 울타리 생성 후 경계 목록 갱신 */ +export function useCreateFence() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: createFence, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.fence.boundaries() }); + }, + }); +} diff --git a/src/features/walk-fence/model/useFenceBoundaries.ts b/src/features/walk-fence/model/useFenceBoundaries.ts new file mode 100644 index 0000000..50a6d5d --- /dev/null +++ b/src/features/walk-fence/model/useFenceBoundaries.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getFenceBoundaries } from '../api/fence'; +import { queryKeys } from '@/shared/lib/react-query/queryKey'; + +/** 접근 가능한 모든 울타리 경계 목록 조회 */ +export function useFenceBoundaries() { + return useQuery({ + queryKey: queryKeys.fence.boundaries(), + queryFn: getFenceBoundaries, + }); +} diff --git a/src/features/walk-fence/model/useFenceStatus.ts b/src/features/walk-fence/model/useFenceStatus.ts new file mode 100644 index 0000000..b5d9eb8 --- /dev/null +++ b/src/features/walk-fence/model/useFenceStatus.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getFenceStatus } from '../api/fence'; +import { queryKeys } from '@/shared/lib/react-query/queryKey'; + +/** 특정 반려동물의 울타리 활성화 상태 조회 (petId 없으면 비활성) */ +export function useFenceStatus(petId: number | null) { + return useQuery({ + queryKey: queryKeys.fence.status(petId ?? 0), + queryFn: () => getFenceStatus(petId as number), + enabled: petId != null, + }); +} diff --git a/src/features/walk-fence/model/useToggleFence.ts b/src/features/walk-fence/model/useToggleFence.ts new file mode 100644 index 0000000..9066442 --- /dev/null +++ b/src/features/walk-fence/model/useToggleFence.ts @@ -0,0 +1,22 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { toggleFence } from '../api/fence'; +import type { ToggleFenceRequest } from './types'; +import { queryKeys } from '@/shared/lib/react-query/queryKey'; + +interface ToggleFenceVariables { + fenceId: number; + payload: ToggleFenceRequest; +} + +/** 울타리 ON/OFF 변경 후 경계 목록 갱신 */ +export function useToggleFence() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ fenceId, payload }: ToggleFenceVariables) => toggleFence(fenceId, payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.fence.boundaries() }); + }, + }); +} diff --git a/src/features/walk-fence/model/useUpdateFenceRange.ts b/src/features/walk-fence/model/useUpdateFenceRange.ts new file mode 100644 index 0000000..632aa5b --- /dev/null +++ b/src/features/walk-fence/model/useUpdateFenceRange.ts @@ -0,0 +1,23 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { updateFenceRange } from '../api/fence'; +import type { UpdateFenceRangeRequest } from './types'; +import { queryKeys } from '@/shared/lib/react-query/queryKey'; + +interface UpdateFenceRangeVariables { + fenceId: number; + payload: UpdateFenceRangeRequest; +} + +/** 울타리 이름/중심/반경 수정 후 관련 쿼리 갱신 */ +export function useUpdateFenceRange() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ fenceId, payload }: UpdateFenceRangeVariables) => updateFenceRange(fenceId, payload), + onSuccess: (_data, { fenceId }) => { + queryClient.invalidateQueries({ queryKey: queryKeys.fence.boundaries() }); + queryClient.invalidateQueries({ queryKey: queryKeys.fence.boundary(fenceId) }); + }, + }); +} diff --git a/src/shared/lib/react-query/queryKey.ts b/src/shared/lib/react-query/queryKey.ts index 1fb92b5..ae3d569 100644 --- a/src/shared/lib/react-query/queryKey.ts +++ b/src/shared/lib/react-query/queryKey.ts @@ -60,4 +60,9 @@ export const queryKeys = { params?.sort ?? 'petWeightsMeasuredAt,desc', ] as const, }, + fence: { + boundaries: () => ['fence', 'boundaries'] as const, + boundary: (fenceId: number) => ['fence', fenceId, 'boundary'] as const, + status: (petId: number) => ['fence', petId, 'status'] as const, + }, } as const; From b245e00fc89b2f8f3a6f7b6b3094b93345b44e1b Mon Sep 17 00:00:00 2001 From: sooloin Date: Mon, 15 Jun 2026 16:13:58 +0900 Subject: [PATCH 03/12] =?UTF-8?q?:sparkles:=20Feat:=20=EC=A7=80=EB=8F=84?= =?UTF-8?q?=EC=97=90=20=EC=9A=B8=ED=83=80=EB=A6=AC=20=EA=B2=BD=EA=B3=84=20?= =?UTF-8?q?=EC=9B=90=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 네이버 지도 전역 타입에 Circle 추가 - 울타리 경계 목록을 지도에 원(Circle)으로 렌더링 - 활성/비활성 상태에 따라 색상 구분, 첫 울타리로 중심 이동 `#62` --- src/features/walk-fence/ui/WalkMap.tsx | 61 ++++++++++++++++++++------ src/shared/lib/naver-map/naver.d.ts | 19 ++++++++ 2 files changed, 66 insertions(+), 14 deletions(-) diff --git a/src/features/walk-fence/ui/WalkMap.tsx b/src/features/walk-fence/ui/WalkMap.tsx index 30c1e52..70ef6c4 100644 --- a/src/features/walk-fence/ui/WalkMap.tsx +++ b/src/features/walk-fence/ui/WalkMap.tsx @@ -2,43 +2,77 @@ import { useEffect, useRef, useState } from 'react'; import { loadNaverMap } from '@/shared/lib/naver-map/loadNaverMap'; -// 지도 기본 중심 좌표 (서울시청) — 나중에 펫 위치로 교체 +import { useFenceBoundaries } from '../model/useFenceBoundaries'; + const DEFAULT_CENTER = { lat: 37.5666103, lng: 126.9783882 }; export function WalkMap() { - // 지도를 그릴 빈 div 참조 + // useRef로 지도 요소와 인스턴스, 그려둔 원들 보관 + // 리렌더돼도 같은 지도 객체를 유지하고 다시 그릴 때 이전 원을 지우기 const mapElementRef = useRef(null); + const mapInstanceRef = useRef(null); // 생성된 지도 보관 + const circlesRef = useRef([]); // 그려둔 원들 보관(나중에 지우려고) + const [isMapReady, setIsMapReady] = useState(false); const [error, setError] = useState(null); + const { data } = useFenceBoundaries(); + + // 1. 지도 생성 (최초 1회) useEffect(() => { - // useEffect 내에서 loadNaverMap() 호출 → new naver.maps.Map() 생성 - let map: naver.maps.Map | null = null; let canceled = false; loadNaverMap() .then(() => { - // 스크립트 로드 사이에 언마운트됐거나 div가 없으면 중단 if (canceled || !mapElementRef.current) return; - - map = new naver.maps.Map(mapElementRef.current, { + mapInstanceRef.current = new naver.maps.Map(mapElementRef.current, { center: new naver.maps.LatLng(DEFAULT_CENTER.lat, DEFAULT_CENTER.lng), zoom: 15, }); + setIsMapReady(true); }) .catch((e: unknown) => { - if (!canceled) { - setError(e instanceof Error ? e.message : '지도를 불러오지 못했습니다.'); - } + if (!canceled) setError(e instanceof Error ? e.message : '지도를 불러오지 못했습니다.'); }); - // 언마운트 시 정리 return () => { - // canceled 플래그 + map.destroy()로 페이지 나갈 때 깔끔하게 정리 canceled = true; - map?.destroy(); + mapInstanceRef.current?.destroy(); + mapInstanceRef.current = null; }; }, []); + // 2. 울타리 원 그리기 (지도 준비됨 + 데이터 들어옴/바뀜) + // 울타리 데이터는 나중에 도착하고 바뀔 수 있기 때문에 + // → [isMapReady, data] (데이터 올 때마다 다시 그림) + useEffect(() => { + const map = mapInstanceRef.current; + if (!isMapReady || !map || !data) return; + + // 이전에 그린 원 모두 제거 + circlesRef.current.forEach((circle) => circle.setMap(null)); + circlesRef.current = []; + + // 새로 그리기 + data.boundaries.forEach((fence) => { + const circle = new naver.maps.Circle({ + map, + center: new naver.maps.LatLng(fence.center.latitude, fence.center.longitude), + radius: fence.radius, + strokeColor: fence.isActive ? '#22c55e' : '#9ca3af', // 활성=초록, 비활성=회색 + strokeWeight: 2, + fillColor: fence.isActive ? '#22c55e' : '#9ca3af', + fillOpacity: 0.15, + }); + circlesRef.current.push(circle); + }); + + // 첫 울타리로 지도 중심 이동 + const first = data.boundaries[0]; + if (first) { + map.setCenter(new naver.maps.LatLng(first.center.latitude, first.center.longitude)); + } + }, [isMapReady, data]); + if (error) { return (
@@ -47,6 +81,5 @@ export function WalkMap() { ); } - // 지도는 부모에 높이가 있어야 보여요 → 여기서 높이 지정 return
; } diff --git a/src/shared/lib/naver-map/naver.d.ts b/src/shared/lib/naver-map/naver.d.ts index 192c230..1542e50 100644 --- a/src/shared/lib/naver-map/naver.d.ts +++ b/src/shared/lib/naver-map/naver.d.ts @@ -15,6 +15,25 @@ declare namespace naver.maps { setZoom(zoom: number): void; destroy(): void; } + + interface CircleOptions { + map?: Map; + center: LatLng; + /** 반경(미터) */ + radius: number; + strokeColor?: string; + strokeWeight?: number; + strokeOpacity?: number; + fillColor?: string; + fillOpacity?: number; + } + + class Circle { + constructor(options: CircleOptions); + setMap(map: Map | null): void; + setCenter(center: LatLng): void; + setRadius(radius: number): void; + } } // window.naver 로 접근할 수 있게 augment From 9b6b2ca3e019d84019e68077a1e5349ba4a2dc14 Mon Sep 17 00:00:00 2001 From: sooloin Date: Mon, 15 Jun 2026 16:31:16 +0900 Subject: [PATCH 04/12] =?UTF-8?q?:sparkles:=20Feat:=20=EC=9A=B8=ED=83=80?= =?UTF-8?q?=EB=A6=AC=20=EC=83=9D=EC=84=B1=C2=B7=EC=88=98=EC=A0=95=C2=B7ON/?= =?UTF-8?q?OFF=20=EC=A1=B0=EC=9E=91=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 지도 클릭으로 울타리 중심 지정 + 점선 미리보기 원 표시 - WalkMap을 props 기반(boundaries/draftCenter/onMapClick)으로 리팩터링 - FenceControlPanel 추가: 펫 선택, 이름·반경 입력, 생성/수정/토글 - naver 타입에 지도 클릭 이벤트(addListener)·LatLng 접근자 추가 - 변경 후 boundaries 재조회로 지도 갱신 `#62` --- src/features/walk-fence/index.ts | 1 + .../walk-fence/ui/FenceControlPanel.tsx | 168 ++++++++++++++++++ src/features/walk-fence/ui/WalkMap.tsx | 97 +++++++--- src/pages/walk/WalkPage.tsx | 109 +++++++++++- src/shared/lib/naver-map/naver.d.ts | 13 ++ 5 files changed, 362 insertions(+), 26 deletions(-) create mode 100644 src/features/walk-fence/ui/FenceControlPanel.tsx diff --git a/src/features/walk-fence/index.ts b/src/features/walk-fence/index.ts index 316914c..21903f9 100644 --- a/src/features/walk-fence/index.ts +++ b/src/features/walk-fence/index.ts @@ -11,6 +11,7 @@ export { useFenceBoundaries } from './model/useFenceBoundaries'; export { useFenceStatus } from './model/useFenceStatus'; export { useToggleFence } from './model/useToggleFence'; export { useUpdateFenceRange } from './model/useUpdateFenceRange'; +export { FenceControlPanel } from './ui/FenceControlPanel'; export { WalkMap } from './ui/WalkMap'; export type { CreateFenceRequest, diff --git a/src/features/walk-fence/ui/FenceControlPanel.tsx b/src/features/walk-fence/ui/FenceControlPanel.tsx new file mode 100644 index 0000000..6806cec --- /dev/null +++ b/src/features/walk-fence/ui/FenceControlPanel.tsx @@ -0,0 +1,168 @@ +import type { PetListItem } from '@/features/auth'; + +import type { FenceBoundary } from '../model/types'; + +interface DraftCenter { + lat: number; + lng: number; +} + +interface FenceControlPanelProps { + pets: PetListItem[]; + selectedPetId: number | null; + onSelectPet: (petId: number) => void; + /** 선택한 펫의 기존 울타리 (없으면 null → 생성 모드) */ + existingFence: FenceBoundary | null; + draftCenter: DraftCenter | null; + radius: number; + onRadiusChange: (radius: number) => void; + fenceName: string; + onFenceNameChange: (name: string) => void; + onCreate: () => void; + onUpdate: () => void; + onToggle: () => void; + isSubmitting: boolean; +} + +export function FenceControlPanel({ + pets, + selectedPetId, + onSelectPet, + existingFence, + draftCenter, + radius, + onRadiusChange, + fenceName, + onFenceNameChange, + onCreate, + onUpdate, + onToggle, + isSubmitting, +}: FenceControlPanelProps) { + const selectedPet = pets.find((pet) => pet.petId === selectedPetId) ?? null; + + return ( + + ); +} diff --git a/src/features/walk-fence/ui/WalkMap.tsx b/src/features/walk-fence/ui/WalkMap.tsx index 70ef6c4..b6ca736 100644 --- a/src/features/walk-fence/ui/WalkMap.tsx +++ b/src/features/walk-fence/ui/WalkMap.tsx @@ -2,32 +2,59 @@ import { useEffect, useRef, useState } from 'react'; import { loadNaverMap } from '@/shared/lib/naver-map/loadNaverMap'; -import { useFenceBoundaries } from '../model/useFenceBoundaries'; +import type { FenceBoundary } from '../model/types'; -const DEFAULT_CENTER = { lat: 37.5666103, lng: 126.9783882 }; +interface DraftCenter { + lat: number; + lng: number; +} + +interface WalkMapProps { + /** 지도에 그릴 기존 울타리 목록 */ + boundaries: FenceBoundary[]; + /** 생성/수정 미리보기용 중심 (없으면 미리보기 원 숨김) */ + draftCenter: DraftCenter | null; + /** 미리보기 원 반경(미터) */ + draftRadius: number; + /** 지도 클릭 시 좌표 콜백 */ + onMapClick: (lat: number, lng: number) => void; +} -export function WalkMap() { - // useRef로 지도 요소와 인스턴스, 그려둔 원들 보관 - // 리렌더돼도 같은 지도 객체를 유지하고 다시 그릴 때 이전 원을 지우기 +const DEFAULT_CENTER = { lat: 37.5796, lng: 126.977 }; // 경복궁 + +export function WalkMap({ boundaries, draftCenter, draftRadius, onMapClick }: WalkMapProps) { const mapElementRef = useRef(null); - const mapInstanceRef = useRef(null); // 생성된 지도 보관 - const circlesRef = useRef([]); // 그려둔 원들 보관(나중에 지우려고) + const mapInstanceRef = useRef(null); + const circlesRef = useRef([]); // 기존 울타리 원들 + const draftCircleRef = useRef(null); // 미리보기 원 + const onMapClickRef = useRef(onMapClick); // 항상 최신 콜백 보관 const [isMapReady, setIsMapReady] = useState(false); const [error, setError] = useState(null); - const { data } = useFenceBoundaries(); + // 리스너를 다시 달지 않고도 최신 onMapClick을 부르도록 ref만 갱신 + useEffect(() => { + onMapClickRef.current = onMapClick; + }, [onMapClick]); - // 1. 지도 생성 (최초 1회) + // 1. 지도 생성 + 클릭 리스너 (최초 1회) useEffect(() => { let canceled = false; loadNaverMap() .then(() => { if (canceled || !mapElementRef.current) return; - mapInstanceRef.current = new naver.maps.Map(mapElementRef.current, { + + const map = new naver.maps.Map(mapElementRef.current, { center: new naver.maps.LatLng(DEFAULT_CENTER.lat, DEFAULT_CENTER.lng), zoom: 15, }); + + // 지도를 클릭하면 그 좌표를 콜백으로 올려보냄 + map.addListener('click', (event) => { + onMapClickRef.current(event.coord.lat(), event.coord.lng()); + }); + + mapInstanceRef.current = map; setIsMapReady(true); }) .catch((e: unknown) => { @@ -41,19 +68,15 @@ export function WalkMap() { }; }, []); - // 2. 울타리 원 그리기 (지도 준비됨 + 데이터 들어옴/바뀜) - // 울타리 데이터는 나중에 도착하고 바뀔 수 있기 때문에 - // → [isMapReady, data] (데이터 올 때마다 다시 그림) + // 2. 기존 울타리 원 그리기 (데이터 바뀔 때마다) useEffect(() => { const map = mapInstanceRef.current; - if (!isMapReady || !map || !data) return; + if (!isMapReady || !map) return; - // 이전에 그린 원 모두 제거 circlesRef.current.forEach((circle) => circle.setMap(null)); circlesRef.current = []; - // 새로 그리기 - data.boundaries.forEach((fence) => { + boundaries.forEach((fence) => { const circle = new naver.maps.Circle({ map, center: new naver.maps.LatLng(fence.center.latitude, fence.center.longitude), @@ -65,17 +88,45 @@ export function WalkMap() { }); circlesRef.current.push(circle); }); + }, [isMapReady, boundaries]); - // 첫 울타리로 지도 중심 이동 - const first = data.boundaries[0]; - if (first) { - map.setCenter(new naver.maps.LatLng(first.center.latitude, first.center.longitude)); + // 3. 미리보기(점선) 원 그리기/갱신 + useEffect(() => { + const map = mapInstanceRef.current; + if (!isMapReady || !map) return; + + // 중심이 없으면 미리보기 원 제거 + if (!draftCenter) { + draftCircleRef.current?.setMap(null); + draftCircleRef.current = null; + return; } - }, [isMapReady, data]); + + const center = new naver.maps.LatLng(draftCenter.lat, draftCenter.lng); + + if (draftCircleRef.current) { + // 이미 있으면 위치/반경만 갱신 + draftCircleRef.current.setCenter(center); + draftCircleRef.current.setRadius(draftRadius); + } else { + draftCircleRef.current = new naver.maps.Circle({ + map, + center, + radius: draftRadius, + strokeColor: '#f59e0b', + strokeWeight: 2, + strokeStyle: 'shortdash', + fillColor: '#f59e0b', + fillOpacity: 0.12, + }); + } + + map.setCenter(center); + }, [isMapReady, draftCenter, draftRadius]); if (error) { return ( -
+
{error}
); diff --git a/src/pages/walk/WalkPage.tsx b/src/pages/walk/WalkPage.tsx index c788e1c..63fd60a 100644 --- a/src/pages/walk/WalkPage.tsx +++ b/src/pages/walk/WalkPage.tsx @@ -1,9 +1,112 @@ -import { WalkMap } from '@/features/walk-fence'; +import { useMemo, useState } from 'react'; + +import { usePetList } from '@/features/auth'; +import { + FenceControlPanel, + WalkMap, + useCreateFence, + useFenceBoundaries, + useToggleFence, + useUpdateFenceRange, +} from '@/features/walk-fence'; + +interface DraftCenter { + lat: number; + lng: number; +} + +const EMPTY_BOUNDARIES: never[] = []; export function WalkPage() { + const { data: boundariesData } = useFenceBoundaries(); + const { data: petListData } = usePetList(); + const createFence = useCreateFence(); + const toggleFence = useToggleFence(); + const updateFenceRange = useUpdateFenceRange(); + + const boundaries = boundariesData?.boundaries ?? EMPTY_BOUNDARIES; + const pets = petListData?.pets ?? []; + + const [selectedPetId, setSelectedPetId] = useState(null); + const [draftCenter, setDraftCenter] = useState(null); + const [radius, setRadius] = useState(500); + const [fenceName, setFenceName] = useState(''); + + // 선택한 펫의 기존 울타리 (있으면 수정 모드, 없으면 생성 모드) + const existingFence = useMemo( + () => (selectedPetId != null ? (boundaries.find((fence) => fence.petId === selectedPetId) ?? null) : null), + [boundaries, selectedPetId], + ); + + // 펫/울타리가 바뀌면 입력값 동기화 (effect 대신 렌더 중 조정 — React 권장 패턴) + const formKey = `${selectedPetId ?? ''}:${existingFence?.fenceId ?? ''}`; + const [syncedKey, setSyncedKey] = useState(formKey); + if (formKey !== syncedKey) { + setSyncedKey(formKey); + setRadius(existingFence ? existingFence.radius : 500); + setFenceName(existingFence ? existingFence.fenceName : ''); + setDraftCenter(null); + } + + const isSubmitting = createFence.isPending || toggleFence.isPending || updateFenceRange.isPending; + + const handleCreate = () => { + if (selectedPetId == null || !draftCenter) return; + createFence.mutate({ + petId: selectedPetId, + centerLatitude: draftCenter.lat, + centerLongitude: draftCenter.lng, + radius, + fenceName: fenceName.trim(), + }); + }; + + const handleUpdate = () => { + if (!existingFence) return; + updateFenceRange.mutate({ + fenceId: existingFence.fenceId, + payload: { + centerLatitude: draftCenter?.lat ?? existingFence.center.latitude, + centerLongitude: draftCenter?.lng ?? existingFence.center.longitude, + radius, + fenceName: fenceName.trim() || existingFence.fenceName, + }, + }); + }; + + const handleToggle = () => { + if (!existingFence) return; + toggleFence.mutate({ + fenceId: existingFence.fenceId, + payload: { fenceIsActive: !existingFence.isActive }, + }); + }; + return ( -
- +
+
+ setDraftCenter({ lat, lng })} + /> + +
); } diff --git a/src/shared/lib/naver-map/naver.d.ts b/src/shared/lib/naver-map/naver.d.ts index 1542e50..4a68442 100644 --- a/src/shared/lib/naver-map/naver.d.ts +++ b/src/shared/lib/naver-map/naver.d.ts @@ -2,6 +2,8 @@ declare namespace naver.maps { class LatLng { constructor(lat: number, lng: number); + lat(): number; + lng(): number; } interface MapOptions { @@ -9,10 +11,19 @@ declare namespace naver.maps { zoom?: number; } + /** 지도 이벤트 핸들 (해제 시 사용) */ + type MapEventListener = object; + + /** 클릭 등 포인터 이벤트 — coord에 클릭 좌표가 담김 */ + interface PointerEvent { + coord: LatLng; + } + class Map { constructor(element: string | HTMLElement, options: MapOptions); setCenter(latlng: LatLng): void; setZoom(zoom: number): void; + addListener(eventName: string, listener: (event: PointerEvent) => void): MapEventListener; destroy(): void; } @@ -24,6 +35,8 @@ declare namespace naver.maps { strokeColor?: string; strokeWeight?: number; strokeOpacity?: number; + /** 'solid' | 'shortdash' | 'dash' 등 */ + strokeStyle?: string; fillColor?: string; fillOpacity?: number; } From e16e66c330ec4dd2cc985c850e376b2c696be445 Mon Sep 17 00:00:00 2001 From: sooloin Date: Mon, 15 Jun 2026 16:47:24 +0900 Subject: [PATCH 05/12] =?UTF-8?q?:art:=20Style:=20=EC=82=B0=EC=B1=85=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=A7=80=EB=8F=84=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=95=84=EC=9B=83=20=EB=B0=8F=20=EC=9A=B8=ED=83=80?= =?UTF-8?q?=EB=A6=AC=20UI=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 네이버 지도 스타일 레이아웃 적용 (좌측 사이드바 + 전체 폭 지도) - 울타리 ON/OFF 토글 렌더링 정상화 및 접근성(role/aria) 보강 - 울타리 원 중심에 펫 이름 라벨(Marker) 표시 - 수정 모드에서도 울타리 이름 변경 가능하도록 입력 노출 - naver 타입에 Marker/Point 추가 `#62` --- .../walk-fence/ui/FenceControlPanel.tsx | 41 +++++++++---------- src/features/walk-fence/ui/WalkMap.tsx | 37 ++++++++++++++--- src/pages/walk/WalkPage.tsx | 27 ++++++++---- src/shared/lib/naver-map/naver.d.ts | 23 +++++++++++ 4 files changed, 93 insertions(+), 35 deletions(-) diff --git a/src/features/walk-fence/ui/FenceControlPanel.tsx b/src/features/walk-fence/ui/FenceControlPanel.tsx index 6806cec..d5fac23 100644 --- a/src/features/walk-fence/ui/FenceControlPanel.tsx +++ b/src/features/walk-fence/ui/FenceControlPanel.tsx @@ -42,8 +42,8 @@ export function FenceControlPanel({ const selectedPet = pets.find((pet) => pet.petId === selectedPetId) ?? null; return ( - +
); } diff --git a/src/features/walk-fence/ui/WalkMap.tsx b/src/features/walk-fence/ui/WalkMap.tsx index b6ca736..d9ce349 100644 --- a/src/features/walk-fence/ui/WalkMap.tsx +++ b/src/features/walk-fence/ui/WalkMap.tsx @@ -22,10 +22,21 @@ interface WalkMapProps { const DEFAULT_CENTER = { lat: 37.5796, lng: 126.977 }; // 경복궁 +// HTML 마커에 펫 이름을 넣기 전 간단한 이스케이프 (XSS 방지) +function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function petLabelContent(name: string, isActive: boolean): string { + const color = isActive ? '#16a34a' : '#6b7280'; + return `
${escapeHtml(name)}
`; +} + export function WalkMap({ boundaries, draftCenter, draftRadius, onMapClick }: WalkMapProps) { const mapElementRef = useRef(null); const mapInstanceRef = useRef(null); const circlesRef = useRef([]); // 기존 울타리 원들 + const markersRef = useRef([]); // 펫 이름 라벨들 const draftCircleRef = useRef(null); // 미리보기 원 const onMapClickRef = useRef(onMapClick); // 항상 최신 콜백 보관 const [isMapReady, setIsMapReady] = useState(false); @@ -68,18 +79,23 @@ export function WalkMap({ boundaries, draftCenter, draftRadius, onMapClick }: Wa }; }, []); - // 2. 기존 울타리 원 그리기 (데이터 바뀔 때마다) + // 2. 기존 울타리 원 + 펫 이름 라벨 그리기 (데이터 바뀔 때마다) useEffect(() => { const map = mapInstanceRef.current; if (!isMapReady || !map) return; + // 이전 원/라벨 제거 circlesRef.current.forEach((circle) => circle.setMap(null)); circlesRef.current = []; + markersRef.current.forEach((marker) => marker.setMap(null)); + markersRef.current = []; boundaries.forEach((fence) => { + const center = new naver.maps.LatLng(fence.center.latitude, fence.center.longitude); + const circle = new naver.maps.Circle({ map, - center: new naver.maps.LatLng(fence.center.latitude, fence.center.longitude), + center, radius: fence.radius, strokeColor: fence.isActive ? '#22c55e' : '#9ca3af', // 활성=초록, 비활성=회색 strokeWeight: 2, @@ -87,6 +103,17 @@ export function WalkMap({ boundaries, draftCenter, draftRadius, onMapClick }: Wa fillOpacity: 0.15, }); circlesRef.current.push(circle); + + // 울타리 중심에 펫 이름 라벨 + const marker = new naver.maps.Marker({ + map, + position: center, + icon: { + content: petLabelContent(fence.petName, fence.isActive), + anchor: new naver.maps.Point(0, 0), + }, + }); + markersRef.current.push(marker); }); }, [isMapReady, boundaries]); @@ -126,11 +153,9 @@ export function WalkMap({ boundaries, draftCenter, draftRadius, onMapClick }: Wa if (error) { return ( -
- {error} -
+
{error}
); } - return
; + return
; } diff --git a/src/pages/walk/WalkPage.tsx b/src/pages/walk/WalkPage.tsx index 63fd60a..4440de3 100644 --- a/src/pages/walk/WalkPage.tsx +++ b/src/pages/walk/WalkPage.tsx @@ -83,14 +83,15 @@ export function WalkPage() { }; return ( -
-
- setDraftCenter({ lat, lng })} - /> + // 헤더(h-14) 아래로 전체 폭을 채우는 네이버 지도 스타일 레이아웃 + // (main의 max-w-5xl·padding을 벗어나기 위해 full-bleed 처리) +
+ {/* 왼쪽 사이드바 — 울타리 설정 (추후 산책 활동 섹션 추가 예정) */} + + + {/* 오른쪽 지도 영역 */} +
+ setDraftCenter({ lat, lng })} + />
); diff --git a/src/shared/lib/naver-map/naver.d.ts b/src/shared/lib/naver-map/naver.d.ts index 4a68442..872927c 100644 --- a/src/shared/lib/naver-map/naver.d.ts +++ b/src/shared/lib/naver-map/naver.d.ts @@ -47,6 +47,29 @@ declare namespace naver.maps { setCenter(center: LatLng): void; setRadius(radius: number): void; } + + class Point { + constructor(x: number, y: number); + } + + interface MarkerIcon { + content: string; + anchor?: Point; + } + + interface MarkerOptions { + map?: Map; + position: LatLng; + icon?: MarkerIcon | string; + title?: string; + clickable?: boolean; + } + + class Marker { + constructor(options: MarkerOptions); + setMap(map: Map | null): void; + setPosition(position: LatLng): void; + } } // window.naver 로 접근할 수 있게 augment From c49c272fa50793e167b3185976e3bde14240d2f0 Mon Sep 17 00:00:00 2001 From: sooloin Date: Mon, 15 Jun 2026 17:01:35 +0900 Subject: [PATCH 06/12] =?UTF-8?q?:art:=20Style:=20=EC=82=B0=EC=B1=85=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=84=A4=EC=9D=B4=EB=B2=84=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 산책 페이지에서 상단 헤더 숨기고 전체화면 지도 레이아웃 적용 - WalkSideRail 추가: 좌측 세로 레일에 로고·메뉴·프로필 배치 - 지도 위 둥근 플로팅 카드(레일+울타리 패널) 구조로 변경 - header 배럴에 useIsLoggedIn 노출 `#62` --- src/app/layouts/AppLayout.tsx | 16 +++- src/pages/walk/WalkPage.tsx | 63 ++++++++------- src/pages/walk/ui/WalkSideRail.tsx | 122 +++++++++++++++++++++++++++++ src/widgets/header/index.ts | 1 + 4 files changed, 173 insertions(+), 29 deletions(-) create mode 100644 src/pages/walk/ui/WalkSideRail.tsx diff --git a/src/app/layouts/AppLayout.tsx b/src/app/layouts/AppLayout.tsx index 1ce0061..339d6b4 100644 --- a/src/app/layouts/AppLayout.tsx +++ b/src/app/layouts/AppLayout.tsx @@ -1,11 +1,15 @@ import { useEffect } from 'react'; -import { Outlet } from 'react-router-dom'; +import { Outlet, useLocation } from 'react-router-dom'; import { redirectToLogin, refreshAccessToken } from '@/shared/lib/auth/refreshSession'; import { getAccessToken, getRefreshToken, isAccessTokenExpired } from '@/shared/lib/auth/token'; import { Header } from '@/widgets/header'; export function AppLayout() { + const location = useLocation(); + // 산책 페이지는 헤더 없는 전체화면(지도) 레이아웃 + const isFullBleed = location.pathname.startsWith('/walk'); + useEffect(() => { if (!getAccessToken() || !getRefreshToken() || !isAccessTokenExpired()) { return; @@ -21,6 +25,16 @@ export function AppLayout() { void refreshSession(); }, []); + if (isFullBleed) { + return ( +
+
+ +
+
+ ); + } + return (
diff --git a/src/pages/walk/WalkPage.tsx b/src/pages/walk/WalkPage.tsx index 4440de3..c228f6f 100644 --- a/src/pages/walk/WalkPage.tsx +++ b/src/pages/walk/WalkPage.tsx @@ -10,6 +10,8 @@ import { useUpdateFenceRange, } from '@/features/walk-fence'; +import { WalkSideRail } from './ui/WalkSideRail'; + interface DraftCenter { lat: number; lng: number; @@ -83,34 +85,10 @@ export function WalkPage() { }; return ( - // 헤더(h-14) 아래로 전체 폭을 채우는 네이버 지도 스타일 레이아웃 - // (main의 max-w-5xl·padding을 벗어나기 위해 full-bleed 처리) -
- {/* 왼쪽 사이드바 — 울타리 설정 (추후 산책 활동 섹션 추가 예정) */} - - - {/* 오른쪽 지도 영역 */} -
+ // 네이버 지도 스타일: 지도가 화면 전체, 그 위에 둥근 레일 + 패널이 떠 있음 +
+ {/* 배경 전체를 채우는 지도 */} +
setDraftCenter({ lat, lng })} />
+ + {/* 좌측 플로팅: 세로 레일 + 울타리 패널 */} +
+ + + +
); } diff --git a/src/pages/walk/ui/WalkSideRail.tsx b/src/pages/walk/ui/WalkSideRail.tsx new file mode 100644 index 0000000..5aa03e3 --- /dev/null +++ b/src/pages/walk/ui/WalkSideRail.tsx @@ -0,0 +1,122 @@ +import { Link, NavLink } from 'react-router-dom'; + +import profileDefaultIllustration from '@/features/auth/assets/profile-default.svg'; +import { useCurrentUser } from '@/features/auth/model/useCurrentUser'; +import DoDoLogo from '@/shared/assets/images/Logo_light.svg?react'; +import { useIsLoggedIn } from '@/widgets/header'; + +interface IconProps { + className?: string; +} + +function PawIcon({ className }: IconProps) { + return ( + + + + + + + + ); +} + +function ChatIcon({ className }: IconProps) { + return ( + + + + ); +} + +function UserIcon({ className }: IconProps) { + return ( + + + + + ); +} + +const NAV_ITEMS = [ + { to: '/walk', label: '산책', Icon: PawIcon }, + { to: '/community', label: '커뮤니티', Icon: ChatIcon }, + { to: '/my', label: '마이도도', Icon: UserIcon }, +]; + +const itemClass = ({ isActive }: { isActive: boolean }) => + [ + 'flex w-full flex-col items-center gap-1 rounded-xl py-2 text-[11px] font-medium transition-colors', + isActive ? 'bg-brand/10 text-brand' : 'text-neutral-500 hover:bg-neutral-100 hover:text-neutral-800', + ].join(' '); + +function RailProfile() { + const { profileUrl } = useCurrentUser(); + const resolvedUrl = profileUrl?.trim() || profileDefaultIllustration; + + return ( + + + + ); +} + +export function WalkSideRail() { + const isLoggedIn = useIsLoggedIn(); + + return ( + + ); +} diff --git a/src/widgets/header/index.ts b/src/widgets/header/index.ts index 43c1c4b..43d5e72 100644 --- a/src/widgets/header/index.ts +++ b/src/widgets/header/index.ts @@ -1 +1,2 @@ export { Header } from './ui/Header'; +export { useIsLoggedIn } from './model/useIsLoggedIn'; From ce935788dcc62b641b46b8b7bf13c08265da88b2 Mon Sep 17 00:00:00 2001 From: sooloin Date: Mon, 15 Jun 2026 17:21:27 +0900 Subject: [PATCH 07/12] =?UTF-8?q?:gear:=20Chore:=20=EC=8B=A4=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=9C=84=EC=B9=98=EC=9A=A9=20STOMP=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EB=B0=8F=20=EC=86=8C=EC=BC=93=20URL=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @stomp/stompjs 설치 (WebSocket STOMP 통신용) - env에 VITE_WS_URL 추가 `#62` --- package.json | 1 + src/shared/config/env.ts | 3 +++ yarn.lock | 5 +++++ 3 files changed, 9 insertions(+) diff --git a/package.json b/package.json index f9ea390..2900597 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ ] }, "dependencies": { + "@stomp/stompjs": "^7.3.0", "@tailwindcss/vite": "^4.2.1", "@tanstack/react-query": "^5.90.21", "axios": "^1.13.6", diff --git a/src/shared/config/env.ts b/src/shared/config/env.ts index 41cc547..e30ec22 100644 --- a/src/shared/config/env.ts +++ b/src/shared/config/env.ts @@ -3,6 +3,7 @@ const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID; const NAVER_CLIENT_ID = import.meta.env.VITE_NAVER_CLIENT_ID; const OAUTH_REDIRECT_URI = import.meta.env.VITE_OAUTH_REDIRECT_URI; const NAVER_MAP_CLIENT_ID = import.meta.env.VITE_NAVER_MAP_CLIENT_ID; +const WS_URL = import.meta.env.VITE_WS_URL; // 환경 변수 누락 방지 const requiredEnv = { @@ -11,6 +12,7 @@ const requiredEnv = { VITE_NAVER_CLIENT_ID: NAVER_CLIENT_ID, VITE_OAUTH_REDIRECT_URI: OAUTH_REDIRECT_URI, VITE_NAVER_MAP_CLIENT_ID: NAVER_MAP_CLIENT_ID, + VITE_WS_URL: WS_URL, }; Object.entries(requiredEnv).forEach(([key, value]) => { @@ -25,4 +27,5 @@ export const env = { NAVER_CLIENT_ID, OAUTH_REDIRECT_URI, NAVER_MAP_CLIENT_ID, + WS_URL, }; diff --git a/yarn.lock b/yarn.lock index 1cc8978..3fd9f67 100644 --- a/yarn.lock +++ b/yarn.lock @@ -670,6 +670,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz#beacb356412eef5dc0164e9edfee51c563732054" integrity sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg== +"@stomp/stompjs@^7.3.0": + version "7.3.0" + resolved "https://registry.yarnpkg.com/@stomp/stompjs/-/stompjs-7.3.0.tgz#5655b93e086a0be684291424c5bc8c92949b33ee" + integrity sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ== + "@svgr/babel-plugin-add-jsx-attribute@8.0.0": version "8.0.0" resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz#4001f5d5dd87fa13303e36ee106e3ff3a7eb8b22" From 113821fc41653b3ce6374fbf8e72a6457ad8f89b Mon Sep 17 00:00:00 2001 From: sooloin Date: Mon, 15 Jun 2026 17:27:58 +0900 Subject: [PATCH 08/12] =?UTF-8?q?:sparkles:=20Feat:=20STOMP=20=ED=81=B4?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=ED=97=AC=ED=8D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createStompClient: brokerURL/자동 재연결/heartbeat 설정 - beforeConnect에서 연결 직전 최신 액세스 토큰을 헤더에 주입 `#62` --- src/shared/lib/socket/stompClient.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/shared/lib/socket/stompClient.ts diff --git a/src/shared/lib/socket/stompClient.ts b/src/shared/lib/socket/stompClient.ts new file mode 100644 index 0000000..748ef8d --- /dev/null +++ b/src/shared/lib/socket/stompClient.ts @@ -0,0 +1,23 @@ +import { Client } from '@stomp/stompjs'; + +import { env } from '@/shared/config'; +import { getAccessToken } from '@/shared/lib/auth/token'; + +/** 설정이 끝난 STOMP 클라이언트를 생성 (구독은 사용하는 쪽에서) */ +export function createStompClient(): Client { + const client = new Client({ + brokerURL: env.WS_URL, // wss://... 로 직접 연결 (SockJS 미사용) + reconnectDelay: 5000, // 연결 끊기면 5초 후 자동 재연결 + heartbeatIncoming: 10000, // 서버 ↔ 클라이언트 연결 살아있는지 확인(10초) + heartbeatOutgoing: 10000, + beforeConnect: () => { + // 연결 직전마다 최신 토큰을 헤더에 실음 (재연결 시 갱신된 토큰 반영) + // 생성 시점에 넣으면 토큰 만료 시 갱신된 토큰이 반영되지 않음 + client.connectHeaders = { + Authorization: `Bearer ${getAccessToken() ?? ''}`, + }; + }, + }); + + return client; +} From cfb40dd33d24adcc2068d90d39162469483390b0 Mon Sep 17 00:00:00 2001 From: sooloin Date: Mon, 15 Jun 2026 17:32:18 +0900 Subject: [PATCH 09/12] =?UTF-8?q?:sparkles:=20Feat:=20=EC=8B=A4=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=9C=84=EC=B9=98=20WebSocket=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=ED=83=80=EC=9E=85=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LiveLocationMessage / LiveLocationPayload 타입 추가 - 서버가 내려주는 insideFence·distanceMeter 등 포함 `#62` --- src/features/walk-fence/index.ts | 2 ++ src/features/walk-fence/model/types.ts | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/features/walk-fence/index.ts b/src/features/walk-fence/index.ts index 21903f9..fa14305 100644 --- a/src/features/walk-fence/index.ts +++ b/src/features/walk-fence/index.ts @@ -21,6 +21,8 @@ export type { FenceCenter, FenceMessageResponse, FenceStatusResponse, + LiveLocationMessage, // 실시간 위치 업데이트 서버 메세지 전체 받기 + LiveLocationPayload, // 실시간 위치 업데이트 서버 메세지 중 payload 부분 ToggleFenceRequest, UpdateFenceRangeRequest, } from './model/types'; diff --git a/src/features/walk-fence/model/types.ts b/src/features/walk-fence/model/types.ts index e0c3807..ae44827 100644 --- a/src/features/walk-fence/model/types.ts +++ b/src/features/walk-fence/model/types.ts @@ -65,3 +65,24 @@ export interface FenceBoundariesResponse { message: string; boundaries: FenceBoundary[]; } + +// 실시간 위치 업데이트 WebSocket 메시지 타입 +/** 실시간 위치 메시지의 payload (서버가 울타리 판정까지 해서 보냄) */ +export interface LiveLocationPayload { + petId: number; + latitude: number; + longitude: number; + measuredAt: string; + /** 울타리 안에 있는지 (서버 판정) */ + insideFence: boolean; + /** 울타리 중심에서의 거리(미터) */ + distanceMeter: number; + radius: number; + message: string; +} + +/** WebSocket 수신 메시지 (payload가 한 겹 감싸져 있음) */ +export interface LiveLocationMessage { + type: string; + payload: LiveLocationPayload; +} From 3f52a836bf7860f7399c3cdba49925e3ced77947 Mon Sep 17 00:00:00 2001 From: sooloin Date: Mon, 15 Jun 2026 17:49:33 +0900 Subject: [PATCH 10/12] =?UTF-8?q?:sparkles:=20Feat:=20=EC=8B=A4=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=9C=84=EC=B9=98=20=EA=B5=AC=EB=8F=85=20=ED=9B=85?= =?UTF-8?q?(useLiveLocation)=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - STOMP 연결 후 /sub/fence/location/{petId} 구독 - 수신 payload를 location 상태로 관리, 연결 상태 노출 - petId 변경/언마운트 시 연결 해제로 누수 방지 `#62` --- src/features/walk-fence/index.ts | 1 + .../walk-fence/model/useLiveLocation.ts | 52 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 src/features/walk-fence/model/useLiveLocation.ts diff --git a/src/features/walk-fence/index.ts b/src/features/walk-fence/index.ts index fa14305..fb196ad 100644 --- a/src/features/walk-fence/index.ts +++ b/src/features/walk-fence/index.ts @@ -9,6 +9,7 @@ export { export { useCreateFence } from './model/useCreateFence'; export { useFenceBoundaries } from './model/useFenceBoundaries'; export { useFenceStatus } from './model/useFenceStatus'; +export { useLiveLocation } from './model/useLiveLocation'; export { useToggleFence } from './model/useToggleFence'; export { useUpdateFenceRange } from './model/useUpdateFenceRange'; export { FenceControlPanel } from './ui/FenceControlPanel'; diff --git a/src/features/walk-fence/model/useLiveLocation.ts b/src/features/walk-fence/model/useLiveLocation.ts new file mode 100644 index 0000000..4d16b3d --- /dev/null +++ b/src/features/walk-fence/model/useLiveLocation.ts @@ -0,0 +1,52 @@ +import { useEffect, useState } from 'react'; + +import { createStompClient } from '@/shared/lib/socket/stompClient'; + +import type { LiveLocationMessage, LiveLocationPayload } from './types'; + +/** 특정 펫의 실시간 위치를 구독 (petId 없으면 연결 안 함) */ +export function useLiveLocation(petId: number | null) { + const [location, setLocation] = useState(null); + const [isConnected, setIsConnected] = useState(false); + + // petId가 바뀌면 이전 펫 위치를 초기화 (렌더 중 조정 — effect 내 setState 회피) + const [trackedPetId, setTrackedPetId] = useState(petId); + if (petId !== trackedPetId) { + setTrackedPetId(petId); + setLocation(null); + setIsConnected(false); + } + + useEffect(() => { + if (petId == null) return; + + const client = createStompClient(); + + // 연결 성공 시 구독 시작 + client.onConnect = () => { + setIsConnected(true); + client.subscribe(`/sub/fence/location/${petId}`, (frame) => { + try { + const message = JSON.parse(frame.body) as LiveLocationMessage; + setLocation(message.payload); // 감싸진 payload만 꺼내 저장 + } catch { + // JSON 파싱 실패는 무시 + } + }); + }; + + // 연결 끊기면 표시 + client.onWebSocketClose = () => { + setIsConnected(false); + }; + + client.activate(); // 연결 시작 + + // 언마운트 / petId 변경 시 연결 해제 + return () => { + void client.deactivate(); + }; + }, [petId]); + + return { location, isConnected }; +} From e921adca47e37de27acc83446e4b6a38ac4aa744 Mon Sep 17 00:00:00 2001 From: sooloin Date: Mon, 15 Jun 2026 17:59:59 +0900 Subject: [PATCH 11/12] =?UTF-8?q?:bug:=20Fix:=20=EC=9A=B8=ED=83=80?= =?UTF-8?q?=EB=A6=AC=20=EB=B9=84=ED=99=9C=EC=84=B1=20=EC=8B=9C=20=EC=9D=B4?= =?UTF-8?q?=ED=83=88=20=EC=95=8C=EB=A6=BC=EC=9D=B4=20=EB=9C=A8=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이탈 판정에 울타리 활성(isActive) 조건 추가 - 울타리가 꺼져 있으면 알림 배너·빨강 마커 미표시 `#62` --- src/features/walk-fence/ui/WalkMap.tsx | 32 +++++++++++++++++++++++++- src/pages/walk/WalkPage.tsx | 21 +++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/features/walk-fence/ui/WalkMap.tsx b/src/features/walk-fence/ui/WalkMap.tsx index d9ce349..c4b5683 100644 --- a/src/features/walk-fence/ui/WalkMap.tsx +++ b/src/features/walk-fence/ui/WalkMap.tsx @@ -18,6 +18,8 @@ interface WalkMapProps { draftRadius: number; /** 지도 클릭 시 좌표 콜백 */ onMapClick: (lat: number, lng: number) => void; + /** 실시간 펫 위치 (없으면 마커 숨김) */ + livePosition: { lat: number; lng: number; insideFence: boolean } | null; } const DEFAULT_CENTER = { lat: 37.5796, lng: 126.977 }; // 경복궁 @@ -32,12 +34,19 @@ function petLabelContent(name: string, isActive: boolean): string { return `
${escapeHtml(name)}
`; } -export function WalkMap({ boundaries, draftCenter, draftRadius, onMapClick }: WalkMapProps) { +// 실시간 위치 점 마커 (울타리 안=초록, 밖=빨강) +function liveMarkerContent(insideFence: boolean): string { + const color = insideFence ? '#22c55e' : '#ef4444'; + return `
`; +} + +export function WalkMap({ boundaries, draftCenter, draftRadius, onMapClick, livePosition }: WalkMapProps) { const mapElementRef = useRef(null); const mapInstanceRef = useRef(null); const circlesRef = useRef([]); // 기존 울타리 원들 const markersRef = useRef([]); // 펫 이름 라벨들 const draftCircleRef = useRef(null); // 미리보기 원 + const liveMarkerRef = useRef(null); // 실시간 위치 마커 const onMapClickRef = useRef(onMapClick); // 항상 최신 콜백 보관 const [isMapReady, setIsMapReady] = useState(false); const [error, setError] = useState(null); @@ -151,6 +160,27 @@ export function WalkMap({ boundaries, draftCenter, draftRadius, onMapClick }: Wa map.setCenter(center); }, [isMapReady, draftCenter, draftRadius]); + // 4. 실시간 펫 위치 마커 (위치 올 때마다 갱신) + useEffect(() => { + const map = mapInstanceRef.current; + if (!isMapReady || !map) return; + + // 이전 마커 제거 후 다시 그림 (마커 1개라 부담 없음) + liveMarkerRef.current?.setMap(null); + liveMarkerRef.current = null; + + if (!livePosition) return; + + liveMarkerRef.current = new naver.maps.Marker({ + map, + position: new naver.maps.LatLng(livePosition.lat, livePosition.lng), + icon: { + content: liveMarkerContent(livePosition.insideFence), + anchor: new naver.maps.Point(0, 0), + }, + }); + }, [isMapReady, livePosition]); + if (error) { return (
{error}
diff --git a/src/pages/walk/WalkPage.tsx b/src/pages/walk/WalkPage.tsx index c228f6f..aa037ef 100644 --- a/src/pages/walk/WalkPage.tsx +++ b/src/pages/walk/WalkPage.tsx @@ -6,6 +6,7 @@ import { WalkMap, useCreateFence, useFenceBoundaries, + useLiveLocation, useToggleFence, useUpdateFenceRange, } from '@/features/walk-fence'; @@ -84,6 +85,14 @@ export function WalkPage() { }); }; + // 선택한 펫의 실시간 위치 구독 + const { location: liveLocation } = useLiveLocation(selectedPetId); + const selectedPetName = pets.find((pet) => pet.petId === selectedPetId)?.petName ?? '반려동물'; + + // 울타리가 켜져 있고 + 실제로 벗어났을 때만 이탈로 간주 + const fenceActive = existingFence?.isActive ?? false; + const isOutsideFence = !!liveLocation && fenceActive && !liveLocation.insideFence; + return ( // 네이버 지도 스타일: 지도가 화면 전체, 그 위에 둥근 레일 + 패널이 떠 있음
@@ -94,9 +103,21 @@ export function WalkPage() { draftCenter={draftCenter} draftRadius={radius} onMapClick={(lat, lng) => setDraftCenter({ lat, lng })} + livePosition={ + liveLocation + ? { lat: liveLocation.latitude, lng: liveLocation.longitude, insideFence: !isOutsideFence } + : null + } />
+ {/* 울타리 이탈 알림 배너 (울타리 켜진 경우에만) */} + {liveLocation && isOutsideFence && ( +
+ ⚠️ {selectedPetName}이(가) 울타리를 벗어났어요! (약 {Math.round(liveLocation.distanceMeter)}m) +
+ )} + {/* 좌측 플로팅: 세로 레일 + 울타리 패널 */}
From 23775f13baa4d089ac00d4c2fcc323dd14f140f1 Mon Sep 17 00:00:00 2001 From: sooloin Date: Mon, 15 Jun 2026 18:19:28 +0900 Subject: [PATCH 12/12] =?UTF-8?q?:bug:=20Fix:=20=EC=9A=B8=ED=83=80?= =?UTF-8?q?=EB=A6=AC=20=EC=A0=80=EC=9E=A5=20=ED=9B=84=20=EB=AF=B8=EB=A6=AC?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EC=9E=94=EC=A1=B4=C2=B7=EB=B0=98=EA=B2=BD?= =?UTF-8?q?=20=EC=A1=B0=EC=A0=88=20=EC=8A=A4=EB=83=85=C2=B7=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B2=84=ED=8A=BC=20=EA=B2=80=EC=A6=9D=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 울타리 생성/수정 성공 시 미리보기 원(draftCenter) 초기화 - 지도 중심 이동을 draftCenter 변경 시에만 수행해 반경 조절 시 스냅 방지 - 울타리 수정 저장 버튼도 이름이 비어있으면 비활성화 `#62` --- .../walk-fence/ui/FenceControlPanel.tsx | 2 +- src/features/walk-fence/ui/WalkMap.tsx | 9 ++++- src/pages/walk/WalkPage.tsx | 40 ++++++++++++------- 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/src/features/walk-fence/ui/FenceControlPanel.tsx b/src/features/walk-fence/ui/FenceControlPanel.tsx index d5fac23..2d2ba68 100644 --- a/src/features/walk-fence/ui/FenceControlPanel.tsx +++ b/src/features/walk-fence/ui/FenceControlPanel.tsx @@ -144,7 +144,7 @@ export function FenceControlPanel({