312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
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<string | null>) | 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<string | null>) | null>;
|
||
}) {
|
||
const { current: map } = useMap();
|
||
useEffect(() => {
|
||
if (!map) return;
|
||
captureRef.current = () =>
|
||
new Promise<string | null>((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 (
|
||
<>
|
||
{/* 좌측 컨트롤 컬럼 */}
|
||
<div className="absolute top-[80px] left-[10px] z-10 flex flex-col gap-1">
|
||
{/* 줌 */}
|
||
<button title="줌 인" onClick={() => map?.zoomIn()} className={btn}>
|
||
+
|
||
</button>
|
||
<button title="줌 아웃" onClick={() => map?.zoomOut()} className={btn}>
|
||
−
|
||
</button>
|
||
<button
|
||
title="초기 위치로"
|
||
onClick={() =>
|
||
map?.flyTo({
|
||
center: [initialCenter[1], initialCenter[0]],
|
||
zoom: initialZoom,
|
||
duration: 1000,
|
||
})
|
||
}
|
||
className={btn}
|
||
style={{ fontSize: '10px' }}
|
||
>
|
||
⊙
|
||
</button>
|
||
|
||
<div className="h-px bg-stroke my-0.5" />
|
||
|
||
{/* 지도 타입 */}
|
||
<button
|
||
title="3D 위성 모드"
|
||
onClick={() => toggleMap('threeD')}
|
||
className={`${btn} ${mapToggles.threeD ? btnOn : ''}`}
|
||
style={{ fontSize: '9px', fontWeight: 600, letterSpacing: '-0.3px' }}
|
||
>
|
||
3D
|
||
</button>
|
||
<button
|
||
title="ENC 전자해도"
|
||
onClick={() => toggleMap('s57')}
|
||
className={`${btn} ${mapToggles.s57 ? btnOn : ''}`}
|
||
style={{ fontSize: '8px', fontWeight: 600 }}
|
||
>
|
||
ENC
|
||
</button>
|
||
|
||
<div className="h-px bg-stroke my-0.5" />
|
||
|
||
{/* 측정 도구 */}
|
||
<button
|
||
title="거리 측정"
|
||
onClick={() => setMeasureMode(measureMode === 'distance' ? null : 'distance')}
|
||
className={`${btn} ${measureMode === 'distance' ? btnOn : ''}`}
|
||
style={{ fontSize: '12px' }}
|
||
>
|
||
📏
|
||
</button>
|
||
<button
|
||
title="면적 측정"
|
||
onClick={() => setMeasureMode(measureMode === 'area' ? null : 'area')}
|
||
className={`${btn} ${measureMode === 'area' ? btnOn : ''}`}
|
||
style={{ fontSize: '11px' }}
|
||
>
|
||
⬜
|
||
</button>
|
||
</div>
|
||
|
||
{/* 좌표 표시 (좌하단) */}
|
||
<div className="cod">
|
||
<span>
|
||
위도{' '}
|
||
<span className="cov">
|
||
{Math.abs(lat).toFixed(4)}°{latDir}
|
||
</span>
|
||
</span>
|
||
<span>
|
||
경도{' '}
|
||
<span className="cov">
|
||
{Math.abs(lng).toFixed(4)}°{lngDir}
|
||
</span>
|
||
</span>
|
||
<span>
|
||
축척 <span className="cov">{scaleLabel}</span>
|
||
</span>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ─── 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 (
|
||
<div className="w-full h-full relative">
|
||
<Map
|
||
initialViewState={{ longitude: center[1], latitude: center[0], zoom }}
|
||
mapStyle={mapStyle}
|
||
className="w-full h-full"
|
||
onClick={handleClick}
|
||
onZoom={handleZoom}
|
||
style={{ cursor: cursor ?? 'grab' }}
|
||
attributionControl={false}
|
||
preserveDrawingBuffer={true}
|
||
>
|
||
{/* 공통 오버레이 */}
|
||
<S57EncOverlay visible={mapToggles.s57 ?? false} />
|
||
<MapPitchController threeD={mapToggles.threeD ?? false} />
|
||
<DeckGLOverlay layers={measureDeckLayers} />
|
||
<MeasureOverlay />
|
||
{mapCaptureRef && <MapCaptureSetup captureRef={mapCaptureRef} />}
|
||
|
||
{/* 공통 컨트롤 UI (줌·지도 타입·측정·좌표) */}
|
||
{showOverlays && <MapOverlayControls initialCenter={center} initialZoom={zoom} />}
|
||
|
||
{/* 탭별 주입 */}
|
||
{children}
|
||
</Map>
|
||
|
||
{/* 측정 모드 힌트 */}
|
||
{showOverlays && measureMode === 'distance' && (
|
||
<div className="boom-drawing-indicator">
|
||
거리 재기 — {measureInProgress.length === 0 ? '시작점을 클릭하세요' : '끝점을 클릭하세요'}
|
||
</div>
|
||
)}
|
||
{showOverlays && measureMode === 'area' && (
|
||
<div className="boom-drawing-indicator">
|
||
면적 재기 — 꼭짓점을 클릭하세요 ({measureInProgress.length}개)
|
||
{measureInProgress.length >= 3 && ' → 첫 번째 점 근처를 클릭하면 닫힙니다'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|