wing-ops/frontend/src/common/components/map/BaseMap.tsx

312 lines
10 KiB
TypeScript
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}