Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 47 additions & 15 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"));

Expand Down Expand Up @@ -69,19 +71,21 @@ function App() {
language={language}
setLanguage={setLanguage}
>
<AppContent
mapId={mapId}
langId={langId}
language={language}
setLanguage={setLanguage}
showSettings={showSettings}
toggleSettings={toggleSettings}
bubbleStop={bubbleStop}
setBubbleStop={setBubbleStop}
/>
{import.meta.env.DEV && (
<ReactQueryDevtools initialIsOpen={false} />
)}
<ToastProvider>
<AppContent
mapId={mapId}
langId={langId}
language={language}
setLanguage={setLanguage}
showSettings={showSettings}
toggleSettings={toggleSettings}
bubbleStop={bubbleStop}
setBubbleStop={setBubbleStop}
/>
{import.meta.env.DEV && (
<ReactQueryDevtools initialIsOpen={false} />
)}
</ToastProvider>
</LanguageProvider>
</QueryClientProvider>
);
Expand Down Expand Up @@ -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 (
<div
Expand All @@ -139,7 +167,11 @@ function AppContent({
/>
</Suspense>
) : null}
<MapContainer mapId={mapId} selectedStopName={bubbleStop?.name}>
<MapContainer
mapId={mapId}
selectedStopName={bubbleStop?.name}
onStopClick={handleStopClick}
>
<Bubble
stop={bubbleStop}
onClose={() => setBubbleStop(undefined)}
Expand Down
18 changes: 15 additions & 3 deletions src/api/bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,27 @@ import { useQuery } from "@tanstack/react-query";
import type { Bus } from "../data/bus";
import { apiGet } from "../lib/api";
import { API_ENDPOINTS } from "../lib/endpoints";
import { handleApiError } from "../lib/error";

export const useBusLocations = () => {
export const useBusLocations = (
onError?: (message: string) => void
) => {
return useQuery({
queryKey: ["busLocations"],
queryFn: async () => {
const data = await apiGet<Bus[]>(API_ENDPOINTS.BUS.LOCATION);
return Array.isArray(data) ? data : [];
try {
const data = await apiGet<Bus[]>(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,
});
};
5 changes: 4 additions & 1 deletion src/components/MapContainer.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 (
Expand Down
60 changes: 0 additions & 60 deletions src/hooks/useKakaoMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down
33 changes: 0 additions & 33 deletions src/hooks/useMapEventHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,52 +2,19 @@ import { useEffect } from "react";

export const useMapEventHandlers = (mapId: string) => {
useEffect(() => {
const isRN = !!window.ReactNativeWebView;
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) => {
// RN(WebView)에서만 origin === "null" 허용
if (
!(isRN && event.origin === "null") &&
!allowedOrigins.has(event.origin)
)
return;
};

const containerEl = document.getElementById(mapId);
const gestureHandler = (e: Event) => {
const t = e.target as HTMLElement | null;
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]);
};
12 changes: 9 additions & 3 deletions src/hooks/useMapOverlays.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
];

Expand All @@ -28,7 +29,12 @@ export const useMapOverlays = (
} catch {
// ignore cleanup errors
}
try {
overlay.cleanup?.();
} catch {
// ignore cleanup errors
}
});
};
}, [map, busStops, buses, selectedStopName]);
}, [map, busStops, buses, selectedStopName, onStopClick]);
};
6 changes: 0 additions & 6 deletions src/types/kakao.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
45 changes: 42 additions & 3 deletions src/utils/mapOverlays.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -130,7 +131,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 [];

Expand All @@ -143,12 +145,40 @@ 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);

// Named click handler for proper cleanup
const handleClick = (e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
if (onStopClick) {
onStopClick(stop);
}
};

// 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({
position: markerPosition,
Expand All @@ -157,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);
},
};
});
};

Expand Down
Loading