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
91 changes: 91 additions & 0 deletions src/features/kakaomap/components/Map.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* @file Map.tsx
* @description 카카오 지도 컨테이너 컴포넌트
*
* 동작 흐름:
* 1. <div ref> DOM 생성
* 2. useLayoutEffect: new kakao.maps.Map(div, options) 인스턴스 생성 (깜빡임 없음)
* 3. KakaoMapContext.Provider로 자식(MapMarker 등)에게 map 인스턴스 전달
* 4. center/level props 변경 → SDK setter 직접 호출 (리렌더 없이 동기화)
*
* @example
* const [loading] = useKakaoLoader()
* if (loading) return <Spinner />
*
* return (
* <Map
* center={{ lat: 37.566826, lng: 126.9786567 }}
* level={3}
* style={{ width: '100%', height: '350px' }}
* onCreate={setMap}
* >
* <MapMarker position={{ lat: 37.566826, lng: 126.9786567 }} />
* </Map>
* )
*/

import { useLayoutEffect, useRef, useState } from 'react'

import { KakaoMapContext } from '../context/KakaoMapContext'
import type { KakaoMap } from '../kakaoMap.types'

export type MapProps = {
/** 지도 중심 좌표 */
center: { lat: number; lng: number }
/** 지도 줌 레벨 (기본값: 3) */
level?: number
style?: React.CSSProperties
className?: string
/** 지도 인스턴스 생성 완료 콜백 — 외부에서 map 인스턴스에 직접 접근할 때 사용 */
onCreate?: (map: KakaoMap) => void
children?: React.ReactNode
}

export function Map({ center, level = 3, style, className, onCreate, children }: MapProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [map, setMap] = useState<KakaoMap | null>(null)

// onCreate 콜백을 ref로 안정화 — deps 변경 없이 항상 최신 참조 유지
const onCreateRef = useRef(onCreate)
onCreateRef.current = onCreate

// ── 최초 마운트 시 지도 인스턴스 생성 ────────────────────────
// useLayoutEffect: DOM paint 직전 실행 → 깜빡임 없음
useLayoutEffect(() => {
if (!containerRef.current || !window.kakao?.maps) return

const kakaoMap = new window.kakao.maps.Map(containerRef.current, {
center: new window.kakao.maps.LatLng(center.lat, center.lng),
level,
})

setMap(kakaoMap)
onCreateRef.current?.(kakaoMap)

return () => {
setMap(null)
}
// center/level 초기값은 한 번만 사용. 이후 변경은 아래 effect에서 SDK setter로 처리
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

// ── center props 변경 → map.setCenter() 호출 ─────────────
useLayoutEffect(() => {
if (!map) return
map.setCenter(new window.kakao.maps.LatLng(center.lat, center.lng))
}, [map, center.lat, center.lng])

// ── level props 변경 → map.setLevel() 호출 ───────────────
useLayoutEffect(() => {
if (!map) return
map.setLevel(level)
}, [map, level])

return (
<KakaoMapContext.Provider value={map}>
<div ref={containerRef} style={style} className={className} />
{/* map 인스턴스가 준비된 후에만 자식 렌더링 */}
{map && children}
</KakaoMapContext.Provider>
)
}
87 changes: 87 additions & 0 deletions src/features/kakaomap/components/MapMarker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* @file MapMarker.tsx
* @description 카카오 지도 마커 공개 컴포넌트
*
* Map 컴포넌트의 자식으로 사용합니다.
* position을 useMemo로 최적화하고, 실제 마커 로직은 Marker에 위임합니다.
*
* @example
* <Map center={{ lat: 37.566826, lng: 126.9786567 }} level={3}>
* <MapMarker
* position={{ lat: 37.566826, lng: 126.9786567 }}
* onClick={(marker) => console.log(marker)}
* >
* <div style={{ color: '#000' }}>마커 내용</div>
* </MapMarker>
* </Map>
*/

import { forwardRef, type PropsWithChildren, useMemo } from 'react'

import { useKakaoMapContext } from '../context/KakaoMapContext'
import type { KakaoMarker } from '../kakaoMap.types'
import { Marker, type MarkerImageProp } from './Marker'

export type MapMarkerProps = {
/**
* 마커 표시 좌표
*/
position: { lat: number; lng: number } | { x: number; y: number }

/** 마커 이미지 커스터마이징 */
image?: MarkerImageProp

/** 마커 툴팁 텍스트 */
title?: string

/** 드래그 가능 여부 */
draggable?: boolean

/** 클릭 가능 여부 */
clickable?: boolean

/** z-index */
zIndex?: number

/** 투명도 (0–1) */
opacity?: number

/** 마커 클릭 이벤트 */
onClick?: (marker: KakaoMarker) => void

/** 마커 마우스오버 이벤트 */
onMouseOver?: (marker: KakaoMarker) => void

/** 마커 마우스아웃 이벤트 */
onMouseOut?: (marker: KakaoMarker) => void

/** 마커 드래그 시작 이벤트 */
onDragStart?: (marker: KakaoMarker) => void

/** 마커 드래그 종료 이벤트 */
onDragEnd?: (marker: KakaoMarker) => void

/** 마커 생성 완료 콜백 */
onCreate?: (marker: KakaoMarker) => void

/** InfoWindow 옵션 */
infoWindowOptions?: {
disableAutoPan?: boolean
removable?: boolean
zIndex?: number
}
}

export const MapMarker = forwardRef<KakaoMarker, PropsWithChildren<MapMarkerProps>>(
function MapMarker({ position, ...props }, ref) {
const map = useKakaoMapContext('MapMarker')

// 복잡한 표현식을 변수로 추출해 useMemo deps를 정적으로 분석 가능하게 함
const lat = 'lat' in position ? position.lat : position.y
const lng = 'lng' in position ? position.lng : position.x

const markerPosition = useMemo(() => new window.kakao.maps.LatLng(lat, lng), [lat, lng])

return <Marker map={map} position={markerPosition} {...props} ref={ref} />
}
)
207 changes: 207 additions & 0 deletions src/features/kakaomap/components/Marker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
/**
* @file Marker.tsx
* @description 카카오 마커 내부 구현 컴포넌트
*
* MapMarker에서 위임받아 실제 kakao.maps.Marker 인스턴스를 관리합니다.
* - useMemo: 렌더 중 동기적으로 마커 인스턴스 생성 (타이밍 문제 없음)
* - useLayoutEffect: map 등록/해제 및 props setter 동기화
* - useKakaoEvent: 이벤트 등록/해제 자동화
* - createPortal: children을 CustomOverlay DOM에 주입
* - forwardRef: 외부에서 마커 인스턴스에 직접 접근 가능
*/

import {
forwardRef,
type PropsWithChildren,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import { createPortal } from 'react-dom'

import type {
KakaoCustomOverlay,
KakaoLatLng,
KakaoMap,
KakaoMarker,
KakaoMarkerImageOptions,
KakaoSdkMarkerImage,
} from '../kakaoMap.types'
import { useKakaoEvent } from '../lib/useKakaoEvent'

export type MarkerImageProp = {
src: string
size: { width: number; height: number }
options?: KakaoMarkerImageOptions
}

export type MarkerProps = {
map: KakaoMap
position: KakaoLatLng
image?: MarkerImageProp
title?: string
draggable?: boolean
clickable?: boolean
zIndex?: number
opacity?: number
onClick?: (marker: KakaoMarker) => void
onMouseOver?: (marker: KakaoMarker) => void
onMouseOut?: (marker: KakaoMarker) => void
onDragStart?: (marker: KakaoMarker) => void
onDragEnd?: (marker: KakaoMarker) => void
onCreate?: (marker: KakaoMarker) => void
infoWindowOptions?: {
disableAutoPan?: boolean
removable?: boolean
zIndex?: number
}
}

function buildMarkerImage(image: MarkerImageProp): KakaoSdkMarkerImage {
const { kakao } = window
const size = new kakao.maps.Size(image.size.width, image.size.height)
const options = image.options
? {
...image.options,
offset: image.options.offset
? new kakao.maps.Point(image.options.offset.x, image.options.offset.y)
: undefined,
spriteOrigin: image.options.spriteOrigin
? new kakao.maps.Point(image.options.spriteOrigin.x, image.options.spriteOrigin.y)
: undefined,
spriteSize: image.options.spriteSize
? new kakao.maps.Size(image.options.spriteSize.width, image.options.spriteSize.height)
: undefined,
}
: undefined
return new kakao.maps.MarkerImage(image.src, size, options)
}

export const Marker = forwardRef<KakaoMarker, PropsWithChildren<MarkerProps>>(function Marker(
{
map,
position,
image,
title,
draggable,
clickable,
zIndex,
opacity,
onClick,
onMouseOver,
onMouseOut,
onDragStart,
onDragEnd,
onCreate,
children,
},
ref
) {
const overlayRef = useRef<KakaoCustomOverlay | null>(null)
const [overlayEl, setOverlayEl] = useState<HTMLDivElement | null>(null)

const hasChildren = children != null

// ── 마커 인스턴스 생성 ────────
const marker = useMemo(
() =>
new window.kakao.maps.Marker({
position,
image: image ? buildMarkerImage(image) : undefined,
title,
draggable,
clickable,
zIndex,
opacity,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
)

// 마커 인스턴스를 ref로 외부에 노출
useImperativeHandle(ref, () => marker, [marker])

// ── map 등록/해제 ──────────────────────────────────────────
useLayoutEffect(() => {
marker.setMap(map)
return () => marker.setMap(null)
}, [map, marker])

// ── onCreate 콜백 ──────────────────────────────────────────
useLayoutEffect(() => {
onCreate?.(marker)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [marker])

// ── props 변경 → SDK setter 호출 ──────────────────────────
useLayoutEffect(() => {
marker.setPosition(position)
}, [marker, position])

useLayoutEffect(() => {
if (!image) return
marker.setImage(buildMarkerImage(image))
}, [marker, image])

useLayoutEffect(() => {
if (title !== undefined) marker.setTitle(title)
}, [marker, title])

useLayoutEffect(() => {
if (draggable !== undefined) marker.setDraggable(draggable)
}, [marker, draggable])

useLayoutEffect(() => {
if (clickable !== undefined) marker.setClickable(clickable)
}, [marker, clickable])

useLayoutEffect(() => {
if (zIndex !== undefined) marker.setZIndex(zIndex)
}, [marker, zIndex])

useLayoutEffect(() => {
if (opacity !== undefined) marker.setOpacity(opacity)
}, [marker, opacity])

// ── 이벤트 자동 등록/해제 ─────────────────────────────────
useKakaoEvent(marker, 'click', onClick ? () => onClick(marker) : undefined)
useKakaoEvent(marker, 'mouseover', onMouseOver ? () => onMouseOver(marker) : undefined)
useKakaoEvent(marker, 'mouseout', onMouseOut ? () => onMouseOut(marker) : undefined)
useKakaoEvent(marker, 'dragstart', onDragStart ? () => onDragStart(marker) : undefined)
useKakaoEvent(marker, 'dragend', onDragEnd ? () => onDragEnd(marker) : undefined)

// ── children → CustomOverlay + createPortal ───────────────
useLayoutEffect(() => {
if (!hasChildren) return

const { kakao } = window
const container = document.createElement('div')

const overlay = new kakao.maps.CustomOverlay({
position,
content: container,
map,
yAnchor: 1,
})

overlayRef.current = overlay
setOverlayEl(container)

return () => {
overlay.setMap(null)
overlayRef.current = null
setOverlayEl(null)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [map, hasChildren])

// overlay position 동기화
useLayoutEffect(() => {
overlayRef.current?.setPosition(position)
}, [position])

if (!overlayEl || !hasChildren) return null
return createPortal(children, overlayEl)
})
Loading
Loading