Merge pull request 'release: 수역 폴리곤 오버레이 + 마커 가시성' (#111) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m45s
All checks were successful
Deploy KCG / deploy (push) Successful in 1m45s
This commit is contained in:
커밋
be38983cc5
@ -9,6 +9,19 @@ const RISK_COLORS: Record<string, string> = {
|
||||
LOW: '#22c55e',
|
||||
};
|
||||
|
||||
const RISK_LABEL: Record<string, string> = {
|
||||
CRITICAL: '긴급',
|
||||
HIGH: '경고',
|
||||
MEDIUM: '주의',
|
||||
LOW: '정상',
|
||||
};
|
||||
|
||||
const RISK_MARKER_SIZE: Record<string, number> = {
|
||||
CRITICAL: 18,
|
||||
HIGH: 14,
|
||||
MEDIUM: 12,
|
||||
};
|
||||
|
||||
const RISK_PRIORITY: Record<string, number> = {
|
||||
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 (
|
||||
<Marker key={`risk-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="center">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', pointerEvents: 'none' }}>
|
||||
{/* 선박명 */}
|
||||
{ship.name && (
|
||||
<div style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
color: '#fff',
|
||||
textShadow: '0 0 2px #000, 0 0 2px #000',
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
marginBottom: 2,
|
||||
}}>
|
||||
{ship.name}
|
||||
</div>
|
||||
)}
|
||||
{/* 삼각형 아이콘 */}
|
||||
<div style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: '5px solid transparent',
|
||||
borderRight: '5px solid transparent',
|
||||
borderBottom: `9px solid ${color}`,
|
||||
borderLeft: `${halfBase}px solid transparent`,
|
||||
borderRight: `${halfBase}px solid transparent`,
|
||||
borderBottom: `${triHeight}px solid ${color}`,
|
||||
filter: `drop-shadow(0 0 3px ${color}88)`,
|
||||
}} />
|
||||
{/* 위험도 텍스트 */}
|
||||
{/* 위험도 텍스트 (한글) */}
|
||||
<div style={{
|
||||
fontSize: 5,
|
||||
fontSize: 8,
|
||||
fontWeight: 700,
|
||||
color,
|
||||
textShadow: '0 0 2px #000, 0 0 2px #000',
|
||||
@ -193,7 +224,7 @@ export function AnalysisOverlay({ ships, analysisMap, clusters, activeFilter }:
|
||||
whiteSpace: 'nowrap',
|
||||
marginTop: 1,
|
||||
}}>
|
||||
{level}
|
||||
{RISK_LABEL[level] ?? level}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
@ -215,6 +246,20 @@ export function AnalysisOverlay({ ships, analysisMap, clusters, activeFilter }:
|
||||
return (
|
||||
<Marker key={`dark-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="center">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', pointerEvents: 'none' }}>
|
||||
{/* 선박명 */}
|
||||
{ship.name && (
|
||||
<div style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
color: '#fff',
|
||||
textShadow: '0 0 2px #000, 0 0 2px #000',
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
marginBottom: 2,
|
||||
}}>
|
||||
{ship.name}
|
||||
</div>
|
||||
)}
|
||||
{/* 보라 점선 원 */}
|
||||
<div style={{
|
||||
width: 16,
|
||||
@ -223,9 +268,9 @@ export function AnalysisOverlay({ ships, analysisMap, clusters, activeFilter }:
|
||||
border: '2px dashed #a855f7',
|
||||
boxShadow: '0 0 4px #a855f788',
|
||||
}} />
|
||||
{/* gap 라벨 */}
|
||||
{/* gap 라벨: "AIS 소실 N분" */}
|
||||
<div style={{
|
||||
fontSize: 5,
|
||||
fontSize: 8,
|
||||
fontWeight: 700,
|
||||
color: '#a855f7',
|
||||
textShadow: '0 0 2px #000, 0 0 2px #000',
|
||||
@ -233,7 +278,7 @@ export function AnalysisOverlay({ ships, analysisMap, clusters, activeFilter }:
|
||||
whiteSpace: 'nowrap',
|
||||
marginTop: 1,
|
||||
}}>
|
||||
{gapMin > 0 ? `${Math.round(gapMin)}분` : 'DARK'}
|
||||
{gapMin > 0 ? `AIS 소실 ${Math.round(gapMin)}분` : 'DARK'}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
@ -241,24 +286,43 @@ export function AnalysisOverlay({ ships, analysisMap, clusters, activeFilter }:
|
||||
})}
|
||||
|
||||
{/* GPS 스푸핑 배지 */}
|
||||
{spoofingMarkers.map(({ ship }) => (
|
||||
<Marker key={`spoof-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="bottom">
|
||||
<div style={{
|
||||
marginBottom: 14,
|
||||
fontSize: 6,
|
||||
fontWeight: 700,
|
||||
color: '#fff',
|
||||
backgroundColor: '#ef4444',
|
||||
borderRadius: 2,
|
||||
padding: '0 2px',
|
||||
textShadow: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
GPS
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
{spoofingMarkers.map(({ ship, dto }) => {
|
||||
const pct = Math.round(dto.algorithms.gpsSpoofing.spoofingScore * 100);
|
||||
return (
|
||||
<Marker key={`spoof-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="bottom">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', pointerEvents: 'none' }}>
|
||||
{/* 선박명 */}
|
||||
{ship.name && (
|
||||
<div style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
color: '#fff',
|
||||
textShadow: '0 0 2px #000, 0 0 2px #000',
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
marginBottom: 2,
|
||||
}}>
|
||||
{ship.name}
|
||||
</div>
|
||||
)}
|
||||
{/* 스푸핑 배지 */}
|
||||
<div style={{
|
||||
marginBottom: 14,
|
||||
fontSize: 8,
|
||||
fontWeight: 700,
|
||||
color: '#fff',
|
||||
backgroundColor: '#ef4444',
|
||||
borderRadius: 2,
|
||||
padding: '0 3px',
|
||||
textShadow: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{`GPS ${pct}%`}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 선단 leader 별 아이콘 */}
|
||||
{leaderShips.map(({ ship }) => (
|
||||
|
||||
93
frontend/src/components/korea/FishingZoneLayer.tsx
Normal file
93
frontend/src/components/korea/FishingZoneLayer.tsx
Normal file
@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<>
|
||||
<Source id="fishing-zones" type="geojson" data={fishingZonesData as GeoJSON.FeatureCollection}>
|
||||
<Layer
|
||||
id="fishing-zone-fill"
|
||||
type="fill"
|
||||
paint={{ 'fill-color': fillColor, 'fill-opacity': 1 }}
|
||||
/>
|
||||
<Layer
|
||||
id="fishing-zone-line"
|
||||
type="line"
|
||||
paint={{
|
||||
'line-color': lineColor,
|
||||
'line-opacity': 1,
|
||||
'line-width': 1.5,
|
||||
'line-dasharray': [4, 2],
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{labels.map(({ id, name, lng, lat }) => (
|
||||
<Marker key={`zone-${id}`} longitude={lng} latitude={lat} anchor="center">
|
||||
<div style={{
|
||||
fontSize: 10, fontWeight: 700, color: '#fff',
|
||||
textShadow: '0 0 3px #000, 0 0 3px #000',
|
||||
backgroundColor: 'rgba(0,0,0,0.45)',
|
||||
borderRadius: 3, padding: '1px 5px',
|
||||
whiteSpace: 'nowrap', pointerEvents: 'none',
|
||||
}}>
|
||||
{name}
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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 && <GovBuildingLayer />}
|
||||
{layers.nkLaunch && <NKLaunchLayer />}
|
||||
{layers.nkMissile && <NKMissileEventLayer ships={ships} />}
|
||||
{koreaFilters.illegalFishing && <FishingZoneLayer />}
|
||||
{layers.cnFishing && <ChineseFishingOverlay ships={ships} analysisMap={vesselAnalysis?.analysisMap} />}
|
||||
{vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && (
|
||||
<AnalysisOverlay
|
||||
|
||||
1
frontend/src/data/zones/fishing-zones-wgs84.json
Normal file
1
frontend/src/data/zones/fishing-zones-wgs84.json
Normal file
File diff suppressed because one or more lines are too long
@ -3,13 +3,10 @@
|
||||
|
||||
import type { Ship } from '../types';
|
||||
import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
|
||||
import { point, multiPolygon } from '@turf/helpers';
|
||||
import { point } from '@turf/helpers';
|
||||
import type { Feature, MultiPolygon } from 'geojson';
|
||||
|
||||
import zone1Data from '../data/zones/특정어업수역Ⅰ.json';
|
||||
import zone2Data from '../data/zones/특정어업수역Ⅱ.json';
|
||||
import zone3Data from '../data/zones/특정어업수역Ⅲ.json';
|
||||
import zone4Data from '../data/zones/특정어업수역Ⅳ.json';
|
||||
import fishingZonesWgs84 from '../data/zones/fishing-zones-wgs84.json';
|
||||
|
||||
/**
|
||||
* 중국 허가 업종 코드 (허가번호 접두사)
|
||||
@ -50,28 +47,6 @@ const GEAR_META: Record<FishingGearType, {
|
||||
|
||||
export { GEAR_META as GEAR_LABELS };
|
||||
|
||||
/**
|
||||
* EPSG:3857 → WGS84 좌표 변환
|
||||
*/
|
||||
function epsg3857ToWgs84(x: number, y: number): [number, number] {
|
||||
const lon = (x / (Math.PI * 6378137)) * 180;
|
||||
const lat = Math.atan(Math.exp(y / 6378137)) * (360 / Math.PI) - 90;
|
||||
return [lon, lat]; // GeoJSON [lng, lat] 순서
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function convertZoneToWgs84(data: any): Feature<MultiPolygon> {
|
||||
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<MultiPolygon>;
|
||||
}
|
||||
|
||||
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<string, string[]> = {
|
||||
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<MultiPolygon> }[] = [
|
||||
{ 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<MultiPolygon>,
|
||||
}));
|
||||
|
||||
/**
|
||||
* 특정어업수역 폴리곤 기반 수역 분류
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user