From 301df70376a96c5c6ffdd679fe15eb1675978f72 Mon Sep 17 00:00:00 2001 From: leedano Date: Mon, 16 Mar 2026 17:52:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(map):=20=EA=B1=B0=EB=A6=AC=C2=B7=EB=A9=B4?= =?UTF-8?q?=EC=A0=81=20=EC=B8=A1=EC=A0=95=20=EB=8F=84=EA=B5=AC=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TopBar 퀵메뉴에서 거리/면적 측정 모드 토글, MapView에서 클릭으로 포인트 수집 후 deck.gl 레이어로 결과를 시각화한다. Co-Authored-By: Claude Opus 4.6 --- .../src/common/components/layout/TopBar.tsx | 41 ++++- .../src/common/components/map/MapView.tsx | 31 +++- .../common/components/map/MeasureOverlay.tsx | 40 +++++ .../common/components/map/measureLayers.ts | 157 ++++++++++++++++++ frontend/src/common/hooks/useMeasureTool.ts | 35 ++++ frontend/src/common/store/mapStore.ts | 72 +++++++- frontend/src/common/utils/geo.ts | 13 ++ 7 files changed, 380 insertions(+), 9 deletions(-) create mode 100644 frontend/src/common/components/map/MeasureOverlay.tsx create mode 100644 frontend/src/common/components/map/measureLayers.ts create mode 100644 frontend/src/common/hooks/useMeasureTool.ts diff --git a/frontend/src/common/components/layout/TopBar.tsx b/frontend/src/common/components/layout/TopBar.tsx index a1db12e..6d177a4 100755 --- a/frontend/src/common/components/layout/TopBar.tsx +++ b/frontend/src/common/components/layout/TopBar.tsx @@ -14,7 +14,16 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) { const quickMenuRef = useRef(null) const { hasPermission, user, logout } = useAuthStore() const { menuConfig, isLoaded } = useMenuStore() - const { mapToggles, toggleMap } = useMapStore() + const { mapToggles, toggleMap, measureMode, setMeasureMode } = useMapStore() + + const MAP_TABS = new Set(['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(() => { if (!isLoaded || menuConfig.length === 0) return [] @@ -148,14 +157,36 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) { {showQuickMenu && (
{/* 거리·면적 계산 */} -
+ {/*
📐 거리·면적 계산 -
-
*/} + -
diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index 3be27d9..8da4e09 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -13,6 +13,9 @@ import HydrParticleOverlay from './HydrParticleOverlay' import type { BoomLine, BoomLineCoord } from '@common/types/boomLine' import type { ReplayShip, CollisionEvent } from '@common/types/backtrack' import { createBacktrackLayers } from './BacktrackReplayOverlay' +import { buildMeasureLayers } from './measureLayers' +import { MeasureOverlay } from './MeasureOverlay' +import { useMeasureTool } from '@common/hooks/useMeasureTool' import { hexToRgba } from './mapUtils' import { useMapStore } from '@common/store/mapStore' @@ -330,7 +333,8 @@ export function MapView({ analysisCircleCenter, analysisCircleRadiusM = 0, }: MapViewProps) { - const { mapToggles } = useMapStore() + const { mapToggles, measureMode, measureInProgress, measurements } = useMapStore() + const { handleMeasureClick } = useMeasureTool() const isControlled = externalCurrentTime !== undefined const [currentPosition, setCurrentPosition] = useState<[number, number]>(DEFAULT_CENTER) const [internalCurrentTime, setInternalCurrentTime] = useState(0) @@ -342,11 +346,15 @@ export function MapView({ const handleMapClick = useCallback((e: MapLayerMouseEvent) => { const { lng, lat } = e.lngLat setCurrentPosition([lat, lng]) + if (measureMode !== null) { + handleMeasureClick(lng, lat) + return + } if (onMapClick) { onMapClick(lng, lat) } setPopupInfo(null) - }, [onMapClick]) + }, [onMapClick, measureMode, handleMeasureClick]) // 애니메이션 재생 로직 (외부 제어 모드에서는 비활성) useEffect(() => { @@ -959,6 +967,9 @@ export function MapView({ ) } + // 거리/면적 측정 레이어 + result.push(...buildMeasureLayers(measureInProgress, measureMode, measurements)) + return result }, [ oilTrajectory, currentTime, selectedModels, @@ -967,6 +978,7 @@ export function MapView({ sensitiveResources, centerPoints, windData, showWind, showBeached, showTimeLabel, simulationStartTime, analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM, + measureInProgress, measureMode, measurements, ]) // 3D 모드에 따른 지도 스타일 전환 @@ -982,7 +994,7 @@ export function MapView({ }} mapStyle={currentMapStyle} className="w-full h-full" - style={{ cursor: (isSelectingLocation || drawAnalysisMode !== null) ? 'crosshair' : 'grab' }} + style={{ cursor: (isSelectingLocation || drawAnalysisMode !== null || measureMode !== null) ? 'crosshair' : 'grab' }} onClick={handleMapClick} attributionControl={false} preserveDrawingBuffer={true} @@ -1056,6 +1068,9 @@ export function MapView({ )} + {/* 측정 결과 지우기 버튼 */} + + {/* 커스텀 줌 컨트롤 */} @@ -1076,6 +1091,16 @@ export function MapView({ {!analysisCircleCenter ? '원 분석 모드 — 중심점을 클릭하세요' : '반경 지점을 클릭하세요'}
)} + {measureMode === 'distance' && ( +
+ 거리 재기 — {measureInProgress.length === 0 ? '시작점을 클릭하세요' : '끝점을 클릭하세요'} +
+ )} + {measureMode === 'area' && ( +
+ 면적 재기 — 꼭짓점을 클릭하세요 ({measureInProgress.length}개){measureInProgress.length >= 3 && ' → 첫 번째 점 근처를 클릭하면 닫힙니다'} +
+ )} {/* 기상청 연계 정보 */} diff --git a/frontend/src/common/components/map/MeasureOverlay.tsx b/frontend/src/common/components/map/MeasureOverlay.tsx new file mode 100644 index 0000000..59a90fd --- /dev/null +++ b/frontend/src/common/components/map/MeasureOverlay.tsx @@ -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) => ( + + + + ))} + + ); +} diff --git a/frontend/src/common/components/map/measureLayers.ts b/frontend/src/common/components/map/measureLayers.ts new file mode 100644 index 0000000..270923f --- /dev/null +++ b/frontend/src/common/components/map/measureLayers.ts @@ -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; +} diff --git a/frontend/src/common/hooks/useMeasureTool.ts b/frontend/src/common/hooks/useMeasureTool.ts new file mode 100644 index 0000000..1ed0c25 --- /dev/null +++ b/frontend/src/common/hooks/useMeasureTool.ts @@ -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 }; +} diff --git a/frontend/src/common/store/mapStore.ts b/frontend/src/common/store/mapStore.ts index a5f939c..aaa7821 100644 --- a/frontend/src/common/store/mapStore.ts +++ b/frontend/src/common/store/mapStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand' +import { haversineDistance, polygonAreaKm2 } from '../utils/geo' interface MapToggles { s57: boolean; @@ -7,15 +8,84 @@ interface MapToggles { 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 { mapToggles: MapToggles; 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((set) => ({ +let measureIdCounter = 0; + +export const useMapStore = create((set, get) => ({ mapToggles: { s57: true, s101: false, threeD: false, satellite: false }, toggleMap: (key) => set((s) => ({ 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 }), })) diff --git a/frontend/src/common/utils/geo.ts b/frontend/src/common/utils/geo.ts index 47d3327..604486b 100755 --- a/frontend/src/common/utils/geo.ts +++ b/frontend/src/common/utils/geo.ts @@ -227,6 +227,19 @@ export function circleAreaKm2(radiusM: number): number { 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()} m²`; + } + return `${km2.toFixed(3)} km²`; +} + /** 차단 시뮬레이션 실행 */ export function runContainmentAnalysis( trajectory: Array<{ lat: number; lon: number; time: number; particle?: number }>,