Merge pull request 'feat: 수역 폴리곤 오버레이 + 마커 가시성 개선' (#110) from feat/fishing-zone-overlay-ui into develop

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

파일 보기

@ -9,6 +9,19 @@ const RISK_COLORS: Record<string, string> = {
LOW: '#22c55e', 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> = { const RISK_PRIORITY: Record<string, number> = {
CRITICAL: 0, CRITICAL: 0,
HIGH: 1, HIGH: 1,
@ -31,9 +44,10 @@ interface AnalyzedShip {
/** 위험도 펄스 애니메이션 인라인 스타일 */ /** 위험도 펄스 애니메이션 인라인 스타일 */
function riskPulseStyle(riskLevel: string): React.CSSProperties { function riskPulseStyle(riskLevel: string): React.CSSProperties {
const color = RISK_COLORS[riskLevel] ?? RISK_COLORS['LOW']; const color = RISK_COLORS[riskLevel] ?? RISK_COLORS['LOW'];
const size = RISK_MARKER_SIZE[riskLevel] ?? 10;
return { return {
width: 10, width: size,
height: 10, height: size,
borderRadius: '50%', borderRadius: '50%',
backgroundColor: color, backgroundColor: color,
boxShadow: `0 0 6px 2px ${color}88`, boxShadow: `0 0 6px 2px ${color}88`,
@ -171,21 +185,38 @@ export function AnalysisOverlay({ ships, analysisMap, clusters, activeFilter }:
{riskMarkers.map(({ ship, dto }) => { {riskMarkers.map(({ ship, dto }) => {
const level = dto.algorithms.riskScore.level; const level = dto.algorithms.riskScore.level;
const color = RISK_COLORS[level] ?? RISK_COLORS['LOW']; 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 ( return (
<Marker key={`risk-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="center"> <Marker key={`risk-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="center">
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', pointerEvents: 'none' }}> <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={{ <div style={{
width: 0, width: 0,
height: 0, height: 0,
borderLeft: '5px solid transparent', borderLeft: `${halfBase}px solid transparent`,
borderRight: '5px solid transparent', borderRight: `${halfBase}px solid transparent`,
borderBottom: `9px solid ${color}`, borderBottom: `${triHeight}px solid ${color}`,
filter: `drop-shadow(0 0 3px ${color}88)`, filter: `drop-shadow(0 0 3px ${color}88)`,
}} /> }} />
{/* 위험도 텍스트 */} {/* 위험도 텍스트 (한글) */}
<div style={{ <div style={{
fontSize: 5, fontSize: 8,
fontWeight: 700, fontWeight: 700,
color, color,
textShadow: '0 0 2px #000, 0 0 2px #000', textShadow: '0 0 2px #000, 0 0 2px #000',
@ -193,7 +224,7 @@ export function AnalysisOverlay({ ships, analysisMap, clusters, activeFilter }:
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
marginTop: 1, marginTop: 1,
}}> }}>
{level} {RISK_LABEL[level] ?? level}
</div> </div>
</div> </div>
</Marker> </Marker>
@ -215,6 +246,20 @@ export function AnalysisOverlay({ ships, analysisMap, clusters, activeFilter }:
return ( return (
<Marker key={`dark-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="center"> <Marker key={`dark-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="center">
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', pointerEvents: 'none' }}> <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={{ <div style={{
width: 16, width: 16,
@ -223,9 +268,9 @@ export function AnalysisOverlay({ ships, analysisMap, clusters, activeFilter }:
border: '2px dashed #a855f7', border: '2px dashed #a855f7',
boxShadow: '0 0 4px #a855f788', boxShadow: '0 0 4px #a855f788',
}} /> }} />
{/* gap 라벨 */} {/* gap 라벨: "AIS 소실 N분" */}
<div style={{ <div style={{
fontSize: 5, fontSize: 8,
fontWeight: 700, fontWeight: 700,
color: '#a855f7', color: '#a855f7',
textShadow: '0 0 2px #000, 0 0 2px #000', textShadow: '0 0 2px #000, 0 0 2px #000',
@ -233,7 +278,7 @@ export function AnalysisOverlay({ ships, analysisMap, clusters, activeFilter }:
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
marginTop: 1, marginTop: 1,
}}> }}>
{gapMin > 0 ? `${Math.round(gapMin)}` : 'DARK'} {gapMin > 0 ? `AIS 소실 ${Math.round(gapMin)}` : 'DARK'}
</div> </div>
</div> </div>
</Marker> </Marker>
@ -241,24 +286,43 @@ export function AnalysisOverlay({ ships, analysisMap, clusters, activeFilter }:
})} })}
{/* GPS 스푸핑 배지 */} {/* GPS 스푸핑 배지 */}
{spoofingMarkers.map(({ ship }) => ( {spoofingMarkers.map(({ ship, dto }) => {
<Marker key={`spoof-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="bottom"> const pct = Math.round(dto.algorithms.gpsSpoofing.spoofingScore * 100);
<div style={{ return (
marginBottom: 14, <Marker key={`spoof-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="bottom">
fontSize: 6, <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', pointerEvents: 'none' }}>
fontWeight: 700, {/* 선박명 */}
color: '#fff', {ship.name && (
backgroundColor: '#ef4444', <div style={{
borderRadius: 2, fontSize: 9,
padding: '0 2px', fontWeight: 700,
textShadow: 'none', color: '#fff',
whiteSpace: 'nowrap', textShadow: '0 0 2px #000, 0 0 2px #000',
pointerEvents: 'none', textAlign: 'center',
}}> whiteSpace: 'nowrap',
GPS marginBottom: 2,
</div> }}>
</Marker> {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 별 아이콘 */} {/* 선단 leader 별 아이콘 */}
{leaderShips.map(({ ship }) => ( {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 { NKMissileEventLayer } from './NKMissileEventLayer';
import { ChineseFishingOverlay } from './ChineseFishingOverlay'; import { ChineseFishingOverlay } from './ChineseFishingOverlay';
import { AnalysisOverlay } from './AnalysisOverlay'; import { AnalysisOverlay } from './AnalysisOverlay';
import { FishingZoneLayer } from './FishingZoneLayer';
import { AnalysisStatsPanel } from './AnalysisStatsPanel'; import { AnalysisStatsPanel } from './AnalysisStatsPanel';
import { fetchKoreaInfra } from '../../services/infra'; import { fetchKoreaInfra } from '../../services/infra';
import type { PowerFacility } 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.govBuildings && <GovBuildingLayer />}
{layers.nkLaunch && <NKLaunchLayer />} {layers.nkLaunch && <NKLaunchLayer />}
{layers.nkMissile && <NKMissileEventLayer ships={ships} />} {layers.nkMissile && <NKMissileEventLayer ships={ships} />}
{koreaFilters.illegalFishing && <FishingZoneLayer />}
{layers.cnFishing && <ChineseFishingOverlay ships={ships} analysisMap={vesselAnalysis?.analysisMap} />} {layers.cnFishing && <ChineseFishingOverlay ships={ships} analysisMap={vesselAnalysis?.analysisMap} />}
{vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && ( {vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && (
<AnalysisOverlay <AnalysisOverlay

File diff suppressed because one or more lines are too long

파일 보기

@ -3,13 +3,10 @@
import type { Ship } from '../types'; import type { Ship } from '../types';
import booleanPointInPolygon from '@turf/boolean-point-in-polygon'; 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 type { Feature, MultiPolygon } from 'geojson';
import zone1Data from '../data/zones/특정어업수역Ⅰ.json'; import fishingZonesWgs84 from '../data/zones/fishing-zones-wgs84.json';
import zone2Data from '../data/zones/특정어업수역Ⅱ.json';
import zone3Data from '../data/zones/특정어업수역Ⅲ.json';
import zone4Data from '../data/zones/특정어업수역Ⅳ.json';
/** /**
* ( ) * ( )
@ -50,28 +47,6 @@ const GEAR_META: Record<FishingGearType, {
export { GEAR_META as GEAR_LABELS }; 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 type FishingZoneId = 'ZONE_I' | 'ZONE_II' | 'ZONE_III' | 'ZONE_IV' | 'OUTSIDE';
export interface FishingZoneInfo { export interface FishingZoneInfo {
@ -80,15 +55,23 @@ export interface FishingZoneInfo {
allowed: string[]; 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> }[] = [ export const ZONE_POLYGONS = fishingZonesWgs84.features.map(f => ({
{ id: 'ZONE_I', name: '수역Ⅰ(동해)', allowed: ['PS', 'FC'], geojson: convertZoneToWgs84(zone1Data) }, id: f.properties.id as FishingZoneId,
{ id: 'ZONE_II', name: '수역Ⅱ(남해)', allowed: ['PT', 'OT', 'GN', 'PS', 'FC'], geojson: convertZoneToWgs84(zone2Data) }, name: f.properties.name,
{ id: 'ZONE_III', name: '수역Ⅲ(서남해)', allowed: ['PT', 'OT', 'GN', 'PS', 'FC'], geojson: convertZoneToWgs84(zone3Data) }, allowed: ZONE_ALLOWED[f.properties.id] ?? [],
{ id: 'ZONE_IV', name: '수역Ⅳ(서해)', allowed: ['GN', 'PS', 'FC'], geojson: convertZoneToWgs84(zone4Data) }, geojson: f as unknown as Feature<MultiPolygon>,
]; }));
/** /**
* *