feat(map): 거리·면적 측정 도구 구현
TopBar 퀵메뉴에서 거리/면적 측정 모드 토글, MapView에서 클릭으로 포인트 수집 후 deck.gl 레이어로 결과를 시각화한다. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
부모
891db2a894
커밋
301df70376
@ -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} />
|
||||||
|
|||||||
40
frontend/src/common/components/map/MeasureOverlay.tsx
Normal file
40
frontend/src/common/components/map/MeasureOverlay.tsx
Normal file
@ -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>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
frontend/src/common/components/map/measureLayers.ts
Normal file
157
frontend/src/common/components/map/measureLayers.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
35
frontend/src/common/hooks/useMeasureTool.ts
Normal file
35
frontend/src/common/hooks/useMeasureTool.ts
Normal file
@ -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()} m²`;
|
||||||
|
}
|
||||||
|
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 }>,
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user