Merge pull request 'release: 수역 폴리곤 오버레이 + 마커 가시성' (#111) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m45s

This commit is contained in:
htlee 2026-03-20 14:05:54 +09:00
커밋 be38983cc5
5개의 변경된 파일206개의 추가작업 그리고 63개의 파일을 삭제

파일 보기

@ -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 }) => (

파일 보기

@ -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

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>,
}));
/**
*