feat(korea): 작전가이드 임검침로 점선 시각화 — 해경→의심선박 루트

- 작전가이드에서 선박 클릭 시 해경 기지→선박 점선 표시
- 위험도별 색상 (CRITICAL 빨강, HIGH 노랑, MEDIUM 파랑)
- 중간 지점에 거리(NM) + 출발지→도착지 라벨
- 해경 기지: 닻() 마커, 대상 선박: 색상 원형 마커
- OpsRoute 타입 export, KoreaMap에 opsRoute prop 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nan Kyung Lee 2026-03-24 15:41:47 +09:00
부모 4edb8236f3
커밋 468a4a2424
3개의 변경된 파일82개의 추가작업 그리고 4개의 파일을 삭제

파일 보기

@ -26,6 +26,7 @@ import CollectorMonitor from './components/common/CollectorMonitor';
import { FieldAnalysisModal } from './components/korea/FieldAnalysisModal';
import { ReportModal } from './components/korea/ReportModal';
import { OpsGuideModal } from './components/korea/OpsGuideModal';
import type { OpsRoute } from './components/korea/OpsGuideModal';
import { filterFacilities } from './data/meEnergyHazardFacilities';
// 정적 데이터 카운트용 import (이미 useStaticDeckLayers에서 번들 포함됨)
import { EAST_ASIA_PORTS } from './data/ports';
@ -196,6 +197,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
const [showFieldAnalysis, setShowFieldAnalysis] = useState(false);
const [showReport, setShowReport] = useState(false);
const [showOpsGuide, setShowOpsGuide] = useState(false);
const [opsRoute, setOpsRoute] = useState<OpsRoute | null>(null);
const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST');
const [hoveredShipMmsi, setHoveredShipMmsi] = useState<string | null>(null);
const [focusShipMmsi, setFocusShipMmsi] = useState<string | null>(null);
@ -668,8 +670,9 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
{showOpsGuide && (
<OpsGuideModal
ships={koreaData.ships}
onClose={() => setShowOpsGuide(false)}
onClose={() => { setShowOpsGuide(false); setOpsRoute(null); }}
onFlyTo={(lat, lng, zoom) => setFlyToTarget({ lat, lng, zoom })}
onRouteSelect={setOpsRoute}
/>
)}
<KoreaMap
@ -688,6 +691,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
vesselAnalysis={vesselAnalysis}
externalFlyTo={flyToTarget}
onExternalFlyToDone={() => setFlyToTarget(null)}
opsRoute={opsRoute}
/>
<div className="map-overlay-left">
<LayerPanel

파일 보기

@ -62,6 +62,7 @@ interface Props {
vesselAnalysis?: UseVesselAnalysisResult;
externalFlyTo?: { lat: number; lng: number; zoom: number } | null;
onExternalFlyToDone?: () => void;
opsRoute?: { from: { lat: number; lng: number; name: string }; to: { lat: number; lng: number; name: string }; distanceNM: number; riskLevel: string } | null;
}
// MarineTraffic-style: satellite + dark ocean + nautical overlay
@ -134,7 +135,7 @@ const FILTER_I18N_KEY: Record<string, string> = {
ferryWatch: 'filters.ferryWatchMonitor',
};
export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis, externalFlyTo, onExternalFlyToDone }: Props) {
export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis, externalFlyTo, onExternalFlyToDone, opsRoute }: Props) {
const { t } = useTranslation();
const mapRef = useRef<MapRef>(null);
const [infra, setInfra] = useState<PowerFacility[]>([]);
@ -942,6 +943,61 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
onExpandedChange={setAnalysisPanelOpen}
/>
)}
{/* 작전가이드 임검침로 점선 */}
{opsRoute && (() => {
const riskColor = opsRoute.riskLevel === 'CRITICAL' ? '#ef4444' : opsRoute.riskLevel === 'HIGH' ? '#f59e0b' : '#3b82f6';
const routeGeoJson: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: [{
type: 'Feature',
properties: {},
geometry: {
type: 'LineString',
coordinates: [
[opsRoute.from.lng, opsRoute.from.lat],
[opsRoute.to.lng, opsRoute.to.lat],
],
},
}],
};
return (
<>
<Source id="ops-route-line" type="geojson" data={routeGeoJson}>
<Layer id="ops-route-dash" type="line" paint={{
'line-color': riskColor,
'line-width': 2.5,
'line-dasharray': [4, 4],
'line-opacity': 0.8,
}} />
</Source>
<Marker longitude={opsRoute.from.lng} latitude={opsRoute.from.lat} anchor="center">
<div style={{ fontSize: 18, filter: 'drop-shadow(0 0 4px rgba(0,0,0,0.8))' }}></div>
</Marker>
<Marker longitude={opsRoute.to.lng} latitude={opsRoute.to.lat} anchor="center">
<div style={{
width: 12, height: 12, borderRadius: '50%',
background: riskColor, border: '2px solid #fff',
boxShadow: `0 0 8px ${riskColor}`,
}} />
</Marker>
<Marker
longitude={(opsRoute.from.lng + opsRoute.to.lng) / 2}
latitude={(opsRoute.from.lat + opsRoute.to.lat) / 2}
anchor="bottom"
>
<div style={{
background: 'rgba(0,0,0,0.8)', padding: '2px 6px', borderRadius: 3,
border: `1px solid ${riskColor}`, fontSize: 9, color: '#fff', fontWeight: 700,
whiteSpace: 'nowrap', textAlign: 'center',
}}>
{opsRoute.distanceNM.toFixed(1)} NM
<div style={{ fontSize: 7, color: riskColor }}>{opsRoute.from.name} {opsRoute.to.name}</div>
</div>
</Marker>
</>
);
})()}
</Map>
);
}

파일 보기

@ -5,10 +5,18 @@ import type { CoastGuardFacility } from '../../services/coastGuard';
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
import { analyzeFishing, classifyFishingZone } from '../../utils/fishingAnalysis';
export interface OpsRoute {
from: { lat: number; lng: number; name: string };
to: { lat: number; lng: number; name: string; mmsi: string };
distanceNM: number;
riskLevel: 'CRITICAL' | 'HIGH' | 'MEDIUM';
}
interface Props {
ships: Ship[];
onClose: () => void;
onFlyTo?: (lat: number, lng: number, zoom: number) => void;
onRouteSelect?: (route: OpsRoute | null) => void;
}
interface SuspectVessel {
@ -29,7 +37,7 @@ function haversineNM(lat1: number, lng1: number, lat2: number, lng2: number): nu
const RISK_COLOR = { CRITICAL: '#ef4444', HIGH: '#f59e0b', MEDIUM: '#3b82f6' };
const RISK_ICON = { CRITICAL: '🔴', HIGH: '🟡', MEDIUM: '🔵' };
export function OpsGuideModal({ ships, onClose, onFlyTo }: Props) {
export function OpsGuideModal({ ships, onClose, onFlyTo, onRouteSelect }: Props) {
const [selectedKCG, setSelectedKCG] = useState<CoastGuardFacility | null>(null);
const [searchRadius, setSearchRadius] = useState(30); // NM
const [pos, setPos] = useState({ x: 60, y: 60 });
@ -212,7 +220,17 @@ export function OpsGuideModal({ ships, onClose, onFlyTo }: Props) {
borderLeft: `3px solid ${RISK_COLOR[s.riskLevel]}`,
cursor: onFlyTo ? 'pointer' : 'default',
}}
onClick={() => onFlyTo?.(s.ship.lat, s.ship.lng, 12)}
onClick={() => {
onFlyTo?.(s.ship.lat, s.ship.lng, 10);
if (selectedKCG) {
onRouteSelect?.({
from: { lat: selectedKCG.lat, lng: selectedKCG.lng, name: selectedKCG.name },
to: { lat: s.ship.lat, lng: s.ship.lng, name: s.ship.name || s.ship.mmsi, mmsi: s.ship.mmsi },
distanceNM: s.distance,
riskLevel: s.riskLevel,
});
}
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: '#64748b', minWidth: 20 }}>#{i + 1}</span>