From 34afff28de1f74d4300c6d5b26db9a5b311a6c19 Mon Sep 17 00:00:00 2001 From: Kwon DaeGeun Date: Thu, 13 Nov 2025 20:00:15 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=EB=B2=84=EC=8A=A4=20=EC=A0=95?= =?UTF-8?q?=EB=A5=98=EC=9E=A5=20=EB=A7=B5=ED=95=80=EC=97=90=20=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 6 +++++- src/components/MapContainer.tsx | 5 ++++- src/hooks/useMapOverlays.ts | 7 ++++--- src/utils/mapOverlays.ts | 14 ++++++++++++-- 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 0c1339b..f41e8da 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -139,7 +139,11 @@ function AppContent({ /> ) : null} - + setBubbleStop(stop)} + > setBubbleStop(undefined)} diff --git a/src/components/MapContainer.tsx b/src/components/MapContainer.tsx index dd98d42..289626f 100644 --- a/src/components/MapContainer.tsx +++ b/src/components/MapContainer.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import { useBusLocations } from "../api/bus"; +import type { BusStop } from "../data/busStops"; import { busStops } from "../data/busStops"; import { useKakaoMap } from "../hooks/useKakaoMap"; import { useMapEventHandlers } from "../hooks/useMapEventHandlers"; @@ -10,18 +11,20 @@ interface MapContainerProps { mapId: string; children?: ReactNode; selectedStopName?: string; + onStopClick?: (stop: BusStop) => void; } export const MapContainer = ({ mapId, children, selectedStopName, + onStopClick, }: MapContainerProps) => { const { toast } = useToast(); const map = useKakaoMap({ mapId, toast }); const { data: buses = [] } = useBusLocations(); - useMapOverlays(map, [...busStops], buses, selectedStopName); + useMapOverlays(map, [...busStops], buses, selectedStopName, onStopClick); useMapEventHandlers(mapId); return ( diff --git a/src/hooks/useMapOverlays.ts b/src/hooks/useMapOverlays.ts index 5304d58..67c5ee0 100644 --- a/src/hooks/useMapOverlays.ts +++ b/src/hooks/useMapOverlays.ts @@ -11,13 +11,14 @@ export const useMapOverlays = ( map: unknown, busStops: BusStop[], buses: Bus[], - selectedStopName?: string + selectedStopName?: string, + onStopClick?: (stop: BusStop) => void ) => { useEffect(() => { if (!map) return; const overlays: OverlayHandle[] = [ - ...createBusStopOverlays(map, busStops, selectedStopName), + ...createBusStopOverlays(map, busStops, selectedStopName, onStopClick), ...createBusOverlays(map, buses), ]; @@ -30,5 +31,5 @@ export const useMapOverlays = ( } }); }; - }, [map, busStops, buses, selectedStopName]); + }, [map, busStops, buses, selectedStopName, onStopClick]); }; diff --git a/src/utils/mapOverlays.ts b/src/utils/mapOverlays.ts index 85a8a48..0fb8296 100644 --- a/src/utils/mapOverlays.ts +++ b/src/utils/mapOverlays.ts @@ -130,7 +130,8 @@ const createIconSVG = (iconType: "mapPin" | "bus", showCircle = false) => { export const createBusStopOverlays = ( map: unknown, busStops: BusStop[], - selectedStopName?: string + selectedStopName?: string, + onStopClick?: (stop: BusStop) => void ): OverlayHandle[] => { if (!map || typeof window === "undefined" || !window.kakao?.maps) return []; @@ -143,12 +144,21 @@ export const createBusStopOverlays = ( busIconDiv.style.display = "flex"; busIconDiv.style.alignItems = "center"; busIconDiv.style.justifyContent = "center"; - busIconDiv.setAttribute("role", "img"); + busIconDiv.style.cursor = "pointer"; + busIconDiv.setAttribute("role", "button"); busIconDiv.setAttribute("aria-label", `정류장: ${stop.name}`); + busIconDiv.setAttribute("tabindex", "0"); const iconSVG = createIconSVG("mapPin", isSelected); busIconDiv.appendChild(iconSVG); + // 클릭 이벤트 추가 + busIconDiv.addEventListener("click", () => { + if (onStopClick) { + onStopClick(stop); + } + }); + const markerPosition = new window.kakao.maps.LatLng(stop.lat, stop.lng); const overlay = new window.kakao.maps.CustomOverlay({ position: markerPosition, From 06b0ec001c72310704bd28c87aef0d86d4ba6527 Mon Sep 17 00:00:00 2001 From: Kwon DaeGeun Date: Thu, 13 Nov 2025 20:11:32 +0900 Subject: [PATCH 2/5] =?UTF-8?q?refactor:=20=ED=82=A4=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/mapOverlays.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/utils/mapOverlays.ts b/src/utils/mapOverlays.ts index 0fb8296..fb03249 100644 --- a/src/utils/mapOverlays.ts +++ b/src/utils/mapOverlays.ts @@ -159,6 +159,16 @@ export const createBusStopOverlays = ( } }); + // 키보드 접근성 + busIconDiv.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + if (onStopClick) { + onStopClick(stop); + } + } + }); + const markerPosition = new window.kakao.maps.LatLng(stop.lat, stop.lng); const overlay = new window.kakao.maps.CustomOverlay({ position: markerPosition, From 369d8cf234c2f6254f5919db400ab426819acc8c Mon Sep 17 00:00:00 2001 From: Kwon DaeGeun Date: Thu, 13 Nov 2025 20:21:48 +0900 Subject: [PATCH 3/5] =?UTF-8?q?refactor:=20=EB=A9=94=EB=AA=A8=EB=A6=AC=20?= =?UTF-8?q?=EB=88=84=EC=88=98=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 58 +++++++++++++++++++++++++++---------- src/api/bus.ts | 18 ++++++++++-- src/hooks/useMapOverlays.ts | 5 ++++ src/utils/mapOverlays.ts | 33 ++++++++++++++++----- 4 files changed, 89 insertions(+), 25 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index f41e8da..61c4850 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,12 +5,14 @@ import { useCallback, useEffect, useId, + useRef, useState, } from "react"; import Bubble from "./components/Bubble"; import { BusStopsPanel } from "./components/BusStopsPanel"; import { MapContainer } from "./components/MapContainer"; import { SettingsButton } from "./components/SettingsButton"; +import { ToastProvider, useToast } from "./components/ui/use-toast"; const SettingsPanel = lazy(() => import("./components/SettingsPanel")); @@ -69,19 +71,21 @@ function App() { language={language} setLanguage={setLanguage} > - - {import.meta.env.DEV && ( - - )} + + + {import.meta.env.DEV && ( + + )} + ); @@ -112,8 +116,32 @@ function AppContent({ bubbleStop, setBubbleStop, }: AppContentProps) { - const { data: buses = [] } = useBusLocations(); + const { toast } = useToast(); + const errorShownRef = useRef(false); + + 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); + const handleStopClick = useCallback( + (stop: { lat: number; lng: number; name: string }) => + setBubbleStop(stop), + [setBubbleStop] + ); return (
setBubbleStop(stop)} + onStopClick={handleStopClick} > { +export const useBusLocations = ( + onError?: (message: string) => void +) => { return useQuery({ queryKey: ["busLocations"], queryFn: async () => { - const data = await apiGet(API_ENDPOINTS.BUS.LOCATION); - return Array.isArray(data) ? data : []; + try { + const data = await apiGet(API_ENDPOINTS.BUS.LOCATION); + return Array.isArray(data) ? data : []; + } catch (error) { + const errorMessage = await handleApiError(error); + if (onError) { + onError(errorMessage); + } + return []; + } }, refetchInterval: 5000, refetchIntervalInBackground: true, + retry: 2, }); }; diff --git a/src/hooks/useMapOverlays.ts b/src/hooks/useMapOverlays.ts index 67c5ee0..01b7b40 100644 --- a/src/hooks/useMapOverlays.ts +++ b/src/hooks/useMapOverlays.ts @@ -29,6 +29,11 @@ export const useMapOverlays = ( } catch { // ignore cleanup errors } + try { + overlay.cleanup?.(); + } catch { + // ignore cleanup errors + } }); }; }, [map, busStops, buses, selectedStopName, onStopClick]); diff --git a/src/utils/mapOverlays.ts b/src/utils/mapOverlays.ts index fb03249..e2b9c76 100644 --- a/src/utils/mapOverlays.ts +++ b/src/utils/mapOverlays.ts @@ -4,6 +4,7 @@ import type { BusStop } from "../data/busStops"; export interface OverlayHandle { setMap: (map: unknown) => void; + cleanup?: () => void; } // Helper to create Lucide icon as SVG element @@ -152,22 +153,31 @@ export const createBusStopOverlays = ( const iconSVG = createIconSVG("mapPin", isSelected); busIconDiv.appendChild(iconSVG); - // 클릭 이벤트 추가 - busIconDiv.addEventListener("click", () => { + // Named click handler for proper cleanup + const handleClick = (e: MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); if (onStopClick) { onStopClick(stop); } - }); + }; - // 키보드 접근성 - busIconDiv.addEventListener("keydown", (e) => { + // Named keydown handler for proper cleanup + const handleKeydown = (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { + e.stopPropagation(); e.preventDefault(); if (onStopClick) { onStopClick(stop); } } - }); + }; + + // 클릭 이벤트 추가 + busIconDiv.addEventListener("click", handleClick); + + // 키보드 접근성 + busIconDiv.addEventListener("keydown", handleKeydown); const markerPosition = new window.kakao.maps.LatLng(stop.lat, stop.lng); const overlay = new window.kakao.maps.CustomOverlay({ @@ -177,7 +187,16 @@ export const createBusStopOverlays = ( }); (overlay as unknown as { setMap: (m: unknown) => void }).setMap(map); - return overlay as OverlayHandle; + // Return overlay with cleanup method + return { + setMap: (m: unknown) => { + (overlay as unknown as { setMap: (m: unknown) => void }).setMap(m); + }, + cleanup: () => { + busIconDiv.removeEventListener("click", handleClick); + busIconDiv.removeEventListener("keydown", handleKeydown); + }, + }; }); }; From 89b84a050b64aa79802e4765d1c35a197560e82c Mon Sep 17 00:00:00 2001 From: Kwon DaeGeun Date: Thu, 13 Nov 2025 20:36:55 +0900 Subject: [PATCH 4/5] =?UTF-8?q?refactor:=20=EB=A6=AC=EC=95=A1=ED=8A=B8=20?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=ED=8B=B0=EB=B8=8C=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useKakaoMap.ts | 60 -------------------------------- src/hooks/useMapEventHandlers.ts | 20 +---------- src/types/kakao.d.ts | 6 ---- 3 files changed, 1 insertion(+), 85 deletions(-) diff --git a/src/hooks/useKakaoMap.ts b/src/hooks/useKakaoMap.ts index bac59bf..638eb10 100644 --- a/src/hooks/useKakaoMap.ts +++ b/src/hooks/useKakaoMap.ts @@ -49,66 +49,6 @@ export const useKakaoMap = ({ mapId, toast }: UseKakaoMapOptions) => { window.map = map; setMapInstance(map); - - // WebView hooks - if (typeof window.__moveFromRN !== "function") { - window.__moveFromRN = (lat: number, lng: number) => { - try { - if ( - window.map && - typeof window.map.setCenter === "function" && - typeof window.kakao !== "undefined" - ) { - window.map.setCenter( - new window.kakao.maps.LatLng(lat, lng) - ); - } else { - window.__pendingMove = { lat, lng }; - } - } catch { - // ignore - } - }; - } - - if ( - window.__pendingMove && - Number.isFinite(window.__pendingMove.lat) && - Number.isFinite(window.__pendingMove.lng) - ) { - try { - window.map?.setCenter( - new window.kakao.maps.LatLng( - window.__pendingMove.lat, - window.__pendingMove.lng - ) - ); - window.__pendingMove = null; - } catch { - // ignore - } - } - - if (typeof window.__onMapReady === "function") { - try { - window.__onMapReady(); - } catch { - // ignore - } - } - - if ( - window.ReactNativeWebView && - typeof window.ReactNativeWebView.postMessage === "function" - ) { - try { - window.ReactNativeWebView.postMessage( - JSON.stringify({ type: "MAP_READY" }) - ); - } catch { - // ignore - } - } }; const loadKakaoMapScript = () => { diff --git a/src/hooks/useMapEventHandlers.ts b/src/hooks/useMapEventHandlers.ts index 3012aec..953d5c7 100644 --- a/src/hooks/useMapEventHandlers.ts +++ b/src/hooks/useMapEventHandlers.ts @@ -2,7 +2,6 @@ import { useEffect } from "react"; export const useMapEventHandlers = (mapId: string) => { useEffect(() => { - const isRN = !!window.ReactNativeWebView; const selfOrigin = location.protocol.startsWith("http") ? location.origin : undefined; @@ -13,12 +12,7 @@ export const useMapEventHandlers = (mapId: string) => { ]); const messageHandler = (event: MessageEvent) => { - // RN(WebView)에서만 origin === "null" 허용 - if ( - !(isRN && event.origin === "null") && - !allowedOrigins.has(event.origin) - ) - return; + if (!allowedOrigins.has(event.origin)) return; }; const containerEl = document.getElementById(mapId); @@ -27,27 +21,15 @@ export const useMapEventHandlers = (mapId: string) => { if (containerEl && t && containerEl.contains(t) && e.cancelable) e.preventDefault(); }; - const touchMoveHandler = (e: TouchEvent) => { - if ( - containerEl && - e.target && - containerEl.contains(e.target as Node) - ) - e.preventDefault(); - }; window.addEventListener("message", messageHandler); document.addEventListener("gesturestart", gestureHandler, { passive: false, }); - containerEl?.addEventListener("touchmove", touchMoveHandler, { - passive: false, - }); return () => { window.removeEventListener("message", messageHandler); document.removeEventListener("gesturestart", gestureHandler); - containerEl?.removeEventListener("touchmove", touchMoveHandler); }; }, [mapId]); }; diff --git a/src/types/kakao.d.ts b/src/types/kakao.d.ts index 8b43214..236c77c 100644 --- a/src/types/kakao.d.ts +++ b/src/types/kakao.d.ts @@ -52,12 +52,6 @@ declare global { }; map?: KakaoMap; __panAnimationId?: number; - // WebView / embed hooks used by React Native WebView or other hosts - __moveFromRN?: (lat: number, lng: number) => void; - __onMapReady?: () => void; - __pendingMove?: { lat: number; lng: number } | null; - // Minimal React Native WebView typing to allow postMessage from the web app - ReactNativeWebView?: { postMessage: (message: string) => void }; // internal bubble overlay handle / name used by the app __currentBubbleOverlay?: KakaoOverlay | undefined; __currentBubbleStopName?: string | undefined; From 1e7f5251d718585de9bbeef59b7ff3b9f02d8a5e Mon Sep 17 00:00:00 2001 From: Kwon DaeGeun Date: Sat, 15 Nov 2025 09:52:50 +0900 Subject: [PATCH 5/5] =?UTF-8?q?refactor:=20=EB=A9=94=EC=84=B8=EC=A7=80=20?= =?UTF-8?q?=ED=95=B8=EB=93=A4=EB=9F=AC=20=EA=B4=80=EB=A0=A8=20=EB=B6=88?= =?UTF-8?q?=ED=95=84=EC=9A=94=ED=95=9C=20=EC=BD=94=EB=93=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useMapEventHandlers.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/hooks/useMapEventHandlers.ts b/src/hooks/useMapEventHandlers.ts index 953d5c7..8e68df2 100644 --- a/src/hooks/useMapEventHandlers.ts +++ b/src/hooks/useMapEventHandlers.ts @@ -2,19 +2,6 @@ import { useEffect } from "react"; export const useMapEventHandlers = (mapId: string) => { useEffect(() => { - const selfOrigin = location.protocol.startsWith("http") - ? location.origin - : undefined; - const allowedOrigins = new Set([ - ...(selfOrigin ? [selfOrigin] : []), - "http://localhost:3000", - "http://localhost:5173", - ]); - - const messageHandler = (event: MessageEvent) => { - if (!allowedOrigins.has(event.origin)) return; - }; - const containerEl = document.getElementById(mapId); const gestureHandler = (e: Event) => { const t = e.target as HTMLElement | null; @@ -22,13 +9,11 @@ export const useMapEventHandlers = (mapId: string) => { e.preventDefault(); }; - window.addEventListener("message", messageHandler); document.addEventListener("gesturestart", gestureHandler, { passive: false, }); return () => { - window.removeEventListener("message", messageHandler); document.removeEventListener("gesturestart", gestureHandler); }; }, [mapId]);