From 0e098412b079c3f9e23e6af3e4dd70f9ea5a5af7 Mon Sep 17 00:00:00 2001 From: Kwon DaeGeun Date: Sat, 15 Nov 2025 13:23:58 +0900 Subject: [PATCH 1/8] =?UTF-8?q?fix:=20=EC=9D=B4=EC=A0=84=20=EA=B0=92?= =?UTF-8?q?=EC=9D=84=20=EC=A0=80=EC=9E=A5=ED=95=98=EC=97=AC=20=ED=9A=8C?= =?UTF-8?q?=EC=A0=84=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.sample | 2 +- src/api/bus.ts | 2 +- src/hooks/useMapOverlays.ts | 25 +++++-- src/types/kakao.d.ts | 1 + src/utils/mapOverlays.ts | 128 ++++++++++++++++++++++++++---------- 5 files changed, 116 insertions(+), 42 deletions(-) diff --git a/.env.sample b/.env.sample index 9c08b70..a8b82aa 100644 --- a/.env.sample +++ b/.env.sample @@ -1,3 +1,3 @@ VITE_KAKAO_MAP_API_KEY= -VITE_API_BASE_URL= +VITE_API_BASE_URL=https://whatthebus.duckdns.org VITE_API_KEY= \ No newline at end of file diff --git a/src/api/bus.ts b/src/api/bus.ts index 87c1ccb..7903eb9 100644 --- a/src/api/bus.ts +++ b/src/api/bus.ts @@ -21,7 +21,7 @@ export const useBusLocations = ( return []; } }, - refetchInterval: 5000, + refetchInterval: 7000, refetchIntervalInBackground: true, retry: 2, }); diff --git a/src/hooks/useMapOverlays.ts b/src/hooks/useMapOverlays.ts index 01b7b40..c95f141 100644 --- a/src/hooks/useMapOverlays.ts +++ b/src/hooks/useMapOverlays.ts @@ -2,9 +2,9 @@ import { useEffect } from "react"; import type { Bus } from "../data/bus"; import type { BusStop } from "../data/busStops"; import { + clearAllBusOverlays, createBusOverlays, createBusStopOverlays, - type OverlayHandle, } from "../utils/mapOverlays"; export const useMapOverlays = ( @@ -14,16 +14,14 @@ export const useMapOverlays = ( selectedStopName?: string, onStopClick?: (stop: BusStop) => void ) => { + // 버스 정류장 오버레이 관리 useEffect(() => { if (!map) return; - const overlays: OverlayHandle[] = [ - ...createBusStopOverlays(map, busStops, selectedStopName, onStopClick), - ...createBusOverlays(map, buses), - ]; + const stopOverlays = createBusStopOverlays(map, busStops, selectedStopName, onStopClick); return () => { - overlays.forEach((overlay) => { + stopOverlays.forEach((overlay) => { try { overlay.setMap(null); } catch { @@ -36,5 +34,18 @@ export const useMapOverlays = ( } }); }; - }, [map, busStops, buses, selectedStopName, onStopClick]); + }, [map, busStops, selectedStopName, onStopClick]); + + // 버스 오버레이 관리 (업데이트만 수행, cleanup 없음) + useEffect(() => { + if (!map) return; + createBusOverlays(map, buses); + }, [map, buses]); + + // 컴포넌트 언마운트 시에만 모든 버스 오버레이 정리 + useEffect(() => { + return () => { + clearAllBusOverlays(); + }; + }, []); }; diff --git a/src/types/kakao.d.ts b/src/types/kakao.d.ts index 236c77c..33def80 100644 --- a/src/types/kakao.d.ts +++ b/src/types/kakao.d.ts @@ -17,6 +17,7 @@ interface KakaoMap { interface KakaoOverlay { setMap: (map: KakaoMap | null) => void; + setPosition: (position: KakaoLatLng) => void; } declare global { diff --git a/src/utils/mapOverlays.ts b/src/utils/mapOverlays.ts index 8dda926..f9ff0db 100644 --- a/src/utils/mapOverlays.ts +++ b/src/utils/mapOverlays.ts @@ -10,6 +10,22 @@ export interface OverlayHandle { // 버스 ID별 이전 위치와 회전 값을 저장 const previousBusPositions = new Map(); +// 버스 ID별 오버레이와 DOM 요소를 캐시 +const busOverlayCache = new Map(); + +// 모든 버스 오버레이 정리 (페이지 이동 시 호출) +export const clearAllBusOverlays = () => { + for (const [busId, cached] of busOverlayCache.entries()) { + (cached.overlay as { setMap: (m: unknown) => void }).setMap(null); + busOverlayCache.delete(busId); + previousBusPositions.delete(busId); + } +}; + // 두 좌표 사이의 각도 계산 (북쪽 기준, 시계방향) const calculateAngle = ( prevLat: number, @@ -30,6 +46,26 @@ const calculateAngle = ( return angleDeg; }; +// 최단 경로로 회전하도록 각도 정규화 (0 ~ 360 범위) +const normalizeRotation = (newAngle: number, prevAngle: number): number => { + // 이전 각도를 0 ~ 360 범위로 정규화 + const normalizedPrev = ((prevAngle % 360) + 360) % 360; + + // 새 각도도 0 ~ 360 범위로 정규화 + const normalizedNew = ((newAngle % 360) + 360) % 360; + + // 각도 차이 계산 + let diff = normalizedNew - normalizedPrev; + + // 최단 경로로 차이 조정 + if (diff > 180) diff -= 360; + if (diff < -180) diff += 360; + + // 정규화된 이전 각도에서 최단 거리만큼 회전 후 0~360 범위로 + const result = normalizedPrev + diff; + return ((result % 360) + 360) % 360; +}; + // Helper to create Lucide icon as SVG element const createIconSVG = (iconType: "mapPin" | "bus", showCircle = false) => { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); @@ -232,9 +268,14 @@ export const createBusOverlays = ( // 현재 활성 버스 ID 집합 const activeBusIds = new Set(buses.map(bus => bus.shuttleId || `${bus.lat}-${bus.lng}`)); - // 비활성 버스 ID를 previousBusPositions에서 제거 (메모리 누수 방지) - for (const busId of previousBusPositions.keys()) { + // 비활성 버스 ID를 캐시와 previousBusPositions에서 제거 (메모리 누수 방지) + for (const busId of busOverlayCache.keys()) { if (!activeBusIds.has(busId)) { + const cached = busOverlayCache.get(busId); + if (cached) { + (cached.overlay as { setMap: (m: unknown) => void }).setMap(null); + } + busOverlayCache.delete(busId); previousBusPositions.delete(busId); } } @@ -253,12 +294,14 @@ export const createBusOverlays = ( previousData.lat !== currentPosition.lat || previousData.lng !== currentPosition.lng ) { - rotation = calculateAngle( + const newAngle = calculateAngle( previousData.lat, previousData.lng, currentPosition.lat, currentPosition.lng ); + // 최단 경로로 회전하도록 정규화 + rotation = normalizeRotation(newAngle, previousData.rotation); } else { // 위치가 변경되지 않았으면 이전 회전 값 재사용 rotation = previousData.rotation; @@ -272,41 +315,60 @@ export const createBusOverlays = ( rotation }); - const busDiv = document.createElement("div"); - busDiv.style.width = "18px"; - busDiv.style.height = "34px"; - busDiv.style.display = "flex"; - busDiv.style.alignItems = "center"; - busDiv.style.justifyContent = "center"; - busDiv.style.cursor = "pointer"; - busDiv.setAttribute("role", "img"); - busDiv.setAttribute("aria-label", bus.shuttleId || "bus"); - - const img = document.createElement("img"); - img.src = busIconSvg; - img.alt = "버스"; - img.style.width = "18px"; - img.style.height = "34px"; - // 회전 적용 - img.style.transform = `rotate(${rotation}deg)`; - img.style.transformOrigin = "center center"; - img.style.transition = "transform 0.3s ease-out"; - busDiv.appendChild(img); - - const busPosition = new window.kakao.maps.LatLng(bus.lat, bus.lng); - const busOverlay = new window.kakao.maps.CustomOverlay({ - position: busPosition, - content: busDiv, - yAnchor: 1, - }); - (busOverlay as unknown as { setMap: (m: unknown) => void }).setMap(map); + // 캐시된 오버레이가 있으면 재사용 + let cached = busOverlayCache.get(busId); + + if (cached) { + // 기존 오버레이 업데이트 + const busPosition = new window.kakao.maps.LatLng(bus.lat, bus.lng); + (cached.overlay as { setPosition: (pos: unknown) => void }).setPosition?.(busPosition); + + // 회전 업데이트 (CSS transition이 적용됨) + cached.img.style.transform = `rotate(${rotation}deg)`; + } else { + // 새 오버레이 생성 + const busDiv = document.createElement("div"); + busDiv.style.width = "18px"; + busDiv.style.height = "34px"; + busDiv.style.display = "flex"; + busDiv.style.alignItems = "center"; + busDiv.style.justifyContent = "center"; + busDiv.style.cursor = "pointer"; + busDiv.setAttribute("role", "img"); + busDiv.setAttribute("aria-label", bus.shuttleId || "bus"); + + const img = document.createElement("img"); + img.src = busIconSvg; + img.alt = "버스"; + img.style.width = "18px"; + img.style.height = "34px"; + img.style.transform = `rotate(${rotation}deg)`; + img.style.transformOrigin = "center center"; + img.style.transition = "transform 0.3s ease-out"; + busDiv.appendChild(img); + + const busPosition = new window.kakao.maps.LatLng(bus.lat, bus.lng); + const busOverlay = new window.kakao.maps.CustomOverlay({ + position: busPosition, + content: busDiv, + yAnchor: 1, + }); + (busOverlay as unknown as { setMap: (m: unknown) => void }).setMap(map); + + // 캐시에 저장 + cached = { overlay: busOverlay, img, div: busDiv }; + busOverlayCache.set(busId, cached); + } return { setMap: (m: unknown) => { - (busOverlay as unknown as { setMap: (m: unknown) => void }).setMap(m); + if (cached) { + (cached.overlay as { setMap: (m: unknown) => void }).setMap(m); + } }, cleanup: () => { - // 오버레이 제거 시 previousBusPositions에서도 제거 + // 오버레이 제거 시 캐시와 previousBusPositions에서도 제거 + busOverlayCache.delete(busId); previousBusPositions.delete(busId); }, }; From d46172bc35593d916574eb77d7e7064b06c8f201 Mon Sep 17 00:00:00 2001 From: Kwon DaeGeun Date: Sat, 15 Nov 2025 13:39:58 +0900 Subject: [PATCH 2/8] =?UTF-8?q?refactor:=20=EB=A7=B5=20=EC=9D=B8=EC=8A=A4?= =?UTF-8?q?=ED=84=B4=EC=8A=A4=20=EB=B3=80=EA=B2=BD=20=EB=8C=80=EC=9D=91=20?= =?UTF-8?q?=EB=B0=8F=20cleanup=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/mapOverlays.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/utils/mapOverlays.ts b/src/utils/mapOverlays.ts index f9ff0db..001017a 100644 --- a/src/utils/mapOverlays.ts +++ b/src/utils/mapOverlays.ts @@ -323,6 +323,9 @@ export const createBusOverlays = ( const busPosition = new window.kakao.maps.LatLng(bus.lat, bus.lng); (cached.overlay as { setPosition: (pos: unknown) => void }).setPosition?.(busPosition); + // map 인스턴스가 변경되었을 수 있으므로 항상 setMap 호출 + (cached.overlay as { setMap: (m: unknown) => void }).setMap(map); + // 회전 업데이트 (CSS transition이 적용됨) cached.img.style.transform = `rotate(${rotation}deg)`; } else { @@ -367,7 +370,11 @@ export const createBusOverlays = ( } }, cleanup: () => { - // 오버레이 제거 시 캐시와 previousBusPositions에서도 제거 + // 오버레이를 지도에서 제거 + if (cached) { + (cached.overlay as { setMap: (m: unknown) => void }).setMap(null); + } + // 캐시와 previousBusPositions에서도 제거 busOverlayCache.delete(busId); previousBusPositions.delete(busId); }, From 5247f000e228b8a8875711ec7db37403249fc664 Mon Sep 17 00:00:00 2001 From: Kwon DaeGeun Date: Sat, 15 Nov 2025 13:55:16 +0900 Subject: [PATCH 3/8] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=ED=83=80=EC=9E=85=20=EC=BA=90=EC=8A=A4=ED=8C=85=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useMapOverlays.ts | 20 ++++++++++++-------- src/types/kakao.d.ts | 17 ++++++++++++----- src/utils/mapOverlays.ts | 25 +++++++++++++------------ 3 files changed, 37 insertions(+), 25 deletions(-) diff --git a/src/hooks/useMapOverlays.ts b/src/hooks/useMapOverlays.ts index c95f141..db2e316 100644 --- a/src/hooks/useMapOverlays.ts +++ b/src/hooks/useMapOverlays.ts @@ -1,6 +1,7 @@ import { useEffect } from "react"; import type { Bus } from "../data/bus"; import type { BusStop } from "../data/busStops"; +import type { KakaoMap } from "../types/kakao"; import { clearAllBusOverlays, createBusOverlays, @@ -8,7 +9,7 @@ import { } from "../utils/mapOverlays"; export const useMapOverlays = ( - map: unknown, + map: KakaoMap | null, busStops: BusStop[], buses: Bus[], selectedStopName?: string, @@ -36,16 +37,19 @@ export const useMapOverlays = ( }; }, [map, busStops, selectedStopName, onStopClick]); - // 버스 오버레이 관리 (업데이트만 수행, cleanup 없음) - useEffect(() => { - if (!map) return; - createBusOverlays(map, buses); - }, [map, buses]); - - // 컴포넌트 언마운트 시에만 모든 버스 오버레이 정리 + // map 변경 시 이전 캐시 정리 (cleanup에서만 실행) + // map이 변경될 때 이전 캐시를 정리하기 위해 의도적으로 의존성에 포함 + // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { + // 컴포넌트 언마운트 또는 map 변경 시 정리 return () => { clearAllBusOverlays(); }; }, []); + + // 버스 오버레이 업데이트 (캐시 유지) + useEffect(() => { + if (!map) return; + createBusOverlays(map, buses); + }, [map, buses]); }; diff --git a/src/types/kakao.d.ts b/src/types/kakao.d.ts index 33def80..ed07597 100644 --- a/src/types/kakao.d.ts +++ b/src/types/kakao.d.ts @@ -1,11 +1,11 @@ -export {}; - -interface KakaoLatLng { +export interface KakaoLatLng { lat: number; lng: number; + getLat(): number; + getLng(): number; } -interface KakaoMap { +export interface KakaoMap { setCenter: (pos: KakaoLatLng) => void; setMinLevel: (level: number) => void; setMaxLevel: (level: number) => void; @@ -15,9 +15,16 @@ interface KakaoMap { | { getLat: () => number; getLng: () => number }; } -interface KakaoOverlay { +export interface KakaoOverlay { setMap: (map: KakaoMap | null) => void; setPosition: (position: KakaoLatLng) => void; + getPosition(): KakaoLatLng; + setContent(content: HTMLElement | string): void; + getContent(): HTMLElement; + setVisible(visible: boolean): void; + getVisible(): boolean; + setZIndex(zIndex: number): void; + getZIndex(): number; } declare global { diff --git a/src/utils/mapOverlays.ts b/src/utils/mapOverlays.ts index 001017a..a00cc98 100644 --- a/src/utils/mapOverlays.ts +++ b/src/utils/mapOverlays.ts @@ -1,9 +1,10 @@ import busIconSvg from "../assets/busIcon.svg"; import type { Bus } from "../data/bus"; import type { BusStop } from "../data/busStops"; +import type { KakaoMap, KakaoOverlay } from "../types/kakao"; export interface OverlayHandle { - setMap: (map: unknown) => void; + setMap: (map: KakaoMap | null) => void; cleanup?: () => void; } @@ -12,7 +13,7 @@ const previousBusPositions = new Map(); @@ -20,7 +21,7 @@ const busOverlayCache = new Map { for (const [busId, cached] of busOverlayCache.entries()) { - (cached.overlay as { setMap: (m: unknown) => void }).setMap(null); + cached.overlay.setMap(null); busOverlayCache.delete(busId); previousBusPositions.delete(busId); } @@ -188,7 +189,7 @@ const createIconSVG = (iconType: "mapPin" | "bus", showCircle = false) => { }; export const createBusStopOverlays = ( - map: unknown, + map: KakaoMap, busStops: BusStop[], selectedStopName?: string, onStopClick?: (stop: BusStop) => void @@ -260,7 +261,7 @@ export const createBusStopOverlays = ( }; export const createBusOverlays = ( - map: unknown, + map: KakaoMap, buses: Bus[] ): OverlayHandle[] => { if (!map || typeof window === "undefined" || !window.kakao?.maps) return []; @@ -273,7 +274,7 @@ export const createBusOverlays = ( if (!activeBusIds.has(busId)) { const cached = busOverlayCache.get(busId); if (cached) { - (cached.overlay as { setMap: (m: unknown) => void }).setMap(null); + cached.overlay.setMap(null); } busOverlayCache.delete(busId); previousBusPositions.delete(busId); @@ -321,10 +322,10 @@ export const createBusOverlays = ( if (cached) { // 기존 오버레이 업데이트 const busPosition = new window.kakao.maps.LatLng(bus.lat, bus.lng); - (cached.overlay as { setPosition: (pos: unknown) => void }).setPosition?.(busPosition); + cached.overlay.setPosition(busPosition); // map 인스턴스가 변경되었을 수 있으므로 항상 setMap 호출 - (cached.overlay as { setMap: (m: unknown) => void }).setMap(map); + cached.overlay.setMap(map); // 회전 업데이트 (CSS transition이 적용됨) cached.img.style.transform = `rotate(${rotation}deg)`; @@ -356,7 +357,7 @@ export const createBusOverlays = ( content: busDiv, yAnchor: 1, }); - (busOverlay as unknown as { setMap: (m: unknown) => void }).setMap(map); + busOverlay.setMap(map); // 캐시에 저장 cached = { overlay: busOverlay, img, div: busDiv }; @@ -364,15 +365,15 @@ export const createBusOverlays = ( } return { - setMap: (m: unknown) => { + setMap: (m) => { if (cached) { - (cached.overlay as { setMap: (m: unknown) => void }).setMap(m); + cached.overlay.setMap(m); } }, cleanup: () => { // 오버레이를 지도에서 제거 if (cached) { - (cached.overlay as { setMap: (m: unknown) => void }).setMap(null); + cached.overlay.setMap(null); } // 캐시와 previousBusPositions에서도 제거 busOverlayCache.delete(busId); From de44a89eb739f4623766a79bc0470ff365048972 Mon Sep 17 00:00:00 2001 From: Kwon DaeGeun Date: Sat, 15 Nov 2025 13:58:39 +0900 Subject: [PATCH 4/8] =?UTF-8?q?refactor:=20=EB=A7=A4=EC=A7=81=20=EB=84=98?= =?UTF-8?q?=EB=B2=84=20=EC=83=81=EC=88=98=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/mapOverlays.ts | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/src/utils/mapOverlays.ts b/src/utils/mapOverlays.ts index a00cc98..6c88483 100644 --- a/src/utils/mapOverlays.ts +++ b/src/utils/mapOverlays.ts @@ -3,6 +3,20 @@ import type { Bus } from "../data/bus"; import type { BusStop } from "../data/busStops"; import type { KakaoMap, KakaoOverlay } from "../types/kakao"; +// 상수 정의 +const BUS_ICON_DIMENSIONS = { + width: 18, + height: 34, +} as const; + +const BUS_STOP_ICON_DIMENSIONS = { + width: 48, + height: 56, +} as const; + +const ANIMATION_DURATION = '0.3s'; +const ROTATION_EASING = 'ease-out'; + export interface OverlayHandle { setMap: (map: KakaoMap | null) => void; cleanup?: () => void; @@ -70,8 +84,8 @@ const normalizeRotation = (newAngle: number, prevAngle: number): number => { // Helper to create Lucide icon as SVG element const createIconSVG = (iconType: "mapPin" | "bus", showCircle = false) => { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - svg.setAttribute("width", "48"); - svg.setAttribute("height", "56"); + svg.setAttribute("width", String(BUS_STOP_ICON_DIMENSIONS.width)); + svg.setAttribute("height", String(BUS_STOP_ICON_DIMENSIONS.height)); svg.setAttribute("viewBox", "0 0 24 40"); svg.setAttribute("fill", "none"); svg.setAttribute("stroke", showCircle ? "#dc2626" : "#2563eb"); // red-600 or blue-600 @@ -200,8 +214,8 @@ export const createBusStopOverlays = ( const isSelected = selectedStopName === stop.name; const busIconDiv = document.createElement("div"); - busIconDiv.style.width = "48px"; - busIconDiv.style.height = "56px"; + busIconDiv.style.width = `${BUS_STOP_ICON_DIMENSIONS.width}px`; + busIconDiv.style.height = `${BUS_STOP_ICON_DIMENSIONS.height}px`; busIconDiv.style.display = "flex"; busIconDiv.style.alignItems = "center"; busIconDiv.style.justifyContent = "center"; @@ -332,8 +346,8 @@ export const createBusOverlays = ( } else { // 새 오버레이 생성 const busDiv = document.createElement("div"); - busDiv.style.width = "18px"; - busDiv.style.height = "34px"; + busDiv.style.width = `${BUS_ICON_DIMENSIONS.width}px`; + busDiv.style.height = `${BUS_ICON_DIMENSIONS.height}px`; busDiv.style.display = "flex"; busDiv.style.alignItems = "center"; busDiv.style.justifyContent = "center"; @@ -344,11 +358,11 @@ export const createBusOverlays = ( const img = document.createElement("img"); img.src = busIconSvg; img.alt = "버스"; - img.style.width = "18px"; - img.style.height = "34px"; + img.style.width = `${BUS_ICON_DIMENSIONS.width}px`; + img.style.height = `${BUS_ICON_DIMENSIONS.height}px`; img.style.transform = `rotate(${rotation}deg)`; img.style.transformOrigin = "center center"; - img.style.transition = "transform 0.3s ease-out"; + img.style.transition = `transform ${ANIMATION_DURATION} ${ROTATION_EASING}`; busDiv.appendChild(img); const busPosition = new window.kakao.maps.LatLng(bus.lat, bus.lng); From e14ae6bad1870969ce25278ed316689d37e41b5a Mon Sep 17 00:00:00 2001 From: Kwon DaeGeun Date: Sat, 15 Nov 2025 14:00:39 +0900 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20=ED=9B=85=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=20=EB=B3=80=EA=B2=BD=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useKakaoMap.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/hooks/useKakaoMap.ts b/src/hooks/useKakaoMap.ts index 638eb10..01e3b2f 100644 --- a/src/hooks/useKakaoMap.ts +++ b/src/hooks/useKakaoMap.ts @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import type { KakaoMap } from "../types/kakao"; interface UseKakaoMapOptions { mapId: string; @@ -10,7 +11,7 @@ interface UseKakaoMapOptions { } export const useKakaoMap = ({ mapId, toast }: UseKakaoMapOptions) => { - const [mapInstance, setMapInstance] = useState(null); + const [mapInstance, setMapInstance] = useState(null); useEffect(() => { const kakaoApiKey = import.meta.env.VITE_KAKAO_MAP_API_KEY; From b45ec65d9b4c4684797195ae46b616ca06af86f3 Mon Sep 17 00:00:00 2001 From: Kwon DaeGeun Date: Sat, 15 Nov 2025 14:07:31 +0900 Subject: [PATCH 6/8] =?UTF-8?q?refactor:=20map=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EC=9D=B4=20=EC=95=84=EB=8B=8C=20=EC=96=B8=EB=A7=88=EC=9A=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=8B=9C=EB=A1=9C=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useMapOverlays.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/hooks/useMapOverlays.ts b/src/hooks/useMapOverlays.ts index db2e316..1fcdf4a 100644 --- a/src/hooks/useMapOverlays.ts +++ b/src/hooks/useMapOverlays.ts @@ -37,9 +37,6 @@ export const useMapOverlays = ( }; }, [map, busStops, selectedStopName, onStopClick]); - // map 변경 시 이전 캐시 정리 (cleanup에서만 실행) - // map이 변경될 때 이전 캐시를 정리하기 위해 의도적으로 의존성에 포함 - // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { // 컴포넌트 언마운트 또는 map 변경 시 정리 return () => { From 51b0129e980e926807998ec7970474315f261f3f Mon Sep 17 00:00:00 2001 From: Kwon DaeGeun Date: Sat, 15 Nov 2025 14:09:45 +0900 Subject: [PATCH 7/8] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=B2=84=ED=8A=BC=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ui/button.tsx | 58 ------------------------------------ 1 file changed, 58 deletions(-) delete mode 100644 src/components/ui/button.tsx diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx deleted file mode 100644 index 26f8f64..0000000 --- a/src/components/ui/button.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Slot } from "@radix-ui/react-slot"; -import { cva, type VariantProps } from "class-variance-authority"; -import type * as React from "react"; - -import { cn } from "@/lib/utils"; - -const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", - { - variants: { - variant: { - default: - "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", - destructive: - "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", - outline: - "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", - secondary: - "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", - ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: "h-9 px-4 py-2 has-[>svg]:px-3", - sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", - lg: "h-10 rounded-md px-6 has-[>svg]:px-4", - icon: "size-9", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -); - -function Button({ - className, - variant, - size, - asChild = false, - ...props -}: React.ComponentProps<"button"> & - VariantProps & { - asChild?: boolean; - }) { - const Comp = asChild ? Slot : "button"; - - return ( - - ); -} - -export { Button, buttonVariants }; From 7d31defa3379a52f533ac9a07da971cda215a9bc Mon Sep 17 00:00:00 2001 From: Kwon DaeGeun Date: Sat, 15 Nov 2025 14:11:07 +0900 Subject: [PATCH 8/8] =?UTF-8?q?style:=20lint=20check=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 49 ++++++++++++----------- src/api/bus.ts | 4 +- src/components/Bubble.tsx | 15 ++++--- src/components/SettingsPanel.tsx | 4 +- src/contexts/LanguageContext.tsx | 12 ++++-- src/hooks/useBusSelection.ts | 6 ++- src/hooks/useMapOverlays.ts | 7 +++- src/utils/mapOverlays.ts | 68 ++++++++++++++++++-------------- 8 files changed, 96 insertions(+), 69 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 61c4850..85f60e5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -39,9 +39,10 @@ function App() { const langId = useId(); const [language, setLanguage] = useState(() => { try { - const stored = typeof window !== "undefined" && window.localStorage - ? localStorage.getItem("wtb:lang") - : null; + const stored = + typeof window !== "undefined" && window.localStorage + ? localStorage.getItem("wtb:lang") + : null; return stored === "en" ? "en" : "ko"; } catch { return "ko"; @@ -67,10 +68,7 @@ function App() { return ( - + { - // Only show toast once to avoid spamming on refetch failures - if (!errorShownRef.current) { - toast({ - title: "버스 위치 조회 오류", - description: message, - variant: "destructive", - }); - errorShownRef.current = true; - // Reset after 30 seconds to allow showing error again if it persists - setTimeout(() => { - errorShownRef.current = false; - }, 30000); - } - }, [toast]); + const handleBusLocationError = useCallback( + (message: string) => { + // Only show toast once to avoid spamming on refetch failures + if (!errorShownRef.current) { + toast({ + title: "버스 위치 조회 오류", + description: message, + variant: "destructive", + }); + errorShownRef.current = true; + // Reset after 30 seconds to allow showing error again if it persists + setTimeout(() => { + errorShownRef.current = false; + }, 30000); + } + }, + [toast] + ); const { data: buses = [] } = useBusLocations(handleBusLocationError); const handleBusNumberSelect = useBusSelection(buses, setBubbleStop); @@ -167,8 +168,8 @@ function AppContent({ /> ) : null} - diff --git a/src/api/bus.ts b/src/api/bus.ts index 7903eb9..9b198d5 100644 --- a/src/api/bus.ts +++ b/src/api/bus.ts @@ -4,9 +4,7 @@ import { apiGet } from "../lib/api"; import { API_ENDPOINTS } from "../lib/endpoints"; import { handleApiError } from "../lib/error"; -export const useBusLocations = ( - onError?: (message: string) => void -) => { +export const useBusLocations = (onError?: (message: string) => void) => { return useQuery({ queryKey: ["busLocations"], queryFn: async () => { diff --git a/src/components/Bubble.tsx b/src/components/Bubble.tsx index adbdfd3..4d10bac 100644 --- a/src/components/Bubble.tsx +++ b/src/components/Bubble.tsx @@ -57,7 +57,7 @@ export default function Bubble({ stop, onClose }: Props) { document.body.appendChild(el); const rawName = String(stop.name); - + // Check if it's a bus key (starts with "bus.") let displayName: string; if (rawName.startsWith("bus.")) { @@ -67,15 +67,18 @@ export default function Bubble({ stop, onClose }: Props) { // It's a bus stop name const translationKey = `busStop.${rawName}`; const translatedName = t(translationKey); - + // If translation key is returned as-is, use the raw name instead - const displayBaseName = translatedName === translationKey ? rawName : translatedName; + const displayBaseName = + translatedName === translationKey + ? rawName + : translatedName; displayName = displayBaseName; if (DISPLAY_NAME_MAP[rawName]) { - const directionKey = DISPLAY_NAME_MAP[rawName].includes( - "죽전역" - ) + const directionKey = DISPLAY_NAME_MAP[ + rawName + ].includes("죽전역") ? "direction.toJukjeon" : "direction.toDKU"; displayName = `${displayBaseName} (${t(directionKey)})`; diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 91dd319..a5994b1 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -83,7 +83,9 @@ const SettingsPanel: React.FC = ({