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:
부모
4edb8236f3
커밋
468a4a2424
@ -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>
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user