diff --git a/frontend/src/components/korea/AnalysisOverlay.tsx b/frontend/src/components/korea/AnalysisOverlay.tsx index e8b5770..a24b655 100644 --- a/frontend/src/components/korea/AnalysisOverlay.tsx +++ b/frontend/src/components/korea/AnalysisOverlay.tsx @@ -9,6 +9,19 @@ const RISK_COLORS: Record = { LOW: '#22c55e', }; +const RISK_LABEL: Record = { + CRITICAL: '긴급', + HIGH: '경고', + MEDIUM: '주의', + LOW: '정상', +}; + +const RISK_MARKER_SIZE: Record = { + CRITICAL: 18, + HIGH: 14, + MEDIUM: 12, +}; + const RISK_PRIORITY: Record = { CRITICAL: 0, HIGH: 1, @@ -31,9 +44,10 @@ interface AnalyzedShip { /** 위험도 펄스 애니메이션 인라인 스타일 */ function riskPulseStyle(riskLevel: string): React.CSSProperties { const color = RISK_COLORS[riskLevel] ?? RISK_COLORS['LOW']; + const size = RISK_MARKER_SIZE[riskLevel] ?? 10; return { - width: 10, - height: 10, + width: size, + height: size, borderRadius: '50%', backgroundColor: color, boxShadow: `0 0 6px 2px ${color}88`, @@ -171,21 +185,38 @@ export function AnalysisOverlay({ ships, analysisMap, clusters, activeFilter }: {riskMarkers.map(({ ship, dto }) => { const level = dto.algorithms.riskScore.level; const color = RISK_COLORS[level] ?? RISK_COLORS['LOW']; + const size = RISK_MARKER_SIZE[level] ?? 12; + const halfBase = Math.round(size * 0.5); + const triHeight = Math.round(size * 0.9); return (
+ {/* 선박명 */} + {ship.name && ( +
+ {ship.name} +
+ )} {/* 삼각형 아이콘 */}
- {/* 위험도 텍스트 */} + {/* 위험도 텍스트 (한글) */}
- {level} + {RISK_LABEL[level] ?? level}
@@ -215,6 +246,20 @@ export function AnalysisOverlay({ ships, analysisMap, clusters, activeFilter }: return (
+ {/* 선박명 */} + {ship.name && ( +
+ {ship.name} +
+ )} {/* 보라 점선 원 */}
- {/* gap 라벨 */} + {/* gap 라벨: "AIS 소실 N분" */}
- {gapMin > 0 ? `${Math.round(gapMin)}분` : 'DARK'} + {gapMin > 0 ? `AIS 소실 ${Math.round(gapMin)}분` : 'DARK'}
@@ -241,24 +286,43 @@ export function AnalysisOverlay({ ships, analysisMap, clusters, activeFilter }: })} {/* GPS 스푸핑 배지 */} - {spoofingMarkers.map(({ ship }) => ( - -
- GPS -
-
- ))} + {spoofingMarkers.map(({ ship, dto }) => { + const pct = Math.round(dto.algorithms.gpsSpoofing.spoofingScore * 100); + return ( + +
+ {/* 선박명 */} + {ship.name && ( +
+ {ship.name} +
+ )} + {/* 스푸핑 배지 */} +
+ {`GPS ${pct}%`} +
+
+
+ ); + })} {/* 선단 leader 별 아이콘 */} {leaderShips.map(({ ship }) => ( diff --git a/frontend/src/components/korea/FishingZoneLayer.tsx b/frontend/src/components/korea/FishingZoneLayer.tsx new file mode 100644 index 0000000..0d521f1 --- /dev/null +++ b/frontend/src/components/korea/FishingZoneLayer.tsx @@ -0,0 +1,93 @@ +import { useMemo } from 'react'; +import { Source, Layer, Marker } from 'react-map-gl/maplibre'; +import fishingZonesData from '../../data/zones/fishing-zones-wgs84.json'; + +const ZONE_FILL: Record = { + ZONE_I: 'rgba(59, 130, 246, 0.15)', + ZONE_II: 'rgba(16, 185, 129, 0.15)', + ZONE_III: 'rgba(245, 158, 11, 0.15)', + ZONE_IV: 'rgba(239, 68, 68, 0.15)', +}; + +const ZONE_LINE: Record = { + ZONE_I: 'rgba(59, 130, 246, 0.6)', + ZONE_II: 'rgba(16, 185, 129, 0.6)', + ZONE_III: 'rgba(245, 158, 11, 0.6)', + ZONE_IV: 'rgba(239, 68, 68, 0.6)', +}; + +/** 폴리곤 중심점 (좌표 평균) */ +function centroid(coordinates: number[][][][]): [number, number] { + let sLng = 0, sLat = 0, n = 0; + for (const poly of coordinates) { + for (const ring of poly) { + for (const [lng, lat] of ring) { + sLng += lng; sLat += lat; n++; + } + } + } + return n > 0 ? [sLng / n, sLat / n] : [0, 0]; +} + +const fillColor = [ + 'match', ['get', 'id'], + 'ZONE_I', ZONE_FILL.ZONE_I, + 'ZONE_II', ZONE_FILL.ZONE_II, + 'ZONE_III', ZONE_FILL.ZONE_III, + 'ZONE_IV', ZONE_FILL.ZONE_IV, + 'rgba(0,0,0,0)', +] as maplibregl.ExpressionSpecification; + +const lineColor = [ + 'match', ['get', 'id'], + 'ZONE_I', ZONE_LINE.ZONE_I, + 'ZONE_II', ZONE_LINE.ZONE_II, + 'ZONE_III', ZONE_LINE.ZONE_III, + 'ZONE_IV', ZONE_LINE.ZONE_IV, + 'rgba(0,0,0,0)', +] as maplibregl.ExpressionSpecification; + +export function FishingZoneLayer() { + const labels = useMemo(() => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fishingZonesData.features.map((f: any) => { + const [lng, lat] = centroid(f.geometry.coordinates); + return { id: f.properties.id as string, name: f.properties.name as string, lng, lat }; + }), []); + + return ( + <> + + + + + + {labels.map(({ id, name, lng, lat }) => ( + +
+ {name} +
+
+ ))} + + ); +} diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index cea50a3..f0eb4b4 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -22,6 +22,7 @@ import { NKLaunchLayer } from './NKLaunchLayer'; import { NKMissileEventLayer } from './NKMissileEventLayer'; import { ChineseFishingOverlay } from './ChineseFishingOverlay'; import { AnalysisOverlay } from './AnalysisOverlay'; +import { FishingZoneLayer } from './FishingZoneLayer'; import { AnalysisStatsPanel } from './AnalysisStatsPanel'; import { fetchKoreaInfra } from '../../services/infra'; import type { PowerFacility } from '../../services/infra'; @@ -272,6 +273,7 @@ export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, curre {layers.govBuildings && } {layers.nkLaunch && } {layers.nkMissile && } + {koreaFilters.illegalFishing && } {layers.cnFishing && } {vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && ( { - const feat = data.features[0]; - const multiCoords: number[][][][] = feat.geometry.coordinates.map( - (poly: number[][][]) => poly.map( - (ring: number[][]) => ring.map(([x, y]: number[]) => epsg3857ToWgs84(x, y)), - ), - ); - return multiPolygon(multiCoords).geometry - ? { type: 'Feature', properties: {}, geometry: { type: 'MultiPolygon', coordinates: multiCoords } } - : multiPolygon(multiCoords) as unknown as Feature; -} - export type FishingZoneId = 'ZONE_I' | 'ZONE_II' | 'ZONE_III' | 'ZONE_IV' | 'OUTSIDE'; export interface FishingZoneInfo { @@ -80,15 +55,23 @@ export interface FishingZoneInfo { allowed: string[]; } +/** 수역별 허가 업종 */ +const ZONE_ALLOWED: Record = { + ZONE_I: ['PS', 'FC'], + ZONE_II: ['PT', 'OT', 'GN', 'PS', 'FC'], + ZONE_III: ['PT', 'OT', 'GN', 'PS', 'FC'], + ZONE_IV: ['GN', 'PS', 'FC'], +}; + /** - * 특정어업수역 Ⅰ~Ⅳ 폴리곤 (WGS84 변환 캐시) + * 특정어업수역 Ⅰ~Ⅳ 폴리곤 (사전 변환된 WGS84 GeoJSON) */ -const ZONE_POLYGONS: { id: FishingZoneId; name: string; allowed: string[]; geojson: Feature }[] = [ - { id: 'ZONE_I', name: '수역Ⅰ(동해)', allowed: ['PS', 'FC'], geojson: convertZoneToWgs84(zone1Data) }, - { id: 'ZONE_II', name: '수역Ⅱ(남해)', allowed: ['PT', 'OT', 'GN', 'PS', 'FC'], geojson: convertZoneToWgs84(zone2Data) }, - { id: 'ZONE_III', name: '수역Ⅲ(서남해)', allowed: ['PT', 'OT', 'GN', 'PS', 'FC'], geojson: convertZoneToWgs84(zone3Data) }, - { id: 'ZONE_IV', name: '수역Ⅳ(서해)', allowed: ['GN', 'PS', 'FC'], geojson: convertZoneToWgs84(zone4Data) }, -]; +export const ZONE_POLYGONS = fishingZonesWgs84.features.map(f => ({ + id: f.properties.id as FishingZoneId, + name: f.properties.name, + allowed: ZONE_ALLOWED[f.properties.id] ?? [], + geojson: f as unknown as Feature, +})); /** * 특정어업수역 폴리곤 기반 수역 분류