feat(map): 거리·면적 측정 도구 구현

TopBar 퀵메뉴에서 거리/면적 측정 모드 토글, MapView에서 클릭으로
포인트 수집 후 deck.gl 레이어로 결과를 시각화한다.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leedano 2026-03-16 17:52:27 +09:00
부모 891db2a894
커밋 301df70376
7개의 변경된 파일380개의 추가작업 그리고 9개의 파일을 삭제

파일 보기

@ -14,7 +14,16 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
const quickMenuRef = useRef<HTMLDivElement>(null) const quickMenuRef = useRef<HTMLDivElement>(null)
const { hasPermission, user, logout } = useAuthStore() const { hasPermission, user, logout } = useAuthStore()
const { menuConfig, isLoaded } = useMenuStore() const { menuConfig, isLoaded } = useMenuStore()
const { mapToggles, toggleMap } = useMapStore() const { mapToggles, toggleMap, measureMode, setMeasureMode } = useMapStore()
const MAP_TABS = new Set<string>(['prediction', 'hns', 'scat', 'weather'])
const isMapTab = MAP_TABS.has(activeTab)
const handleToggleMeasure = (mode: 'distance' | 'area') => {
if (!isMapTab) return;
setMeasureMode(measureMode === mode ? null : mode);
setShowQuickMenu(false);
};
const tabs = useMemo(() => { const tabs = useMemo(() => {
if (!isLoaded || menuConfig.length === 0) return [] if (!isLoaded || menuConfig.length === 0) return []
@ -148,14 +157,36 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
{showQuickMenu && ( {showQuickMenu && (
<div className="absolute top-[44px] right-0 w-[220px] bg-[rgba(18,25,41,0.97)] backdrop-blur-xl border border-border rounded-lg shadow-2xl z-[200] py-2 font-korean"> <div className="absolute top-[44px] right-0 w-[220px] bg-[rgba(18,25,41,0.97)] backdrop-blur-xl border border-border rounded-lg shadow-2xl z-[200] py-2 font-korean">
{/* 거리·면적 계산 */} {/* 거리·면적 계산 */}
<div className="px-3 py-1.5 flex items-center gap-2 text-[11px] font-bold text-text-3"> {/* <div className="px-3 py-1.5 flex items-center gap-2 text-[11px] font-bold text-text-3">
<span>📐</span> · <span>📐</span> ·
</div> </div> */}
<button className="w-full px-3 py-2 flex items-center gap-2.5 text-[12px] text-text-2 hover:bg-[rgba(255,255,255,0.06)] hover:text-text-1 transition-all"> <button
onClick={() => handleToggleMeasure('distance')}
disabled={!isMapTab}
className={`w-full px-3 py-2 flex items-center gap-2.5 text-[12px] transition-all ${
!isMapTab
? 'text-text-3 opacity-40 cursor-not-allowed'
: measureMode === 'distance'
? 'text-primary-cyan bg-[rgba(6,182,212,0.1)]'
: 'text-text-2 hover:bg-[rgba(255,255,255,0.06)] hover:text-text-1'
}`}
>
<span className="text-[13px]"></span> <span className="text-[13px]"></span>
{measureMode === 'distance' && <span className="ml-auto text-[10px] text-primary-cyan"></span>}
</button> </button>
<button className="w-full px-3 py-2 flex items-center gap-2.5 text-[12px] text-text-2 hover:bg-[rgba(255,255,255,0.06)] hover:text-text-1 transition-all"> <button
onClick={() => handleToggleMeasure('area')}
disabled={!isMapTab}
className={`w-full px-3 py-2 flex items-center gap-2.5 text-[12px] transition-all ${
!isMapTab
? 'text-text-3 opacity-40 cursor-not-allowed'
: measureMode === 'area'
? 'text-primary-cyan bg-[rgba(6,182,212,0.1)]'
: 'text-text-2 hover:bg-[rgba(255,255,255,0.06)] hover:text-text-1'
}`}
>
<span className="text-[13px]"></span> <span className="text-[13px]"></span>
{measureMode === 'area' && <span className="ml-auto text-[10px] text-primary-cyan"></span>}
</button> </button>
<div className="my-1.5 border-t border-border" /> <div className="my-1.5 border-t border-border" />

파일 보기

@ -13,6 +13,9 @@ import HydrParticleOverlay from './HydrParticleOverlay'
import type { BoomLine, BoomLineCoord } from '@common/types/boomLine' import type { BoomLine, BoomLineCoord } from '@common/types/boomLine'
import type { ReplayShip, CollisionEvent } from '@common/types/backtrack' import type { ReplayShip, CollisionEvent } from '@common/types/backtrack'
import { createBacktrackLayers } from './BacktrackReplayOverlay' import { createBacktrackLayers } from './BacktrackReplayOverlay'
import { buildMeasureLayers } from './measureLayers'
import { MeasureOverlay } from './MeasureOverlay'
import { useMeasureTool } from '@common/hooks/useMeasureTool'
import { hexToRgba } from './mapUtils' import { hexToRgba } from './mapUtils'
import { useMapStore } from '@common/store/mapStore' import { useMapStore } from '@common/store/mapStore'
@ -330,7 +333,8 @@ export function MapView({
analysisCircleCenter, analysisCircleCenter,
analysisCircleRadiusM = 0, analysisCircleRadiusM = 0,
}: MapViewProps) { }: MapViewProps) {
const { mapToggles } = useMapStore() const { mapToggles, measureMode, measureInProgress, measurements } = useMapStore()
const { handleMeasureClick } = useMeasureTool()
const isControlled = externalCurrentTime !== undefined const isControlled = externalCurrentTime !== undefined
const [currentPosition, setCurrentPosition] = useState<[number, number]>(DEFAULT_CENTER) const [currentPosition, setCurrentPosition] = useState<[number, number]>(DEFAULT_CENTER)
const [internalCurrentTime, setInternalCurrentTime] = useState(0) const [internalCurrentTime, setInternalCurrentTime] = useState(0)
@ -342,11 +346,15 @@ export function MapView({
const handleMapClick = useCallback((e: MapLayerMouseEvent) => { const handleMapClick = useCallback((e: MapLayerMouseEvent) => {
const { lng, lat } = e.lngLat const { lng, lat } = e.lngLat
setCurrentPosition([lat, lng]) setCurrentPosition([lat, lng])
if (measureMode !== null) {
handleMeasureClick(lng, lat)
return
}
if (onMapClick) { if (onMapClick) {
onMapClick(lng, lat) onMapClick(lng, lat)
} }
setPopupInfo(null) setPopupInfo(null)
}, [onMapClick]) }, [onMapClick, measureMode, handleMeasureClick])
// 애니메이션 재생 로직 (외부 제어 모드에서는 비활성) // 애니메이션 재생 로직 (외부 제어 모드에서는 비활성)
useEffect(() => { useEffect(() => {
@ -959,6 +967,9 @@ export function MapView({
) )
} }
// 거리/면적 측정 레이어
result.push(...buildMeasureLayers(measureInProgress, measureMode, measurements))
return result return result
}, [ }, [
oilTrajectory, currentTime, selectedModels, oilTrajectory, currentTime, selectedModels,
@ -967,6 +978,7 @@ export function MapView({
sensitiveResources, centerPoints, windData, sensitiveResources, centerPoints, windData,
showWind, showBeached, showTimeLabel, simulationStartTime, showWind, showBeached, showTimeLabel, simulationStartTime,
analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM, analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM,
measureInProgress, measureMode, measurements,
]) ])
// 3D 모드에 따른 지도 스타일 전환 // 3D 모드에 따른 지도 스타일 전환
@ -982,7 +994,7 @@ export function MapView({
}} }}
mapStyle={currentMapStyle} mapStyle={currentMapStyle}
className="w-full h-full" className="w-full h-full"
style={{ cursor: (isSelectingLocation || drawAnalysisMode !== null) ? 'crosshair' : 'grab' }} style={{ cursor: (isSelectingLocation || drawAnalysisMode !== null || measureMode !== null) ? 'crosshair' : 'grab' }}
onClick={handleMapClick} onClick={handleMapClick}
attributionControl={false} attributionControl={false}
preserveDrawingBuffer={true} preserveDrawingBuffer={true}
@ -1056,6 +1068,9 @@ export function MapView({
</Popup> </Popup>
)} )}
{/* 측정 결과 지우기 버튼 */}
<MeasureOverlay />
{/* 커스텀 줌 컨트롤 */} {/* 커스텀 줌 컨트롤 */}
<MapControls center={center} zoom={zoom} /> <MapControls center={center} zoom={zoom} />
</Map> </Map>
@ -1076,6 +1091,16 @@ export function MapView({
{!analysisCircleCenter ? '원 분석 모드 — 중심점을 클릭하세요' : '반경 지점을 클릭하세요'} {!analysisCircleCenter ? '원 분석 모드 — 중심점을 클릭하세요' : '반경 지점을 클릭하세요'}
</div> </div>
)} )}
{measureMode === 'distance' && (
<div className="boom-drawing-indicator">
{measureInProgress.length === 0 ? '시작점을 클릭하세요' : '끝점을 클릭하세요'}
</div>
)}
{measureMode === 'area' && (
<div className="boom-drawing-indicator">
({measureInProgress.length}){measureInProgress.length >= 3 && ' → 첫 번째 점 근처를 클릭하면 닫힙니다'}
</div>
)}
{/* 기상청 연계 정보 */} {/* 기상청 연계 정보 */}
<WeatherInfoPanel position={currentPosition} /> <WeatherInfoPanel position={currentPosition} />

파일 보기

@ -0,0 +1,40 @@
import { useMemo } from 'react';
import { Marker } from '@vis.gl/react-maplibre';
import { useMapStore } from '../../store/mapStore';
import { midpointOf, centroid } from './measureLayers';
/** 완료된 측정 결과의 지우기 버튼을 Marker로 렌더 */
export function MeasureOverlay() {
const measurements = useMapStore((s) => s.measurements);
const removeMeasurement = useMapStore((s) => s.removeMeasurement);
const markers = useMemo(() => {
return measurements.map((m) => {
const pos =
m.mode === 'distance'
? midpointOf(m.points[0], m.points[1])
: centroid(m.points);
return { id: m.id, lon: pos[0], lat: pos[1] };
});
}, [measurements]);
if (markers.length === 0) return null;
return (
<>
{markers.map((mk) => (
<Marker key={mk.id} longitude={mk.lon} latitude={mk.lat} anchor="top" offset={[0, 12]}>
<button
onClick={(e) => {
e.stopPropagation();
removeMeasurement(mk.id);
}}
className="px-2 py-0.5 text-[11px] font-semibold text-white bg-[rgba(239,68,68,0.85)] hover:bg-[rgba(239,68,68,1)] rounded shadow-lg border border-[rgba(255,255,255,0.2)] cursor-pointer font-korean"
>
</button>
</Marker>
))}
</>
);
}

파일 보기

@ -0,0 +1,157 @@
import { ScatterplotLayer, PathLayer, TextLayer, PolygonLayer } from '@deck.gl/layers';
import type { Layer as DeckLayer } from '@deck.gl/core';
import type { MeasurePoint, MeasureResult } from '../../store/mapStore';
import { formatDistance, formatArea } from '../../utils/geo';
const CYAN = [6, 182, 212, 220] as const;
const CYAN_FILL = [6, 182, 212, 60] as const;
const WHITE = [255, 255, 255, 255] as const;
function midpoint(a: MeasurePoint, b: MeasurePoint): [number, number] {
return [(a.lon + b.lon) / 2, (a.lat + b.lat) / 2];
}
export function centroid(pts: MeasurePoint[]): [number, number] {
const n = pts.length;
return [
pts.reduce((s, p) => s + p.lon, 0) / n,
pts.reduce((s, p) => s + p.lat, 0) / n,
];
}
function toPos(pt: MeasurePoint): [number, number] {
return [pt.lon, pt.lat];
}
export function midpointOf(a: MeasurePoint, b: MeasurePoint): [number, number] {
return midpoint(a, b);
}
export function buildMeasureLayers(
measureInProgress: MeasurePoint[],
measureMode: 'distance' | 'area' | null,
measurements: MeasureResult[],
): DeckLayer[] {
const layers: DeckLayer[] = [];
// 진행 중인 점들
if (measureInProgress.length > 0) {
layers.push(
new ScatterplotLayer({
id: 'measure-in-progress-points',
data: measureInProgress,
getPosition: (d: MeasurePoint) => toPos(d),
getRadius: 6,
getFillColor: [...CYAN],
getLineColor: [...WHITE],
getLineWidth: 2,
radiusUnits: 'pixels',
lineWidthUnits: 'pixels',
stroked: true,
}),
);
if (measureInProgress.length >= 2) {
layers.push(
new PathLayer({
id: 'measure-in-progress-path',
data: [{ path: measureInProgress.map(toPos) }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [...CYAN],
getWidth: 2,
widthUnits: 'pixels',
}),
);
}
// 면적 모드: 첫 점으로 돌아가는 점선 미리보기
if (measureMode === 'area' && measureInProgress.length >= 3) {
const first = measureInProgress[0];
const last = measureInProgress[measureInProgress.length - 1];
layers.push(
new PathLayer({
id: 'measure-area-close-preview',
data: [{ path: [toPos(last), toPos(first)] }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [6, 182, 212, 100],
getWidth: 2,
widthUnits: 'pixels',
}),
);
}
}
// 완료된 측정 결과들
for (const m of measurements) {
if (m.mode === 'distance') {
layers.push(
new PathLayer({
id: `${m.id}-line`,
data: [{ path: m.points.map(toPos) }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [...CYAN],
getWidth: 3,
widthUnits: 'pixels',
}),
new ScatterplotLayer({
id: `${m.id}-points`,
data: m.points,
getPosition: (d: MeasurePoint) => toPos(d),
getRadius: 6,
getFillColor: [...CYAN],
getLineColor: [...WHITE],
getLineWidth: 2,
radiusUnits: 'pixels',
lineWidthUnits: 'pixels',
stroked: true,
}),
new TextLayer({
id: `${m.id}-label`,
data: [{ position: midpoint(m.points[0], m.points[1]), text: formatDistance(m.value) }],
getPosition: (d: { position: [number, number] }) => d.position,
getText: (d: { text: string }) => d.text,
getSize: 14,
getColor: [255, 255, 255, 255],
getTextAnchor: 'middle',
getAlignmentBaseline: 'center',
fontFamily: 'Pretendard, sans-serif',
fontWeight: 700,
outlineWidth: 3,
outlineColor: [0, 0, 0, 200],
billboard: true,
}),
);
} else {
layers.push(
new PolygonLayer({
id: `${m.id}-polygon`,
data: [{ polygon: m.points.map(toPos) }],
getPolygon: (d: { polygon: [number, number][] }) => d.polygon,
getFillColor: [...CYAN_FILL],
getLineColor: [...CYAN],
getLineWidth: 2,
lineWidthUnits: 'pixels',
stroked: true,
filled: true,
}),
new TextLayer({
id: `${m.id}-label`,
data: [{ position: centroid(m.points), text: formatArea(m.value) }],
getPosition: (d: { position: [number, number] }) => d.position,
getText: (d: { text: string }) => d.text,
getSize: 14,
getColor: [255, 255, 255, 255],
getTextAnchor: 'middle',
getAlignmentBaseline: 'center',
fontFamily: 'Pretendard, sans-serif',
fontWeight: 700,
outlineWidth: 3,
outlineColor: [0, 0, 0, 200],
billboard: true,
}),
);
}
}
return layers;
}

파일 보기

@ -0,0 +1,35 @@
import { useCallback } from 'react';
import { useMapStore } from '../store/mapStore';
import { haversineDistance } from '../utils/geo';
const CLOSE_THRESHOLD_M = 200;
export function useMeasureTool() {
const measureMode = useMapStore((s) => s.measureMode);
const measureInProgress = useMapStore((s) => s.measureInProgress);
const addMeasurePoint = useMapStore((s) => s.addMeasurePoint);
const commitAreaMeasurement = useMapStore((s) => s.commitAreaMeasurement);
const handleMeasureClick = useCallback(
(lon: number, lat: number) => {
if (!measureMode) return;
const pt = { lat, lon };
// 면적 모드: 첫 점 근처 클릭 시 다각형 닫기
if (measureMode === 'area' && measureInProgress.length >= 3) {
const first = measureInProgress[0];
const dist = haversineDistance(first, pt);
if (dist < CLOSE_THRESHOLD_M) {
commitAreaMeasurement();
return;
}
}
addMeasurePoint(pt);
},
[measureMode, measureInProgress, addMeasurePoint, commitAreaMeasurement],
);
return { handleMeasureClick, measureMode };
}

파일 보기

@ -1,4 +1,5 @@
import { create } from 'zustand' import { create } from 'zustand'
import { haversineDistance, polygonAreaKm2 } from '../utils/geo'
interface MapToggles { interface MapToggles {
s57: boolean; s57: boolean;
@ -7,15 +8,84 @@ interface MapToggles {
satellite: boolean; satellite: boolean;
} }
export type MeasureMode = 'distance' | 'area' | null;
export interface MeasurePoint {
lat: number;
lon: number;
}
export interface MeasureResult {
id: string;
mode: 'distance' | 'area';
points: MeasurePoint[];
value: number; // distance(m) or area(km²)
}
interface MapState { interface MapState {
mapToggles: MapToggles; mapToggles: MapToggles;
toggleMap: (key: keyof MapToggles) => void; toggleMap: (key: keyof MapToggles) => void;
// 측정
measureMode: MeasureMode;
measureInProgress: MeasurePoint[];
measurements: MeasureResult[];
setMeasureMode: (mode: MeasureMode) => void;
addMeasurePoint: (pt: MeasurePoint) => void;
commitAreaMeasurement: () => void;
removeMeasurement: (id: string) => void;
clearAllMeasurements: () => void;
} }
export const useMapStore = create<MapState>((set) => ({ let measureIdCounter = 0;
export const useMapStore = create<MapState>((set, get) => ({
mapToggles: { s57: true, s101: false, threeD: false, satellite: false }, mapToggles: { s57: true, s101: false, threeD: false, satellite: false },
toggleMap: (key) => toggleMap: (key) =>
set((s) => ({ set((s) => ({
mapToggles: { ...s.mapToggles, [key]: !s.mapToggles[key] }, mapToggles: { ...s.mapToggles, [key]: !s.mapToggles[key] },
})), })),
// 측정
measureMode: null,
measureInProgress: [],
measurements: [],
setMeasureMode: (mode) =>
set({ measureMode: mode, measureInProgress: [] }),
addMeasurePoint: (pt) => {
const { measureMode, measureInProgress } = get();
if (measureMode === 'distance') {
const next = [...measureInProgress, pt];
if (next.length >= 2) {
const dist = haversineDistance(next[0], next[1]);
const id = `measure-${++measureIdCounter}`;
set((s) => ({
measurements: [...s.measurements, { id, mode: 'distance', points: [next[0], next[1]], value: dist }],
measureInProgress: [],
}));
} else {
set({ measureInProgress: next });
}
} else if (measureMode === 'area') {
set({ measureInProgress: [...measureInProgress, pt] });
}
},
commitAreaMeasurement: () => {
const { measureInProgress } = get();
if (measureInProgress.length < 3) return;
const area = polygonAreaKm2(measureInProgress);
const id = `measure-${++measureIdCounter}`;
set((s) => ({
measurements: [...s.measurements, { id, mode: 'area', points: [...measureInProgress], value: area }],
measureInProgress: [],
}));
},
removeMeasurement: (id) =>
set((s) => ({ measurements: s.measurements.filter((m) => m.id !== id) })),
clearAllMeasurements: () =>
set({ measurements: [], measureInProgress: [], measureMode: null }),
})) }))

파일 보기

@ -227,6 +227,19 @@ export function circleAreaKm2(radiusM: number): number {
return Math.PI * (radiusM / 1000) ** 2 return Math.PI * (radiusM / 1000) ** 2
} }
/** 거리 포맷 (m → "234 m" or "1.23 km") */
export function formatDistance(m: number): string {
return m >= 1000 ? `${(m / 1000).toFixed(2)} km` : `${Math.round(m)} m`;
}
/** 면적 포맷 (km² → "12,345 m²" or "0.123 km²") */
export function formatArea(km2: number): string {
if (km2 < 0.01) {
return `${Math.round(km2 * 1_000_000).toLocaleString()}`;
}
return `${km2.toFixed(3)} km²`;
}
/** 차단 시뮬레이션 실행 */ /** 차단 시뮬레이션 실행 */
export function runContainmentAnalysis( export function runContainmentAnalysis(
trajectory: Array<{ lat: number; lon: number; time: number; particle?: number }>, trajectory: Array<{ lat: number; lon: number; time: number; particle?: number }>,