1- import { Box , Button , Stack , Tab , Tabs } from "@mui/material" ;
1+ import { Box , Button , CircularProgress , Stack , Tab , Tabs , Typography } from "@mui/material" ;
22import { CSSProperties , FC , SyntheticEvent , useEffect , useRef , useState } from "react" ;
33import { renderToStaticMarkup } from "react-dom/server" ;
44
@@ -27,6 +27,51 @@ type MapStateType = {
2727 tab : number ;
2828} ;
2929
30+ /**
31+ * dapi.kakao.com 의 sdk.js 는 첫 줄에서 `window.kakao.maps` 를 빈 객체로 만들어 두고,
32+ * 실제 클래스(LatLng·Map 등)는 뒤이어 주입되는 kakao.js 가 붙여준다.
33+ * 게다가 autoload 모드에서는 로드 완료와 무관하게 readyState 를 즉시 2 로 올려서 `kakao.maps.load()` 콜백도 바로 실행된다.
34+ * 따라서 `window.kakao.maps` 존재 여부로는 판단할 수 없고, 실제로 쓸 생성자가 붙었는지를 확인해야 한다.
35+ * (느린 네트워크에서 브라우저가 document.write 로 삽입된 스크립트를 차단하면 끝내 붙지 않는다.)
36+ */
37+ const isKakaoMapsReady = ( ) : boolean => typeof window . kakao ?. maps ?. LatLng === "function" && typeof window . kakao ?. maps ?. Map === "function" ;
38+
39+ /**
40+ * `autoload=false` 로 심은 경우 이 호출이 실제 로딩을 시작시킨다(document.write 가 아닌 동적 스크립트 삽입).
41+ * autoload 모드에서는 콜백만 즉시 실행되고 끝나므로 무해하다. sdk.js 자체가 아직 없으면 false 를 돌려준다.
42+ */
43+ const requestKakaoMapsLoad = ( ) : boolean => {
44+ if ( typeof window . kakao ?. maps ?. load !== "function" ) return false ;
45+ window . kakao . maps . load ( ( ) => undefined ) ;
46+ return true ;
47+ } ;
48+
49+ const KAKAO_MAPS_POLL_INTERVAL_MS = 200 ;
50+ const KAKAO_MAPS_TIMEOUT_MS = 5000 ;
51+
52+ type KakaoMapsStatus = "loading" | "ready" | "failed" ;
53+
54+ const useKakaoMapsStatus = ( ) : KakaoMapsStatus => {
55+ const [ status , setStatus ] = useState < KakaoMapsStatus > ( ( ) => ( isKakaoMapsReady ( ) ? "ready" : "loading" ) ) ;
56+
57+ useEffect ( ( ) => {
58+ if ( status !== "loading" ) return ;
59+
60+ let loadRequested = requestKakaoMapsLoad ( ) ;
61+ const startedAt = Date . now ( ) ;
62+ const timer = window . setInterval ( ( ) => {
63+ if ( ! loadRequested ) loadRequested = requestKakaoMapsLoad ( ) ;
64+
65+ if ( isKakaoMapsReady ( ) ) setStatus ( "ready" ) ;
66+ else if ( Date . now ( ) - startedAt >= KAKAO_MAPS_TIMEOUT_MS ) setStatus ( "failed" ) ;
67+ } , KAKAO_MAPS_POLL_INTERVAL_MS ) ;
68+
69+ return ( ) => window . clearInterval ( timer ) ;
70+ } , [ status ] ) ;
71+
72+ return status ;
73+ } ;
74+
3075export type MapDataType = {
3176 title : {
3277 ko : string ;
@@ -59,6 +104,27 @@ const MapData: { [key in SupportedMapType]: MapDataType } = {
59104 } ,
60105} ;
61106
107+ const KakaoMapArea : FC < {
108+ status : KakaoMapsStatus ;
109+ mapRef : React . RefObject < HTMLDivElement | null > ;
110+ style : CSSProperties ;
111+ language : LangType ;
112+ } > = ( { status, mapRef, style, language } ) => {
113+ if ( status === "ready" ) return < div ref = { mapRef } style = { style } /> ;
114+
115+ return (
116+ < Stack sx = { { ...style , alignItems : "center" , justifyContent : "center" , backgroundColor : "action.hover" } } >
117+ { status === "loading" ? (
118+ < CircularProgress size = "1.5rem" />
119+ ) : (
120+ < Typography variant = "body2" color = "text.secondary" align = "center" sx = { { p : 2 } } >
121+ { language === "ko" ? "카카오맵을 불러오지 못했어요. 아래 버튼으로 열어주세요." : "Couldn't load Kakao Map. Please use the button below." }
122+ </ Typography >
123+ ) }
124+ </ Stack >
125+ ) ;
126+ } ;
127+
62128/**
63129 * 카카오맵·구글지도·네이버지도 탭으로 특정 장소를 보여주는 지도 컴포넌트.
64130 * 각 지도 서비스로 바로 여는 버튼도 함께 렌더하며, 주로 행사장 위치 안내에 사용한다.
@@ -67,13 +133,14 @@ const MapData: { [key in SupportedMapType]: MapDataType } = {
67133export const Map : FC < MapPropType > = ( { geo, placeName, placeCode, googleMapIframeSrc } ) => {
68134 const { language } = useCommonContext ( ) ;
69135 const kakaoMapRef = useRef < HTMLDivElement > ( null ) ;
136+ const kakaoMapsStatus = useKakaoMapsStatus ( ) ;
70137 const [ mapState , setMapState ] = useState < MapStateType > ( { tab : 0 } ) ;
71138 const selectedMapType = MAP_TYPES [ mapState . tab ] || "kakao" ;
72139 const setTab = ( _ : SyntheticEvent , tab : number ) => setMapState ( ( ps ) => ( { ...ps , tab } ) ) ;
73140
74141 useEffect ( ( ) => {
75142 const kakaoMapDiv = kakaoMapRef . current ;
76- if ( ! ( window . kakao && window . kakao . maps && kakaoMapDiv ) ) return ;
143+ if ( kakaoMapsStatus !== "ready" || ! kakaoMapDiv ) return ;
77144
78145 const kakaoMapUrl = MapData . kakao . basePlaceInfoUrl + placeCode . kakao ;
79146 const content : string = renderToStaticMarkup (
@@ -108,7 +175,7 @@ export const Map: FC<MapPropType> = ({ geo, placeName, placeCode, googleMapIfram
108175 if ( map ) map . setCenter ( new kakao . maps . LatLng ( geo . lat , geo . lng ) ) ;
109176 if ( kakaoMapDiv ) kakaoMapDiv . innerHTML = "" ; // Clear the map container
110177 } ;
111- } , [ mapState . tab , geo , language , placeName , placeCode . kakao ] ) ;
178+ } , [ mapState . tab , geo , language , placeName , placeCode . kakao , kakaoMapsStatus ] ) ;
112179
113180 const mapStyle : CSSProperties = { border : 0 , width : "100%" , aspectRatio : "3/2" } ;
114181
@@ -121,7 +188,7 @@ export const Map: FC<MapPropType> = ({ geo, placeName, placeCode, googleMapIfram
121188 < Tab key = { k } label = { d . title [ language ] } sx = { { textTransform : "none" } } />
122189 ) ) }
123190 </ Tabs >
124- { selectedMapType === "kakao" && < div ref = { kakaoMapRef } style = { mapStyle } /> }
191+ { selectedMapType === "kakao" && < KakaoMapArea status = { kakaoMapsStatus } mapRef = { kakaoMapRef } style = { mapStyle } language = { language } /> }
125192 { selectedMapType === "google" && (
126193 < iframe title = "map" src = { googleMapIframeSrc } style = { mapStyle } allowFullScreen loading = "lazy" referrerPolicy = "no-referrer-when-downgrade" />
127194 ) }
0 commit comments