import { useState, useMemo, useCallback, useEffect, type MutableRefObject, type ReactNode, } from 'react'; import { Map, useMap } from '@vis.gl/react-maplibre'; import type { MapLayerMouseEvent } from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; import { useMapStore } from '@common/store/mapStore'; import { useMeasureTool } from '@common/hooks/useMeasureTool'; import { S57EncOverlay } from './S57EncOverlay'; import { MeasureOverlay } from './MeasureOverlay'; import { DeckGLOverlay } from './DeckGLOverlay'; import { buildMeasureLayers } from './measureLayers'; const DEFAULT_CENTER: [number, number] = [37.39, 126.64]; const DEFAULT_ZOOM = 10; export interface BaseMapProps { /** 초기 중심 좌표 [lat, lng]. 기본: 인천 송도 */ center?: [number, number]; /** 초기 줌 레벨. 기본: 10 */ zoom?: number; /** 지도 클릭 핸들러 (측정 모드 중에는 호출되지 않음) */ onMapClick?: (lon: number, lat: number) => void; /** 줌 변경 핸들러. ScatMap 등 줌 기반 레이어 스케일 조정에 사용 */ onZoom?: (zoom: number) => void; /** 커서 스타일 (예: 'crosshair'). 기본: 'grab' */ cursor?: string; /** false 시 컨트롤 UI·좌표 표시를 숨김 (캡처 전용 모드). 기본: true */ showOverlays?: boolean; /** 지도 캡처 함수 ref (Reports 탭 전용) */ mapCaptureRef?: MutableRefObject<(() => Promise) | null>; /** 탭별 고유 오버레이·마커·팝업 등 */ children?: ReactNode; } // ─── 3D 모드 pitch/bearing 제어 ──────────────────────────────────────────── function MapPitchController({ threeD }: { threeD: boolean }) { const { current: map } = useMap(); useEffect(() => { if (!map) return; map.easeTo( threeD ? { pitch: 45, bearing: -17, duration: 800 } : { pitch: 0, bearing: 0, duration: 800 }, ); }, [threeD, map]); return null; } // ─── 지도 캡처 지원 ──────────────────────────────────────────────────────── function MapCaptureSetup({ captureRef, }: { captureRef: MutableRefObject<(() => Promise) | null>; }) { const { current: map } = useMap(); useEffect(() => { if (!map) return; captureRef.current = () => new Promise((resolve) => { map.once('render', () => { try { const src = map.getCanvas(); const maxW = 1200; const scale = src.width > maxW ? maxW / src.width : 1; const composite = document.createElement('canvas'); composite.width = Math.round(src.width * scale); composite.height = Math.round(src.height * scale); const ctx = composite.getContext('2d')!; ctx.fillStyle = '#0f1117'; ctx.fillRect(0, 0, composite.width, composite.height); ctx.drawImage(src, 0, 0, composite.width, composite.height); resolve(composite.toDataURL('image/jpeg', 0.82)); } catch { resolve(null); } }); map.triggerRepaint(); }); }, [map, captureRef]); return null; } // ─── 공통 컨트롤 UI + 좌표 표시 ─────────────────────────────────────────── // Map 내부에 렌더링되어 useMap()으로 인스턴스에 접근함. // 줌 버튼·지도 타입·측정 도구·좌표 표시를 하나의 컴포넌트로 통합. function MapOverlayControls({ initialCenter, initialZoom, }: { initialCenter: [number, number]; initialZoom: number; }) { const { current: map } = useMap(); const mapToggles = useMapStore((s) => s.mapToggles); const toggleMap = useMapStore((s) => s.toggleMap); const measureMode = useMapStore((s) => s.measureMode); const setMeasureMode = useMapStore((s) => s.setMeasureMode); const [pos, setPos] = useState({ lat: initialCenter[0], lng: initialCenter[1], zoom: initialZoom, }); useEffect(() => { if (!map) return; const update = () => { const c = map.getCenter(); setPos({ lat: c.lat, lng: c.lng, zoom: map.getZoom() }); }; update(); map.on('move', update); map.on('zoom', update); return () => { map.off('move', update); map.off('zoom', update); }; }, [map]); const btn = 'w-[28px] h-[28px] bg-[color-mix(in_srgb,var(--bg-elevated)_85%,transparent)] ' + 'backdrop-blur-sm border border-stroke rounded-sm text-fg-sub flex items-center justify-center ' + 'hover:bg-bg-surface-hover hover:text-fg transition-all text-caption select-none cursor-pointer'; const btnOn = 'text-color-accent border-color-accent bg-[rgba(6,182,212,0.08)]'; // 좌표·축척 계산 const { lat, lng, zoom } = pos; const latDir = lat >= 0 ? 'N' : 'S'; const lngDir = lng >= 0 ? 'E' : 'W'; const mpp = (40075016.686 * Math.cos((lat * Math.PI) / 180)) / (256 * Math.pow(2, zoom)); const sr = Math.round(mpp * (96 / 0.0254)); const scaleLabel = sr >= 1_000_000 ? `1:${(sr / 1_000_000).toFixed(1)}M` : `1:${sr.toLocaleString()}`; return ( <> {/* 좌측 컨트롤 컬럼 */}
{/* 줌 */}
{/* 지도 타입 */}
{/* 측정 도구 */}
{/* 좌표 표시 (좌하단) */}
위도{' '} {Math.abs(lat).toFixed(4)}°{latDir} 경도{' '} {Math.abs(lng).toFixed(4)}°{lngDir} 축척 {scaleLabel}
); } // ─── BaseMap ─────────────────────────────────────────────────────────────── export function BaseMap({ center = DEFAULT_CENTER, zoom = DEFAULT_ZOOM, onMapClick, onZoom, cursor, showOverlays = true, mapCaptureRef, children, }: BaseMapProps) { const mapStyle = useBaseMapStyle(); const mapToggles = useMapStore((s) => s.mapToggles); const measureMode = useMapStore((s) => s.measureMode); const measureInProgress = useMapStore((s) => s.measureInProgress); const measurements = useMapStore((s) => s.measurements); const { handleMeasureClick } = useMeasureTool(); const handleClick = useCallback( (e: MapLayerMouseEvent) => { const { lng, lat } = e.lngLat; if (measureMode !== null) { handleMeasureClick(lng, lat); return; } onMapClick?.(lng, lat); }, [measureMode, handleMeasureClick, onMapClick], ); const handleZoom = useCallback( (e: { viewState: { zoom: number } }) => { onZoom?.(e.viewState.zoom); }, [onZoom], ); const measureDeckLayers = useMemo( () => buildMeasureLayers(measureInProgress, measureMode, measurements), [measureInProgress, measureMode, measurements], ); return (
{/* 공통 오버레이 */} {mapCaptureRef && } {/* 공통 컨트롤 UI (줌·지도 타입·측정·좌표) */} {showOverlays && } {/* 탭별 주입 */} {children} {/* 측정 모드 힌트 */} {showOverlays && measureMode === 'distance' && (
거리 재기 — {measureInProgress.length === 0 ? '시작점을 클릭하세요' : '끝점을 클릭하세요'}
)} {showOverlays && measureMode === 'area' && (
면적 재기 — 꼭짓점을 클릭하세요 ({measureInProgress.length}개) {measureInProgress.length >= 3 && ' → 첫 번째 점 근처를 클릭하면 닫힙니다'}
)}
); }