perf: 렌더링 성능 최적화 + 환적 Python 이관 + 중국어선감시 통합 (#158)
This commit is contained in:
부모
f4ee67a71a
커밋
2511a33444
@ -39,6 +39,7 @@ public class VesselAnalysisDto {
|
|||||||
private ClusterInfo cluster;
|
private ClusterInfo cluster;
|
||||||
private FleetRoleInfo fleetRole;
|
private FleetRoleInfo fleetRole;
|
||||||
private RiskScoreInfo riskScore;
|
private RiskScoreInfo riskScore;
|
||||||
|
private TransshipInfo transship;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@ -99,6 +100,15 @@ public class VesselAnalysisDto {
|
|||||||
private String level;
|
private String level;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public static class TransshipInfo {
|
||||||
|
private Boolean isSuspect;
|
||||||
|
private String pairMmsi;
|
||||||
|
private Integer durationMin;
|
||||||
|
}
|
||||||
|
|
||||||
public static VesselAnalysisDto from(VesselAnalysisResult r) {
|
public static VesselAnalysisDto from(VesselAnalysisResult r) {
|
||||||
return VesselAnalysisDto.builder()
|
return VesselAnalysisDto.builder()
|
||||||
.mmsi(r.getMmsi())
|
.mmsi(r.getMmsi())
|
||||||
@ -141,6 +151,11 @@ public class VesselAnalysisDto {
|
|||||||
.score(r.getRiskScore())
|
.score(r.getRiskScore())
|
||||||
.level(r.getRiskLevel())
|
.level(r.getRiskLevel())
|
||||||
.build())
|
.build())
|
||||||
|
.transship(TransshipInfo.builder()
|
||||||
|
.isSuspect(r.getIsTransshipSuspect())
|
||||||
|
.pairMmsi(r.getTransshipPairMmsi())
|
||||||
|
.durationMin(r.getTransshipDurationMin())
|
||||||
|
.build())
|
||||||
.build())
|
.build())
|
||||||
.features(r.getFeatures())
|
.features(r.getFeatures())
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@ -76,6 +76,14 @@ public class VesselAnalysisResult {
|
|||||||
@Column(length = 20)
|
@Column(length = 20)
|
||||||
private String riskLevel;
|
private String riskLevel;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Boolean isTransshipSuspect;
|
||||||
|
|
||||||
|
@Column(length = 15)
|
||||||
|
private String transshipPairMmsi;
|
||||||
|
|
||||||
|
private Integer transshipDurationMin;
|
||||||
|
|
||||||
@JdbcTypeCode(SqlTypes.JSON)
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
@Column(columnDefinition = "jsonb")
|
@Column(columnDefinition = "jsonb")
|
||||||
private Map<String, Double> features;
|
private Map<String, Double> features;
|
||||||
@ -94,5 +102,8 @@ public class VesselAnalysisResult {
|
|||||||
if (isLeader == null) {
|
if (isLeader == null) {
|
||||||
isLeader = false;
|
isLeader = false;
|
||||||
}
|
}
|
||||||
|
if (isTransshipSuspect == null) {
|
||||||
|
isTransshipSuspect = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
7
database/migration/008_transshipment.sql
Normal file
7
database/migration/008_transshipment.sql
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
-- 008: 환적 의심 탐지 필드 추가
|
||||||
|
SET search_path TO kcg, public;
|
||||||
|
|
||||||
|
ALTER TABLE vessel_analysis_results
|
||||||
|
ADD COLUMN IF NOT EXISTS is_transship_suspect BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN IF NOT EXISTS transship_pair_mmsi VARCHAR(15) DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS transship_duration_min INTEGER DEFAULT 0;
|
||||||
@ -4,6 +4,28 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 환적탐지 Python 이관: 프론트엔드 O(n²) 근접탐지 → 서버사이드 그리드 공간인덱스 O(n log n)
|
||||||
|
- 필터 배지 클릭 → 대상 선박 목록 패널 (MMSI/이름/국적/유형/속도) + CSV 다운로드
|
||||||
|
- 중국어선감시 KoreaFilters 통합: 다른 감시 탭과 동일한 선박 비활성화/배지/카운트 동작
|
||||||
|
- 중국 어구그룹 감시 배지: 어구그룹 수(고유 모선명) 기준 집계
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- deck.gl updateTriggers 적용: 줌 변경 시 레이어 accessor 재평가 최소화
|
||||||
|
- 선박 카테고리/국적 토글: JS-level 배열 필터링 → MapLibre GPU-side filter 표현식
|
||||||
|
- Ship.mtCategory/natGroup 사전 계산: Set.has() O(1) 필터 룩업 (getMarineTrafficCategory 매번 호출 제거)
|
||||||
|
- LIVE 모드: currentTime 의존성 분리 → 매초 선박 재계산 제거
|
||||||
|
- 분석 레이어 데이터/스타일 useMemo 분리: 줌 변경 시 ships 필터링 스킵
|
||||||
|
- SVG 데이터 URI 모듈 레벨 캐싱
|
||||||
|
|
||||||
|
### 수정
|
||||||
|
- 비허가 어구 그룹: 2개 이상일 때만 그룹 탐지/폴리곤 생성
|
||||||
|
- 한국 필터 토글 시 선박 표시 복원 (anyKoreaFilterOn 조건 분기)
|
||||||
|
- 필터별 개별 탐지 카운트 (합산 → 탭별 분리)
|
||||||
|
- 헤더 1행 배치 (flex-wrap:nowrap), 이란 mode-toggle 좌측/지도 모드 중앙
|
||||||
|
- onPick useCallback 안정화 (매 렌더 28개 정적 레이어 재생성 방지)
|
||||||
|
- 감시 목록 Flag 빈값 표기: '??' → '-'
|
||||||
|
|
||||||
## [2026-03-23.3]
|
## [2026-03-23.3]
|
||||||
|
|
||||||
### 변경
|
### 변경
|
||||||
|
|||||||
@ -1991,17 +1991,34 @@
|
|||||||
color: var(--kcg-muted) !important;
|
color: var(--kcg-muted) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#dashboard-header-slot {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
#dashboard-header-slot > .mode-toggle-left {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ======================== */
|
/* ======================== */
|
||||||
/* Mode Toggle (LIVE/REPLAY) */
|
/* Mode Toggle (LIVE/REPLAY) */
|
||||||
/* ======================== */
|
/* ======================== */
|
||||||
.mode-toggle {
|
.mode-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
background: var(--kcg-subtle);
|
background: var(--kcg-subtle);
|
||||||
border: 1px solid var(--kcg-border);
|
border: 1px solid var(--kcg-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
.mode-toggle::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
.mode-btn {
|
.mode-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -121,7 +121,7 @@ const IranDashboard = ({
|
|||||||
<>
|
<>
|
||||||
{headerSlot && createPortal(
|
{headerSlot && createPortal(
|
||||||
<>
|
<>
|
||||||
<div className="mode-toggle">
|
<div className="mode-toggle mode-toggle-left">
|
||||||
<div className="flex items-center gap-1.5 font-mono text-[11px] text-kcg-danger bg-kcg-danger-bg border border-kcg-danger/30 rounded-[6px] px-2.5 py-1 font-bold animate-pulse">
|
<div className="flex items-center gap-1.5 font-mono text-[11px] text-kcg-danger bg-kcg-danger-bg border border-kcg-danger/30 rounded-[6px] px-2.5 py-1 font-bold animate-pulse">
|
||||||
<span className="text-[13px]">⚔️</span>
|
<span className="text-[13px]">⚔️</span>
|
||||||
D+{Math.floor((currentTime - new Date('2026-03-01T00:00:00Z').getTime()) / (1000 * 60 * 60 * 24))}
|
D+{Math.floor((currentTime - new Date('2026-03-01T00:00:00Z').getTime()) / (1000 * 60 * 60 * 24))}
|
||||||
|
|||||||
@ -304,12 +304,17 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
|||||||
const outZone: { name: string; parent: Ship | null; gears: Ship[] }[] = [];
|
const outZone: { name: string; parent: Ship | null; gears: Ship[] }[] = [];
|
||||||
for (const [name, { parent, gears }] of gearGroupMap) {
|
for (const [name, { parent, gears }] of gearGroupMap) {
|
||||||
const anchor = parent ?? gears[0];
|
const anchor = parent ?? gears[0];
|
||||||
if (!anchor) { outZone.push({ name, parent, gears }); continue; }
|
if (!anchor) {
|
||||||
|
// 비허가 어구: 2개 이상일 때만 그룹으로 탐지
|
||||||
|
if (gears.length >= 2) outZone.push({ name, parent, gears });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const zoneInfo = classifyFishingZone(anchor.lat, anchor.lng);
|
const zoneInfo = classifyFishingZone(anchor.lat, anchor.lng);
|
||||||
if (zoneInfo.zone !== 'OUTSIDE') {
|
if (zoneInfo.zone !== 'OUTSIDE') {
|
||||||
inZone.push({ name, parent, gears, zone: zoneInfo.name });
|
inZone.push({ name, parent, gears, zone: zoneInfo.name });
|
||||||
} else {
|
} else {
|
||||||
outZone.push({ name, parent, gears });
|
// 비허가 어구: 2개 이상일 때만 그룹으로 탐지
|
||||||
|
if (gears.length >= 2) outZone.push({ name, parent, gears });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
inZone.sort((a, b) => b.gears.length - a.gears.length);
|
inZone.sort((a, b) => b.gears.length - a.gears.length);
|
||||||
@ -318,10 +323,15 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
|||||||
}, [gearGroupMap]);
|
}, [gearGroupMap]);
|
||||||
|
|
||||||
// 어구 클러스터 GeoJSON (수역 내: 붉은색, 수역 외: 오렌지)
|
// 어구 클러스터 GeoJSON (수역 내: 붉은색, 수역 외: 오렌지)
|
||||||
|
// 비허가 어구(outZone)는 2개 이상만 폴리곤 생성
|
||||||
const gearClusterGeoJson = useMemo((): GeoJSON => {
|
const gearClusterGeoJson = useMemo((): GeoJSON => {
|
||||||
const inZoneNames = new Set(inZoneGearGroups.map(g => g.name));
|
const inZoneNames = new Set(inZoneGearGroups.map(g => g.name));
|
||||||
|
const outZoneNames = new Set(outZoneGearGroups.map(g => g.name));
|
||||||
const features: GeoJSON.Feature[] = [];
|
const features: GeoJSON.Feature[] = [];
|
||||||
for (const [parentName, { parent, gears }] of gearGroupMap) {
|
for (const [parentName, { parent, gears }] of gearGroupMap) {
|
||||||
|
// 비허가(outZone) 1개짜리는 폴리곤에서 제외
|
||||||
|
const isInZone = inZoneNames.has(parentName);
|
||||||
|
if (!isInZone && !outZoneNames.has(parentName)) continue;
|
||||||
const points: [number, number][] = gears.map(g => [g.lng, g.lat]);
|
const points: [number, number][] = gears.map(g => [g.lng, g.lat]);
|
||||||
if (parent) points.push([parent.lng, parent.lat]);
|
if (parent) points.push([parent.lng, parent.lat]);
|
||||||
if (points.length < 3) continue;
|
if (points.length < 3) continue;
|
||||||
@ -330,12 +340,12 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
|||||||
padded.push(padded[0]);
|
padded.push(padded[0]);
|
||||||
features.push({
|
features.push({
|
||||||
type: 'Feature',
|
type: 'Feature',
|
||||||
properties: { name: parentName, gearCount: gears.length, inZone: inZoneNames.has(parentName) ? 1 : 0 },
|
properties: { name: parentName, gearCount: gears.length, inZone: isInZone ? 1 : 0 },
|
||||||
geometry: { type: 'Polygon', coordinates: [padded] },
|
geometry: { type: 'Polygon', coordinates: [padded] },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { type: 'FeatureCollection', features };
|
return { type: 'FeatureCollection', features };
|
||||||
}, [gearGroupMap, inZoneGearGroups]);
|
}, [gearGroupMap, inZoneGearGroups, outZoneGearGroups]);
|
||||||
|
|
||||||
const handleGearGroupZoom = useCallback((parentName: string) => {
|
const handleGearGroupZoom = useCallback((parentName: string) => {
|
||||||
setSelectedGearGroup(prev => prev === parentName ? null : parentName);
|
setSelectedGearGroup(prev => prev === parentName ? null : parentName);
|
||||||
|
|||||||
@ -159,7 +159,6 @@ export const KoreaDashboard = ({
|
|||||||
koreaData.visibleShips,
|
koreaData.visibleShips,
|
||||||
currentTime,
|
currentTime,
|
||||||
vesselAnalysis.analysisMap,
|
vesselAnalysis.analysisMap,
|
||||||
koreaLayers.cnFishing,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTabChange = useCallback((_tab: DashboardTab) => {
|
const handleTabChange = useCallback((_tab: DashboardTab) => {
|
||||||
@ -198,8 +197,8 @@ export const KoreaDashboard = ({
|
|||||||
onClick={() => koreaFiltersResult.setFilter('ferryWatch', !koreaFiltersResult.filters.ferryWatch)} title={t('filters.ferryWatch')}>
|
onClick={() => koreaFiltersResult.setFilter('ferryWatch', !koreaFiltersResult.filters.ferryWatch)} title={t('filters.ferryWatch')}>
|
||||||
<span className="text-[11px]">🚢</span>{t('filters.ferryWatch')}
|
<span className="text-[11px]">🚢</span>{t('filters.ferryWatch')}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={`mode-btn ${koreaLayers.cnFishing ? 'active live' : ''}`}
|
<button type="button" className={`mode-btn ${koreaFiltersResult.filters.cnFishing ? 'active live' : ''}`}
|
||||||
onClick={() => toggleKoreaLayer('cnFishing')} title="중국어선감시">
|
onClick={() => koreaFiltersResult.setFilter('cnFishing', !koreaFiltersResult.filters.cnFishing)} title="중국어선감시">
|
||||||
<span className="text-[11px]">🎣</span>중국어선감시
|
<span className="text-[11px]">🎣</span>중국어선감시
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={`mode-btn ${showFieldAnalysis ? 'active' : ''}`}
|
<button type="button" className={`mode-btn ${showFieldAnalysis ? 'active' : ''}`}
|
||||||
@ -229,7 +228,7 @@ export const KoreaDashboard = ({
|
|||||||
)}
|
)}
|
||||||
<KoreaMap
|
<KoreaMap
|
||||||
ships={koreaFiltersResult.filteredShips}
|
ships={koreaFiltersResult.filteredShips}
|
||||||
allShips={koreaData.visibleShips}
|
allShips={koreaData.ships}
|
||||||
aircraft={koreaData.visibleAircraft}
|
aircraft={koreaData.visibleAircraft}
|
||||||
satellites={koreaData.satPositions}
|
satellites={koreaData.satPositions}
|
||||||
layers={koreaLayers}
|
layers={koreaLayers}
|
||||||
@ -239,8 +238,11 @@ export const KoreaDashboard = ({
|
|||||||
transshipSuspects={koreaFiltersResult.transshipSuspects}
|
transshipSuspects={koreaFiltersResult.transshipSuspects}
|
||||||
cableWatchSuspects={koreaFiltersResult.cableWatchSuspects}
|
cableWatchSuspects={koreaFiltersResult.cableWatchSuspects}
|
||||||
dokdoWatchSuspects={koreaFiltersResult.dokdoWatchSuspects}
|
dokdoWatchSuspects={koreaFiltersResult.dokdoWatchSuspects}
|
||||||
|
cnFishingSuspects={koreaFiltersResult.cnFishingSuspects}
|
||||||
dokdoAlerts={koreaFiltersResult.dokdoAlerts}
|
dokdoAlerts={koreaFiltersResult.dokdoAlerts}
|
||||||
vesselAnalysis={vesselAnalysis}
|
vesselAnalysis={vesselAnalysis}
|
||||||
|
hiddenShipCategories={hiddenShipCategories}
|
||||||
|
hiddenNationalities={hiddenNationalities}
|
||||||
/>
|
/>
|
||||||
<div className="map-overlay-left">
|
<div className="map-overlay-left">
|
||||||
<LayerPanel
|
<LayerPanel
|
||||||
|
|||||||
@ -45,6 +45,7 @@ export interface KoreaFiltersState {
|
|||||||
cableWatch: boolean;
|
cableWatch: boolean;
|
||||||
dokdoWatch: boolean;
|
dokdoWatch: boolean;
|
||||||
ferryWatch: boolean;
|
ferryWatch: boolean;
|
||||||
|
cnFishing: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -59,8 +60,11 @@ interface Props {
|
|||||||
transshipSuspects: Set<string>;
|
transshipSuspects: Set<string>;
|
||||||
cableWatchSuspects: Set<string>;
|
cableWatchSuspects: Set<string>;
|
||||||
dokdoWatchSuspects: Set<string>;
|
dokdoWatchSuspects: Set<string>;
|
||||||
|
cnFishingSuspects: Set<string>;
|
||||||
dokdoAlerts: { mmsi: string; name: string; dist: number; time: number }[];
|
dokdoAlerts: { mmsi: string; name: string; dist: number; time: number }[];
|
||||||
vesselAnalysis?: UseVesselAnalysisResult;
|
vesselAnalysis?: UseVesselAnalysisResult;
|
||||||
|
hiddenShipCategories?: Set<string>;
|
||||||
|
hiddenNationalities?: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarineTraffic-style: satellite + dark ocean + nautical overlay
|
// MarineTraffic-style: satellite + dark ocean + nautical overlay
|
||||||
@ -113,6 +117,7 @@ const FILTER_ICON: Record<string, string> = {
|
|||||||
cableWatch: '\u{1F50C}',
|
cableWatch: '\u{1F50C}',
|
||||||
dokdoWatch: '\u{1F3DD}\uFE0F',
|
dokdoWatch: '\u{1F3DD}\uFE0F',
|
||||||
ferryWatch: '\u{1F6A2}',
|
ferryWatch: '\u{1F6A2}',
|
||||||
|
cnFishing: '\u{1F3A3}',
|
||||||
};
|
};
|
||||||
|
|
||||||
const FILTER_COLOR: Record<string, string> = {
|
const FILTER_COLOR: Record<string, string> = {
|
||||||
@ -122,6 +127,7 @@ const FILTER_COLOR: Record<string, string> = {
|
|||||||
cableWatch: '#00e5ff',
|
cableWatch: '#00e5ff',
|
||||||
dokdoWatch: '#22c55e',
|
dokdoWatch: '#22c55e',
|
||||||
ferryWatch: '#2196f3',
|
ferryWatch: '#2196f3',
|
||||||
|
cnFishing: '#f59e0b',
|
||||||
};
|
};
|
||||||
|
|
||||||
const FILTER_I18N_KEY: Record<string, string> = {
|
const FILTER_I18N_KEY: Record<string, string> = {
|
||||||
@ -131,9 +137,10 @@ const FILTER_I18N_KEY: Record<string, string> = {
|
|||||||
cableWatch: 'filters.cableWatchMonitor',
|
cableWatch: 'filters.cableWatchMonitor',
|
||||||
dokdoWatch: 'filters.dokdoWatchMonitor',
|
dokdoWatch: 'filters.dokdoWatchMonitor',
|
||||||
ferryWatch: 'filters.ferryWatchMonitor',
|
ferryWatch: 'filters.ferryWatchMonitor',
|
||||||
|
cnFishing: 'filters.cnFishingMonitor',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis }: Props) {
|
export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis, hiddenShipCategories, hiddenNationalities }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const mapRef = useRef<MapRef>(null);
|
const mapRef = useRef<MapRef>(null);
|
||||||
const [infra, setInfra] = useState<PowerFacility[]>([]);
|
const [infra, setInfra] = useState<PowerFacility[]>([]);
|
||||||
@ -152,7 +159,9 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
const [staticPickInfo, setStaticPickInfo] = useState<StaticPickInfo | null>(null);
|
const [staticPickInfo, setStaticPickInfo] = useState<StaticPickInfo | null>(null);
|
||||||
|
const handleStaticPick = useCallback((info: StaticPickInfo) => setStaticPickInfo(info), []);
|
||||||
const [analysisPanelOpen, setAnalysisPanelOpen] = useLocalStorage('analysisPanelOpen', false);
|
const [analysisPanelOpen, setAnalysisPanelOpen] = useLocalStorage('analysisPanelOpen', false);
|
||||||
|
const [activeBadgeFilter, setActiveBadgeFilter] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchKoreaInfra().then(setInfra).catch(() => {});
|
fetchKoreaInfra().then(setInfra).catch(() => {});
|
||||||
@ -186,7 +195,8 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 줌 레벨별 아이콘/심볼 스케일 배율 — z4=1.0x 기준, 2단계씩 상향
|
const anyKoreaFilterOn = koreaFilters.illegalFishing || koreaFilters.illegalTransship || koreaFilters.darkVessel || koreaFilters.cableWatch || koreaFilters.dokdoWatch || koreaFilters.ferryWatch || koreaFilters.cnFishing;
|
||||||
|
|
||||||
// 줌 레벨별 아이콘/심볼 스케일 배율 — z4=0.8x, z6=1.0x 시작, 2단계씩 상향
|
// 줌 레벨별 아이콘/심볼 스케일 배율 — z4=0.8x, z6=1.0x 시작, 2단계씩 상향
|
||||||
const zoomScale = useMemo(() => {
|
const zoomScale = useMemo(() => {
|
||||||
if (zoomLevel <= 4) return 0.8;
|
if (zoomLevel <= 4) return 0.8;
|
||||||
@ -224,6 +234,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
filled: true,
|
filled: true,
|
||||||
radiusUnits: 'meters',
|
radiusUnits: 'meters',
|
||||||
lineWidthUnits: 'pixels',
|
lineWidthUnits: 'pixels',
|
||||||
|
updateTriggers: { getRadius: [zoomScale] },
|
||||||
}), [illegalFishingData, zoomScale]);
|
}), [illegalFishingData, zoomScale]);
|
||||||
|
|
||||||
const illegalFishingLabelLayer = useMemo(() => new TextLayer({
|
const illegalFishingLabelLayer = useMemo(() => new TextLayer({
|
||||||
@ -241,6 +252,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
outlineColor: [0, 0, 0, 200],
|
outlineColor: [0, 0, 0, 200],
|
||||||
billboard: false,
|
billboard: false,
|
||||||
characterSet: 'auto',
|
characterSet: 'auto',
|
||||||
|
updateTriggers: { getSize: [zoomScale] },
|
||||||
}), [illegalFishingData, zoomScale]);
|
}), [illegalFishingData, zoomScale]);
|
||||||
|
|
||||||
// 수역 라벨 TextLayer — illegalFishing 필터 활성 시만 표시
|
// 수역 라벨 TextLayer — illegalFishing 필터 활성 시만 표시
|
||||||
@ -277,6 +289,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
outlineColor: [0, 0, 0, 200],
|
outlineColor: [0, 0, 0, 200],
|
||||||
billboard: false,
|
billboard: false,
|
||||||
characterSet: 'auto',
|
characterSet: 'auto',
|
||||||
|
updateTriggers: { getSize: [zoomScale] },
|
||||||
});
|
});
|
||||||
}, [koreaFilters.illegalFishing, zoomScale]);
|
}, [koreaFilters.illegalFishing, zoomScale]);
|
||||||
|
|
||||||
@ -309,7 +322,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
cnMilitary: !!layers.cnMilitary,
|
cnMilitary: !!layers.cnMilitary,
|
||||||
jpPower: !!layers.jpPower,
|
jpPower: !!layers.jpPower,
|
||||||
jpMilitary: !!layers.jpMilitary,
|
jpMilitary: !!layers.jpMilitary,
|
||||||
onPick: (info) => setStaticPickInfo(info),
|
onPick: handleStaticPick,
|
||||||
sizeScale: zoomScale,
|
sizeScale: zoomScale,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -332,6 +345,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
radiusUnits: 'pixels',
|
radiusUnits: 'pixels',
|
||||||
lineWidthUnits: 'pixels',
|
lineWidthUnits: 'pixels',
|
||||||
getLineWidth: 1.5,
|
getLineWidth: 1.5,
|
||||||
|
updateTriggers: { getRadius: [zoomScale] },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 어구 이름 라벨
|
// 어구 이름 라벨
|
||||||
@ -350,6 +364,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
outlineColor: [0, 0, 0, 220],
|
outlineColor: [0, 0, 0, 220],
|
||||||
billboard: false,
|
billboard: false,
|
||||||
characterSet: 'auto',
|
characterSet: 'auto',
|
||||||
|
updateTriggers: { getSize: [zoomScale] },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 모선 강조 — 큰 원 + 라벨
|
// 모선 강조 — 큰 원 + 라벨
|
||||||
@ -366,6 +381,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
radiusUnits: 'pixels',
|
radiusUnits: 'pixels',
|
||||||
lineWidthUnits: 'pixels',
|
lineWidthUnits: 'pixels',
|
||||||
getLineWidth: 3,
|
getLineWidth: 3,
|
||||||
|
updateTriggers: { getRadius: [zoomScale] },
|
||||||
}));
|
}));
|
||||||
layers.push(new TextLayer({
|
layers.push(new TextLayer({
|
||||||
id: 'selected-gear-parent-label',
|
id: 'selected-gear-parent-label',
|
||||||
@ -383,6 +399,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
outlineColor: [0, 0, 0, 220],
|
outlineColor: [0, 0, 0, 220],
|
||||||
billboard: false,
|
billboard: false,
|
||||||
characterSet: 'auto',
|
characterSet: 'auto',
|
||||||
|
updateTriggers: { getSize: [zoomScale] },
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -421,6 +438,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
radiusUnits: 'pixels',
|
radiusUnits: 'pixels',
|
||||||
lineWidthUnits: 'pixels',
|
lineWidthUnits: 'pixels',
|
||||||
getLineWidth: 2,
|
getLineWidth: 2,
|
||||||
|
updateTriggers: { getRadius: [zoomScale] },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 소속 선박 이름 라벨
|
// 소속 선박 이름 라벨
|
||||||
@ -445,6 +463,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
outlineColor: [0, 0, 0, 220],
|
outlineColor: [0, 0, 0, 220],
|
||||||
billboard: false,
|
billboard: false,
|
||||||
characterSet: 'auto',
|
characterSet: 'auto',
|
||||||
|
updateTriggers: { getSize: [zoomScale], getText: [vesselAnalysis] },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 리더 선박 추가 강조 (큰 외곽 링)
|
// 리더 선박 추가 강조 (큰 외곽 링)
|
||||||
@ -465,6 +484,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
radiusUnits: 'pixels',
|
radiusUnits: 'pixels',
|
||||||
lineWidthUnits: 'pixels',
|
lineWidthUnits: 'pixels',
|
||||||
getLineWidth: 3,
|
getLineWidth: 3,
|
||||||
|
updateTriggers: { getRadius: [zoomScale] },
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -474,7 +494,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
// 분석 결과 deck.gl 레이어
|
// 분석 결과 deck.gl 레이어
|
||||||
const analysisActiveFilter = koreaFilters.illegalFishing ? 'illegalFishing'
|
const analysisActiveFilter = koreaFilters.illegalFishing ? 'illegalFishing'
|
||||||
: koreaFilters.darkVessel ? 'darkVessel'
|
: koreaFilters.darkVessel ? 'darkVessel'
|
||||||
: layers.cnFishing ? 'cnFishing'
|
: koreaFilters.cnFishing ? 'cnFishing'
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const analysisDeckLayers = useAnalysisDeckLayers(
|
const analysisDeckLayers = useAnalysisDeckLayers(
|
||||||
@ -555,7 +575,13 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
/>
|
/>
|
||||||
</Source>
|
</Source>
|
||||||
|
|
||||||
{layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} analysisMap={vesselAnalysis?.analysisMap} />}
|
{layers.ships && <ShipLayer
|
||||||
|
ships={anyKoreaFilterOn ? ships : (allShips ?? ships)}
|
||||||
|
militaryOnly={layers.militaryOnly}
|
||||||
|
analysisMap={vesselAnalysis?.analysisMap}
|
||||||
|
hiddenShipCategories={hiddenShipCategories}
|
||||||
|
hiddenNationalities={hiddenNationalities}
|
||||||
|
/>}
|
||||||
{/* Transship suspect labels — Marker DOM, inline styles kept for dynamic border color */}
|
{/* Transship suspect labels — Marker DOM, inline styles kept for dynamic border color */}
|
||||||
{transshipSuspects.size > 0 && ships.filter(s => transshipSuspects.has(s.mmsi)).map(s => (
|
{transshipSuspects.size > 0 && ships.filter(s => transshipSuspects.has(s.mmsi)).map(s => (
|
||||||
<Marker key={`ts-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="bottom">
|
<Marker key={`ts-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="bottom">
|
||||||
@ -618,10 +644,10 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
{layers.cables && <SubmarineCableLayer />}
|
{layers.cables && <SubmarineCableLayer />}
|
||||||
{layers.cctv && <CctvLayer />}
|
{layers.cctv && <CctvLayer />}
|
||||||
{/* 정적 레이어들은 deck.gl DeckGLOverlay로 전환됨 — 아래 DOM 레이어 제거 */}
|
{/* 정적 레이어들은 deck.gl DeckGLOverlay로 전환됨 — 아래 DOM 레이어 제거 */}
|
||||||
{(koreaFilters.illegalFishing || layers.cnFishing) && <FishingZoneLayer />}
|
{(koreaFilters.illegalFishing || koreaFilters.cnFishing) && <FishingZoneLayer />}
|
||||||
{layers.cnFishing && <ChineseFishingOverlay ships={ships} analysisMap={vesselAnalysis?.analysisMap} />}
|
{koreaFilters.cnFishing && <ChineseFishingOverlay ships={ships} analysisMap={vesselAnalysis?.analysisMap} />}
|
||||||
{/* HazardFacility, CnFacility, JpFacility → useStaticDeckLayers (deck.gl GPU) 전환 완료 */}
|
{/* HazardFacility, CnFacility, JpFacility → useStaticDeckLayers (deck.gl GPU) 전환 완료 */}
|
||||||
{layers.cnFishing && (
|
{koreaFilters.cnFishing && (
|
||||||
<FleetClusterLayer
|
<FleetClusterLayer
|
||||||
ships={allShips ?? ships}
|
ships={allShips ?? ships}
|
||||||
analysisMap={vesselAnalysis ? vesselAnalysis.analysisMap : undefined}
|
analysisMap={vesselAnalysis ? vesselAnalysis.analysisMap : undefined}
|
||||||
@ -658,32 +684,118 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
{layers.osint && <OsintMapLayer osintFeed={osintFeed} currentTime={currentTime} />}
|
{layers.osint && <OsintMapLayer osintFeed={osintFeed} currentTime={currentTime} />}
|
||||||
{layers.eez && <EezLayer />}
|
{layers.eez && <EezLayer />}
|
||||||
|
|
||||||
{/* Filter Status Banner */}
|
{/* Filter Status Banner — 필터별 개별 탐지 카운트 */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const active = (Object.keys(koreaFilters) as (keyof KoreaFiltersState)[]).filter(k => koreaFilters[k]);
|
const active = (Object.keys(koreaFilters) as (keyof KoreaFiltersState)[]).filter(k => koreaFilters[k]);
|
||||||
if (active.length === 0) return null;
|
if (active.length === 0) { if (activeBadgeFilter) setActiveBadgeFilter(null); return null; }
|
||||||
|
const gearPattern = /^.+?_\d+_\d+_?$/;
|
||||||
|
const all = allShips ?? ships;
|
||||||
|
const getShipsForFilter = (k: string): Ship[] => {
|
||||||
|
switch (k) {
|
||||||
|
case 'illegalFishing': return all.filter(s => s.mtCategory === 'fishing' && s.flag !== 'KR' && classifyFishingZone(s.lat, s.lng).zone !== 'OUTSIDE');
|
||||||
|
case 'illegalTransship': return all.filter(s => transshipSuspects.has(s.mmsi));
|
||||||
|
case 'darkVessel': return all.filter(s => { const dto = vesselAnalysis?.analysisMap.get(s.mmsi); return !!(dto?.algorithms.darkVessel.isDark) || (s.lastSeen != null && currentTime - s.lastSeen > 3600000); });
|
||||||
|
case 'cableWatch': return all.filter(s => cableWatchSuspects.has(s.mmsi));
|
||||||
|
case 'dokdoWatch': return all.filter(s => dokdoWatchSuspects.has(s.mmsi));
|
||||||
|
case 'ferryWatch': return all.filter(s => s.mtCategory === 'passenger');
|
||||||
|
case 'cnFishing': return all.filter(s => gearPattern.test(s.name || ''));
|
||||||
|
default: return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const downloadCsv = (k: string) => {
|
||||||
|
const data = getShipsForFilter(k);
|
||||||
|
const bom = '\uFEFF';
|
||||||
|
const header = 'MMSI,Name,Flag,Category,Lat,Lng,Speed,Heading';
|
||||||
|
const rows = data.map(s => `${s.mmsi},"${(s.name || '').replace(/"/g, '""')}",${s.flag || ''},${s.mtCategory || ''},${s.lat.toFixed(4)},${s.lng.toFixed(4)},${s.speed},${s.heading}`);
|
||||||
|
const blob = new Blob([bom + header + '\n' + rows.join('\n')], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${k}_${new Date().toISOString().slice(0, 10)}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
const badgeShips = activeBadgeFilter ? getShipsForFilter(activeBadgeFilter) : [];
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="absolute top-2.5 left-1/2 -translate-x-1/2 z-20 flex gap-1.5 backdrop-blur-lg">
|
<div className="absolute top-2.5 left-1/2 -translate-x-1/2 z-20 flex gap-1.5 backdrop-blur-lg">
|
||||||
{active.map(k => {
|
{active.map(k => {
|
||||||
const color = FILTER_COLOR[k];
|
const color = FILTER_COLOR[k];
|
||||||
|
const filterShips = getShipsForFilter(k);
|
||||||
|
const isOpen = activeBadgeFilter === k;
|
||||||
|
// cnFishing: 어구그룹 수(고유 모선명)로 표시, 나머지: 선박 수
|
||||||
|
let badgeLabel: string;
|
||||||
|
if (k === 'cnFishing') {
|
||||||
|
const groupNames = new Set(filterShips.map(s => (s.name || '').match(/^(.+?)_\d+/)?.[1]).filter(Boolean));
|
||||||
|
badgeLabel = `${groupNames.size}개`;
|
||||||
|
} else {
|
||||||
|
badgeLabel = `${filterShips.length}척`;
|
||||||
|
}
|
||||||
|
const badgeName = k === 'cnFishing' ? '중국 어구그룹 감시' : (FILTER_I18N_KEY[k] ? t(FILTER_I18N_KEY[k]) : k);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={k}
|
key={k}
|
||||||
className="rounded-lg px-3 py-1.5 font-mono text-[11px] font-bold flex items-center gap-1.5 animate-pulse"
|
className={`rounded-lg px-3 py-1.5 font-mono text-[11px] font-bold flex items-center gap-1.5 cursor-pointer select-none whitespace-nowrap ${isOpen ? '' : 'animate-pulse'}`}
|
||||||
style={{
|
style={{
|
||||||
background: `${color}22`, border: `1px solid ${color}88`,
|
background: isOpen ? `${color}44` : `${color}22`,
|
||||||
|
border: `1px solid ${isOpen ? color : color + '88'}`,
|
||||||
color,
|
color,
|
||||||
}}
|
}}
|
||||||
|
onClick={() => setActiveBadgeFilter(prev => prev === k ? null : k)}
|
||||||
>
|
>
|
||||||
<span className="text-[13px]">{FILTER_ICON[k]}</span>
|
<span className="text-[13px]">{FILTER_ICON[k]}</span>
|
||||||
{t(FILTER_I18N_KEY[k])}
|
{badgeName}
|
||||||
|
<span className="ml-0.5 text-white/80">{badgeLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<div className="rounded-lg px-3 py-1.5 font-mono text-xs font-bold flex items-center bg-kcg-glass border border-kcg-border-light text-white">
|
</div>
|
||||||
{t('korea.detected', { count: ships.length })}
|
{activeBadgeFilter && badgeShips.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="absolute top-12 left-1/2 -translate-x-1/2 z-30 rounded-lg px-3 py-2 font-mono text-[11px] w-[440px] max-h-[320px] bg-kcg-overlay backdrop-blur-lg shadow-lg"
|
||||||
|
style={{ borderWidth: 1, borderStyle: 'solid', borderColor: (FILTER_COLOR[activeBadgeFilter] ?? '#888') + '88' }}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className="font-bold text-white">
|
||||||
|
{FILTER_ICON[activeBadgeFilter]} {FILTER_I18N_KEY[activeBadgeFilter] ? t(FILTER_I18N_KEY[activeBadgeFilter]) : activeBadgeFilter} — {badgeShips.length}척
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<button type="button" onClick={() => downloadCsv(activeBadgeFilter)} className="text-[10px] px-2 py-0.5 rounded bg-white/10 hover:bg-white/20 text-white transition-colors">CSV</button>
|
||||||
|
<button type="button" onClick={() => setActiveBadgeFilter(null)} className="text-white/60 hover:text-white transition-colors">✕</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="overflow-y-auto max-h-[260px]">
|
||||||
|
<table className="w-full text-[10px]">
|
||||||
|
<thead className="sticky top-0 bg-kcg-overlay">
|
||||||
|
<tr className="text-white/50 border-b border-white/10">
|
||||||
|
<th className="text-left py-1 px-1">MMSI</th>
|
||||||
|
<th className="text-left py-1 px-1">Name</th>
|
||||||
|
<th className="text-center py-1 px-1">Flag</th>
|
||||||
|
<th className="text-left py-1 px-1">Type</th>
|
||||||
|
<th className="text-right py-1 px-1">Speed</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{badgeShips.slice(0, 200).map(s => (
|
||||||
|
<tr
|
||||||
|
key={s.mmsi}
|
||||||
|
className="hover:bg-white/5 cursor-pointer border-b border-white/5"
|
||||||
|
onClick={() => setFlyToTarget({ lng: s.lng, lat: s.lat, zoom: 12 })}
|
||||||
|
>
|
||||||
|
<td className="py-0.5 px-1 text-white/60">{s.mmsi}</td>
|
||||||
|
<td className="py-0.5 px-1 text-white/90 truncate max-w-[140px]">{s.name || '-'}</td>
|
||||||
|
<td className="py-0.5 px-1 text-center text-white/60">{s.flag || '-'}</td>
|
||||||
|
<td className="py-0.5 px-1 text-white/50">{s.mtCategory || '-'}</td>
|
||||||
|
<td className="py-0.5 px-1 text-right text-white/50">{s.speed?.toFixed(1)}kn</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{badgeShips.length > 200 && <div className="text-center text-white/40 text-[10px] py-1">...외 {badgeShips.length - 200}척</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,9 @@ import { Marker, Popup, Source, Layer, useMap } from 'react-map-gl/maplibre';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { Ship, VesselAnalysisDto } from '../../types';
|
import type { Ship, VesselAnalysisDto } from '../../types';
|
||||||
import maplibregl from 'maplibre-gl';
|
import maplibregl from 'maplibre-gl';
|
||||||
import { MT_TYPE_HEX, getMTType, NAVY_COLORS, FLAG_EMOJI, SIZE_MAP, isMilitary, getShipColor } from '../../utils/shipClassification';
|
import { MT_TYPE_COLORS, MT_TYPE_HEX, getMTType, NAVY_COLORS, FLAG_EMOJI, SIZE_MAP, isMilitary, getShipColor } from '../../utils/shipClassification';
|
||||||
|
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
||||||
|
import { getNationalityGroup } from '../../hooks/useKoreaData';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
ships: Ship[];
|
ships: Ship[];
|
||||||
@ -13,6 +15,8 @@ interface Props {
|
|||||||
focusMmsi?: string | null;
|
focusMmsi?: string | null;
|
||||||
onFocusClear?: () => void;
|
onFocusClear?: () => void;
|
||||||
analysisMap?: Map<string, VesselAnalysisDto>;
|
analysisMap?: Map<string, VesselAnalysisDto>;
|
||||||
|
hiddenShipCategories?: Set<string>;
|
||||||
|
hiddenNationalities?: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -268,7 +272,7 @@ function ensureTriangleImage(map: maplibregl.Map) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Main layer (WebGL symbol rendering — triangles) ──
|
// ── Main layer (WebGL symbol rendering — triangles) ──
|
||||||
export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusMmsi, onFocusClear, analysisMap }: Props) {
|
export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusMmsi, onFocusClear, analysisMap, hiddenShipCategories, hiddenNationalities }: Props) {
|
||||||
const { current: map } = useMap();
|
const { current: map } = useMap();
|
||||||
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
|
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
|
||||||
const [imageReady, setImageReady] = useState(false);
|
const [imageReady, setImageReady] = useState(false);
|
||||||
@ -283,12 +287,6 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
|||||||
}
|
}
|
||||||
}, [focusMmsi, onFocusClear]);
|
}, [focusMmsi, onFocusClear]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
let result = ships;
|
|
||||||
if (militaryOnly) result = result.filter(s => isMilitary(s.category));
|
|
||||||
return result;
|
|
||||||
}, [ships, militaryOnly]);
|
|
||||||
|
|
||||||
// Add triangle image to map
|
// Add triangle image to map
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
@ -302,9 +300,9 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
|||||||
return () => { m.off('load', addIcon); };
|
return () => { m.off('load', addIcon); };
|
||||||
}, [map]);
|
}, [map]);
|
||||||
|
|
||||||
// Build GeoJSON for all ships
|
// Build GeoJSON from ALL ships (category/nationality filtering is GPU-side via MapLibre filter)
|
||||||
const shipGeoJson = useMemo(() => {
|
const shipGeoJson = useMemo(() => {
|
||||||
const features: GeoJSON.Feature[] = filtered.map(ship => ({
|
const features: GeoJSON.Feature[] = ships.map(ship => ({
|
||||||
type: 'Feature' as const,
|
type: 'Feature' as const,
|
||||||
properties: {
|
properties: {
|
||||||
mmsi: ship.mmsi,
|
mmsi: ship.mmsi,
|
||||||
@ -315,6 +313,8 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
|||||||
isKorean: ship.flag === 'KR' ? 1 : 0,
|
isKorean: ship.flag === 'KR' ? 1 : 0,
|
||||||
isCheonghae: ship.mmsi === '440001981' ? 1 : 0,
|
isCheonghae: ship.mmsi === '440001981' ? 1 : 0,
|
||||||
heading: ship.heading,
|
heading: ship.heading,
|
||||||
|
mtCategory: getMarineTrafficCategory(ship.typecode, ship.category),
|
||||||
|
natGroup: getNationalityGroup(ship.flag),
|
||||||
},
|
},
|
||||||
geometry: {
|
geometry: {
|
||||||
type: 'Point' as const,
|
type: 'Point' as const,
|
||||||
@ -322,7 +322,25 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
return { type: 'FeatureCollection' as const, features };
|
return { type: 'FeatureCollection' as const, features };
|
||||||
}, [filtered]);
|
}, [ships]);
|
||||||
|
|
||||||
|
// MapLibre filter expression — GPU-side category/nationality/military filtering (no GeoJSON rebuild on toggle)
|
||||||
|
type FilterExpr = (string | number | string[] | FilterExpr)[];
|
||||||
|
const shipVisibilityFilter = useMemo((): FilterExpr => {
|
||||||
|
const conditions: FilterExpr[] = [];
|
||||||
|
if (militaryOnly) {
|
||||||
|
conditions.push(['==', ['get', 'isMil'], 1]);
|
||||||
|
}
|
||||||
|
if (hiddenShipCategories && hiddenShipCategories.size > 0) {
|
||||||
|
conditions.push(['!', ['in', ['get', 'mtCategory'], ['literal', [...hiddenShipCategories]]]]);
|
||||||
|
}
|
||||||
|
if (hiddenNationalities && hiddenNationalities.size > 0) {
|
||||||
|
conditions.push(['!', ['in', ['get', 'natGroup'], ['literal', [...hiddenNationalities]]]]);
|
||||||
|
}
|
||||||
|
if (conditions.length === 0) return ['has', 'mmsi'];
|
||||||
|
if (conditions.length === 1) return conditions[0];
|
||||||
|
return ['all', ...conditions];
|
||||||
|
}, [militaryOnly, hiddenShipCategories, hiddenNationalities]);
|
||||||
|
|
||||||
// hoveredMmsi 변경 시 feature-state로 hover 표시 (GeoJSON 재생성 없이)
|
// hoveredMmsi 변경 시 feature-state로 hover 표시 (GeoJSON 재생성 없이)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -369,7 +387,7 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
|||||||
};
|
};
|
||||||
}, [map]);
|
}, [map]);
|
||||||
|
|
||||||
const selectedShip = selectedMmsi ? filtered.find(s => s.mmsi === selectedMmsi) ?? null : null;
|
const selectedShip = selectedMmsi ? ships.find(s => s.mmsi === selectedMmsi) ?? null : null;
|
||||||
|
|
||||||
// Python 분석 결과 기반 선단 그룹 (cluster_id로 그룹핑)
|
// Python 분석 결과 기반 선단 그룹 (cluster_id로 그룹핑)
|
||||||
const selectedFleetMembers = useMemo(() => {
|
const selectedFleetMembers = useMemo(() => {
|
||||||
@ -415,7 +433,7 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
|||||||
}, [selectedFleetMembers]);
|
}, [selectedFleetMembers]);
|
||||||
|
|
||||||
// Carrier labels — only a few, so DOM markers are fine
|
// Carrier labels — only a few, so DOM markers are fine
|
||||||
const carriers = useMemo(() => filtered.filter(s => s.category === 'carrier'), [filtered]);
|
const carriers = useMemo(() => ships.filter(s => s.category === 'carrier'), [ships]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -437,6 +455,7 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
|||||||
<Layer
|
<Layer
|
||||||
id="ships-hover-ring"
|
id="ships-hover-ring"
|
||||||
type="circle"
|
type="circle"
|
||||||
|
filter={shipVisibilityFilter}
|
||||||
paint={{
|
paint={{
|
||||||
'circle-radius': ['case', ['boolean', ['feature-state', 'hovered'], false], 18, 0],
|
'circle-radius': ['case', ['boolean', ['feature-state', 'hovered'], false], 18, 0],
|
||||||
'circle-color': 'rgba(255, 255, 255, 0.1)',
|
'circle-color': 'rgba(255, 255, 255, 0.1)',
|
||||||
@ -482,6 +501,7 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
|||||||
<Layer
|
<Layer
|
||||||
id="ships-triangles"
|
id="ships-triangles"
|
||||||
type="symbol"
|
type="symbol"
|
||||||
|
filter={shipVisibilityFilter}
|
||||||
layout={{
|
layout={{
|
||||||
'icon-image': 'ship-triangle',
|
'icon-image': 'ship-triangle',
|
||||||
'icon-size': ['interpolate', ['linear'], ['zoom'],
|
'icon-size': ['interpolate', ['linear'], ['zoom'],
|
||||||
|
|||||||
@ -59,6 +59,10 @@ function infraSvg(f: PowerFacility): string {
|
|||||||
</svg>`;
|
</svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Module-level icon caches ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const infraIconCache = new Map<string, string>();
|
||||||
|
|
||||||
// ─── createFacilityLayers ─────────────────────────────────────────────────────
|
// ─── createFacilityLayers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function createFacilityLayers(
|
export function createFacilityLayers(
|
||||||
@ -79,7 +83,6 @@ export function createFacilityLayers(
|
|||||||
|
|
||||||
// ── Infra ──────────────────────────────────────────────────────────────
|
// ── Infra ──────────────────────────────────────────────────────────────
|
||||||
if (config.infra && config.infraFacilities.length > 0) {
|
if (config.infra && config.infraFacilities.length > 0) {
|
||||||
const infraIconCache = new Map<string, string>();
|
|
||||||
function getInfraIconUrl(f: PowerFacility): string {
|
function getInfraIconUrl(f: PowerFacility): string {
|
||||||
const key = `${f.type}-${f.source ?? ''}`;
|
const key = `${f.type}-${f.source ?? ''}`;
|
||||||
if (!infraIconCache.has(key)) {
|
if (!infraIconCache.has(key)) {
|
||||||
@ -98,6 +101,7 @@ export function createFacilityLayers(
|
|||||||
getPosition: (d) => [d.lng, d.lat],
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
getIcon: (d) => ({ url: getInfraIconUrl(d), width: 14, height: 14, anchorX: 7, anchorY: 7 }),
|
getIcon: (d) => ({ url: getInfraIconUrl(d), width: 14, height: 14, anchorX: 7, anchorY: 7 }),
|
||||||
getSize: 7 * sc,
|
getSize: 7 * sc,
|
||||||
|
updateTriggers: { getSize: [sc] },
|
||||||
pickable: true,
|
pickable: true,
|
||||||
onClick: (info: PickingInfo<PowerFacility>) => {
|
onClick: (info: PickingInfo<PowerFacility>) => {
|
||||||
if (info.object) onPick({ kind: 'infra', object: info.object });
|
if (info.object) onPick({ kind: 'infra', object: info.object });
|
||||||
@ -110,6 +114,7 @@ export function createFacilityLayers(
|
|||||||
getPosition: (d) => [d.lng, d.lat],
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
getIcon: (d) => ({ url: getInfraIconUrl(d), width: 24, height: 24, anchorX: 12, anchorY: 12 }),
|
getIcon: (d) => ({ url: getInfraIconUrl(d), width: 24, height: 24, anchorX: 12, anchorY: 12 }),
|
||||||
getSize: 12 * sc,
|
getSize: 12 * sc,
|
||||||
|
updateTriggers: { getSize: [sc] },
|
||||||
pickable: true,
|
pickable: true,
|
||||||
onClick: (info: PickingInfo<PowerFacility>) => {
|
onClick: (info: PickingInfo<PowerFacility>) => {
|
||||||
if (info.object) onPick({ kind: 'infra', object: info.object });
|
if (info.object) onPick({ kind: 'infra', object: info.object });
|
||||||
@ -122,6 +127,7 @@ export function createFacilityLayers(
|
|||||||
getPosition: (d) => [d.lng, d.lat],
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name),
|
getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name),
|
||||||
getSize: 8 * sc,
|
getSize: 8 * sc,
|
||||||
|
updateTriggers: { getSize: [sc] },
|
||||||
getColor: (d) => [...hexToRgb(infraColor(d)), 255] as [number, number, number, number],
|
getColor: (d) => [...hexToRgb(infraColor(d)), 255] as [number, number, number, number],
|
||||||
getTextAnchor: 'middle',
|
getTextAnchor: 'middle',
|
||||||
getAlignmentBaseline: 'top',
|
getAlignmentBaseline: 'top',
|
||||||
@ -161,6 +167,7 @@ export function createFacilityLayers(
|
|||||||
getPosition: (d) => [d.lng, d.lat],
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
getText: (d) => HAZARD_META[d.type]?.icon ?? '⚠️',
|
getText: (d) => HAZARD_META[d.type]?.icon ?? '⚠️',
|
||||||
getSize: 16 * sc,
|
getSize: 16 * sc,
|
||||||
|
updateTriggers: { getSize: [sc] },
|
||||||
getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 255],
|
getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 255],
|
||||||
getTextAnchor: 'middle',
|
getTextAnchor: 'middle',
|
||||||
getAlignmentBaseline: 'center',
|
getAlignmentBaseline: 'center',
|
||||||
@ -180,6 +187,7 @@ export function createFacilityLayers(
|
|||||||
getPosition: (d) => [d.lng, d.lat],
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo,
|
getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo,
|
||||||
getSize: 9 * sc,
|
getSize: 9 * sc,
|
||||||
|
updateTriggers: { getSize: [sc] },
|
||||||
getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 200],
|
getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 200],
|
||||||
getTextAnchor: 'middle',
|
getTextAnchor: 'middle',
|
||||||
getAlignmentBaseline: 'top',
|
getAlignmentBaseline: 'top',
|
||||||
@ -217,6 +225,7 @@ export function createFacilityLayers(
|
|||||||
getPosition: (d) => [d.lng, d.lat],
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
getText: (d) => CN_META[d.subType]?.icon ?? '📍',
|
getText: (d) => CN_META[d.subType]?.icon ?? '📍',
|
||||||
getSize: 16 * sc,
|
getSize: 16 * sc,
|
||||||
|
updateTriggers: { getSize: [sc] },
|
||||||
getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 255],
|
getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 255],
|
||||||
getTextAnchor: 'middle',
|
getTextAnchor: 'middle',
|
||||||
getAlignmentBaseline: 'center',
|
getAlignmentBaseline: 'center',
|
||||||
@ -236,6 +245,7 @@ export function createFacilityLayers(
|
|||||||
getPosition: (d) => [d.lng, d.lat],
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
getText: (d) => d.name,
|
getText: (d) => d.name,
|
||||||
getSize: 9 * sc,
|
getSize: 9 * sc,
|
||||||
|
updateTriggers: { getSize: [sc] },
|
||||||
getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 200],
|
getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 200],
|
||||||
getTextAnchor: 'middle',
|
getTextAnchor: 'middle',
|
||||||
getAlignmentBaseline: 'top',
|
getAlignmentBaseline: 'top',
|
||||||
@ -272,6 +282,7 @@ export function createFacilityLayers(
|
|||||||
getPosition: (d) => [d.lng, d.lat],
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
getText: (d) => JP_META[d.subType]?.icon ?? '📍',
|
getText: (d) => JP_META[d.subType]?.icon ?? '📍',
|
||||||
getSize: 16 * sc,
|
getSize: 16 * sc,
|
||||||
|
updateTriggers: { getSize: [sc] },
|
||||||
getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 255],
|
getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 255],
|
||||||
getTextAnchor: 'middle',
|
getTextAnchor: 'middle',
|
||||||
getAlignmentBaseline: 'center',
|
getAlignmentBaseline: 'center',
|
||||||
@ -291,6 +302,7 @@ export function createFacilityLayers(
|
|||||||
getPosition: (d) => [d.lng, d.lat],
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
getText: (d) => d.name,
|
getText: (d) => d.name,
|
||||||
getSize: 9 * sc,
|
getSize: 9 * sc,
|
||||||
|
updateTriggers: { getSize: [sc] },
|
||||||
getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 200],
|
getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 200],
|
||||||
getTextAnchor: 'middle',
|
getTextAnchor: 'middle',
|
||||||
getAlignmentBaseline: 'top',
|
getAlignmentBaseline: 'top',
|
||||||
|
|||||||
@ -35,6 +35,11 @@ function missileImpactSvg(color: string): string {
|
|||||||
</svg>`;
|
</svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Module-level icon caches ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const launchIconCache = new Map<string, string>();
|
||||||
|
const impactIconCache = new Map<string, string>();
|
||||||
|
|
||||||
export function createMilitaryLayers(
|
export function createMilitaryLayers(
|
||||||
config: { militaryBases: boolean; govBuildings: boolean; nkLaunch: boolean; nkMissile: boolean },
|
config: { militaryBases: boolean; govBuildings: boolean; nkLaunch: boolean; nkMissile: boolean },
|
||||||
fc: LayerFactoryConfig,
|
fc: LayerFactoryConfig,
|
||||||
@ -59,6 +64,7 @@ export function createMilitaryLayers(
|
|||||||
getPosition: (d) => [d.lng, d.lat],
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
getText: (d) => TYPE_ICON[d.type] ?? '⭐',
|
getText: (d) => TYPE_ICON[d.type] ?? '⭐',
|
||||||
getSize: 14 * sc,
|
getSize: 14 * sc,
|
||||||
|
updateTriggers: { getSize: [sc] },
|
||||||
getColor: [255, 255, 255, 220],
|
getColor: [255, 255, 255, 220],
|
||||||
getTextAnchor: 'middle',
|
getTextAnchor: 'middle',
|
||||||
getAlignmentBaseline: 'center',
|
getAlignmentBaseline: 'center',
|
||||||
@ -76,6 +82,7 @@ export function createMilitaryLayers(
|
|||||||
getPosition: (d) => [d.lng, d.lat],
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
getText: (d) => (d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo),
|
getText: (d) => (d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo),
|
||||||
getSize: 8 * sc,
|
getSize: 8 * sc,
|
||||||
|
updateTriggers: { getSize: [sc] },
|
||||||
getColor: (d) => [...hexToRgb(TYPE_COLOR[d.type] ?? '#a78bfa'), 255] as [number, number, number, number],
|
getColor: (d) => [...hexToRgb(TYPE_COLOR[d.type] ?? '#a78bfa'), 255] as [number, number, number, number],
|
||||||
getTextAnchor: 'middle',
|
getTextAnchor: 'middle',
|
||||||
getAlignmentBaseline: 'top',
|
getAlignmentBaseline: 'top',
|
||||||
@ -107,6 +114,7 @@ export function createMilitaryLayers(
|
|||||||
getPosition: (d) => [d.lng, d.lat],
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
getText: (d) => GOV_TYPE_ICON[d.type] ?? '🏛',
|
getText: (d) => GOV_TYPE_ICON[d.type] ?? '🏛',
|
||||||
getSize: 12 * sc,
|
getSize: 12 * sc,
|
||||||
|
updateTriggers: { getSize: [sc] },
|
||||||
getColor: [255, 255, 255, 220],
|
getColor: [255, 255, 255, 220],
|
||||||
getTextAnchor: 'middle',
|
getTextAnchor: 'middle',
|
||||||
getAlignmentBaseline: 'center',
|
getAlignmentBaseline: 'center',
|
||||||
@ -124,6 +132,7 @@ export function createMilitaryLayers(
|
|||||||
getPosition: (d) => [d.lng, d.lat],
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
getText: (d) => (d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo),
|
getText: (d) => (d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo),
|
||||||
getSize: 8 * sc,
|
getSize: 8 * sc,
|
||||||
|
updateTriggers: { getSize: [sc] },
|
||||||
getColor: (d) => [...hexToRgb(GOV_TYPE_COLOR[d.type] ?? '#f59e0b'), 255] as [number, number, number, number],
|
getColor: (d) => [...hexToRgb(GOV_TYPE_COLOR[d.type] ?? '#f59e0b'), 255] as [number, number, number, number],
|
||||||
getTextAnchor: 'middle',
|
getTextAnchor: 'middle',
|
||||||
getAlignmentBaseline: 'top',
|
getAlignmentBaseline: 'top',
|
||||||
@ -147,6 +156,7 @@ export function createMilitaryLayers(
|
|||||||
getPosition: (d) => [d.lng, d.lat],
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
getText: (d) => NK_LAUNCH_TYPE_META[d.type]?.icon ?? '🚀',
|
getText: (d) => NK_LAUNCH_TYPE_META[d.type]?.icon ?? '🚀',
|
||||||
getSize: (d) => (d.type === 'artillery' || d.type === 'mlrs' ? 10 : 13) * sc,
|
getSize: (d) => (d.type === 'artillery' || d.type === 'mlrs' ? 10 : 13) * sc,
|
||||||
|
updateTriggers: { getSize: [sc] },
|
||||||
getColor: [255, 255, 255, 220],
|
getColor: [255, 255, 255, 220],
|
||||||
getTextAnchor: 'middle',
|
getTextAnchor: 'middle',
|
||||||
getAlignmentBaseline: 'center',
|
getAlignmentBaseline: 'center',
|
||||||
@ -164,6 +174,7 @@ export function createMilitaryLayers(
|
|||||||
getPosition: (d) => [d.lng, d.lat],
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
getText: (d) => (d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo),
|
getText: (d) => (d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo),
|
||||||
getSize: 8 * sc,
|
getSize: 8 * sc,
|
||||||
|
updateTriggers: { getSize: [sc] },
|
||||||
getColor: (d) => [...hexToRgb(NK_LAUNCH_TYPE_META[d.type]?.color ?? '#f97316'), 255] as [number, number, number, number],
|
getColor: (d) => [...hexToRgb(NK_LAUNCH_TYPE_META[d.type]?.color ?? '#f97316'), 255] as [number, number, number, number],
|
||||||
getTextAnchor: 'middle',
|
getTextAnchor: 'middle',
|
||||||
getAlignmentBaseline: 'top',
|
getAlignmentBaseline: 'top',
|
||||||
@ -180,14 +191,12 @@ export function createMilitaryLayers(
|
|||||||
|
|
||||||
// ── NK Missile Events — IconLayer ─────────────────────────────────────
|
// ── NK Missile Events — IconLayer ─────────────────────────────────────
|
||||||
if (config.nkMissile) {
|
if (config.nkMissile) {
|
||||||
const launchIconCache = new Map<string, string>();
|
|
||||||
function getLaunchIconUrl(type: string): string {
|
function getLaunchIconUrl(type: string): string {
|
||||||
if (!launchIconCache.has(type)) {
|
if (!launchIconCache.has(type)) {
|
||||||
launchIconCache.set(type, svgToDataUri(missileLaunchSvg(getMissileColor(type))));
|
launchIconCache.set(type, svgToDataUri(missileLaunchSvg(getMissileColor(type))));
|
||||||
}
|
}
|
||||||
return launchIconCache.get(type)!;
|
return launchIconCache.get(type)!;
|
||||||
}
|
}
|
||||||
const impactIconCache = new Map<string, string>();
|
|
||||||
function getImpactIconUrl(type: string): string {
|
function getImpactIconUrl(type: string): string {
|
||||||
if (!impactIconCache.has(type)) {
|
if (!impactIconCache.has(type)) {
|
||||||
impactIconCache.set(type, svgToDataUri(missileImpactSvg(getMissileColor(type))));
|
impactIconCache.set(type, svgToDataUri(missileImpactSvg(getMissileColor(type))));
|
||||||
@ -227,6 +236,7 @@ export function createMilitaryLayers(
|
|||||||
getPosition: (d) => [d.lng, d.lat],
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
getIcon: (d) => ({ url: getLaunchIconUrl(d.ev.type), width: 24, height: 24, anchorX: 12, anchorY: 12 }),
|
getIcon: (d) => ({ url: getLaunchIconUrl(d.ev.type), width: 24, height: 24, anchorX: 12, anchorY: 12 }),
|
||||||
getSize: 12 * sc,
|
getSize: 12 * sc,
|
||||||
|
updateTriggers: { getSize: [sc] },
|
||||||
getColor: (d) => {
|
getColor: (d) => {
|
||||||
const today = new Date().toISOString().slice(0, 10) === d.ev.date;
|
const today = new Date().toISOString().slice(0, 10) === d.ev.date;
|
||||||
return [255, 255, 255, today ? 255 : 90] as [number, number, number, number];
|
return [255, 255, 255, today ? 255 : 90] as [number, number, number, number];
|
||||||
@ -238,6 +248,7 @@ export function createMilitaryLayers(
|
|||||||
getPosition: (d) => [d.lng, d.lat],
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
getIcon: (d) => ({ url: getImpactIconUrl(d.ev.type), width: 32, height: 32, anchorX: 16, anchorY: 16 }),
|
getIcon: (d) => ({ url: getImpactIconUrl(d.ev.type), width: 32, height: 32, anchorX: 16, anchorY: 16 }),
|
||||||
getSize: 16 * sc,
|
getSize: 16 * sc,
|
||||||
|
updateTriggers: { getSize: [sc] },
|
||||||
getColor: (d) => {
|
getColor: (d) => {
|
||||||
const today = new Date().toISOString().slice(0, 10) === d.ev.date;
|
const today = new Date().toISOString().slice(0, 10) === d.ev.date;
|
||||||
return [255, 255, 255, today ? 255 : 100] as [number, number, number, number];
|
return [255, 255, 255, today ? 255 : 100] as [number, number, number, number];
|
||||||
@ -254,6 +265,7 @@ export function createMilitaryLayers(
|
|||||||
getPosition: (d) => [d.lng, d.lat],
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
getText: (d) => `${d.ev.date.slice(5)} ${d.ev.time} ← ${d.ev.launchNameKo}`,
|
getText: (d) => `${d.ev.date.slice(5)} ${d.ev.time} ← ${d.ev.launchNameKo}`,
|
||||||
getSize: 8 * sc,
|
getSize: 8 * sc,
|
||||||
|
updateTriggers: { getSize: [sc] },
|
||||||
getColor: (d) => [...hexToRgb(getMissileColor(d.ev.type)), 255] as [number, number, number, number],
|
getColor: (d) => [...hexToRgb(getMissileColor(d.ev.type)), 255] as [number, number, number, number],
|
||||||
getTextAnchor: 'middle',
|
getTextAnchor: 'middle',
|
||||||
getAlignmentBaseline: 'top',
|
getAlignmentBaseline: 'top',
|
||||||
|
|||||||
@ -123,6 +123,13 @@ function piracySvg(color: string, size: number): string {
|
|||||||
</svg>`;
|
</svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Module-level icon caches ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const cgIconCache = new Map<CoastGuardType, string>();
|
||||||
|
const apIconCache = new Map<string, string>();
|
||||||
|
const nwIconCache = new Map<string, string>();
|
||||||
|
const piracyIconCache = new Map<string, string>();
|
||||||
|
|
||||||
export function createNavigationLayers(
|
export function createNavigationLayers(
|
||||||
config: { coastGuard: boolean; airports: boolean; navWarning: boolean; piracy: boolean },
|
config: { coastGuard: boolean; airports: boolean; navWarning: boolean; piracy: boolean },
|
||||||
fc: LayerFactoryConfig,
|
fc: LayerFactoryConfig,
|
||||||
@ -133,7 +140,6 @@ export function createNavigationLayers(
|
|||||||
|
|
||||||
// ── Coast Guard ────────────────────────────────────────────────────────
|
// ── Coast Guard ────────────────────────────────────────────────────────
|
||||||
if (config.coastGuard) {
|
if (config.coastGuard) {
|
||||||
const cgIconCache = new Map<CoastGuardType, string>();
|
|
||||||
function getCgIconUrl(type: CoastGuardType): string {
|
function getCgIconUrl(type: CoastGuardType): string {
|
||||||
if (!cgIconCache.has(type)) {
|
if (!cgIconCache.has(type)) {
|
||||||
const size = CG_TYPE_SIZE[type];
|
const size = CG_TYPE_SIZE[type];
|
||||||
@ -152,6 +158,7 @@ export function createNavigationLayers(
|
|||||||
return { url: getCgIconUrl(d.type), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 };
|
return { url: getCgIconUrl(d.type), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 };
|
||||||
},
|
},
|
||||||
getSize: (d) => CG_TYPE_SIZE[d.type] * sc,
|
getSize: (d) => CG_TYPE_SIZE[d.type] * sc,
|
||||||
|
updateTriggers: { getSize: [sc] },
|
||||||
pickable: true,
|
pickable: true,
|
||||||
onClick: (info: PickingInfo<CoastGuardFacility>) => {
|
onClick: (info: PickingInfo<CoastGuardFacility>) => {
|
||||||
if (info.object) onPick({ kind: 'coastGuard', object: info.object });
|
if (info.object) onPick({ kind: 'coastGuard', object: info.object });
|
||||||
@ -168,6 +175,7 @@ export function createNavigationLayers(
|
|||||||
return d.name.replace('해양경찰청', '').replace('지방', '').trim() || '본청';
|
return d.name.replace('해양경찰청', '').replace('지방', '').trim() || '본청';
|
||||||
},
|
},
|
||||||
getSize: 8 * sc,
|
getSize: 8 * sc,
|
||||||
|
updateTriggers: { getSize: [sc] },
|
||||||
getColor: (d) => [...hexToRgb(CG_TYPE_COLOR[d.type]), 255] as [number, number, number, number],
|
getColor: (d) => [...hexToRgb(CG_TYPE_COLOR[d.type]), 255] as [number, number, number, number],
|
||||||
getTextAnchor: 'middle',
|
getTextAnchor: 'middle',
|
||||||
getAlignmentBaseline: 'top',
|
getAlignmentBaseline: 'top',
|
||||||
@ -184,7 +192,6 @@ export function createNavigationLayers(
|
|||||||
|
|
||||||
// ── Airports ───────────────────────────────────────────────────────────
|
// ── Airports ───────────────────────────────────────────────────────────
|
||||||
if (config.airports) {
|
if (config.airports) {
|
||||||
const apIconCache = new Map<string, string>();
|
|
||||||
function getApIconUrl(ap: KoreanAirport): string {
|
function getApIconUrl(ap: KoreanAirport): string {
|
||||||
const color = apColor(ap);
|
const color = apColor(ap);
|
||||||
const size = ap.intl ? 40 : 32;
|
const size = ap.intl ? 40 : 32;
|
||||||
@ -205,6 +212,7 @@ export function createNavigationLayers(
|
|||||||
return { url: getApIconUrl(d), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 };
|
return { url: getApIconUrl(d), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 };
|
||||||
},
|
},
|
||||||
getSize: (d) => (d.intl ? 20 : 16) * sc,
|
getSize: (d) => (d.intl ? 20 : 16) * sc,
|
||||||
|
updateTriggers: { getSize: [sc] },
|
||||||
pickable: true,
|
pickable: true,
|
||||||
onClick: (info: PickingInfo<KoreanAirport>) => {
|
onClick: (info: PickingInfo<KoreanAirport>) => {
|
||||||
if (info.object) onPick({ kind: 'airport', object: info.object });
|
if (info.object) onPick({ kind: 'airport', object: info.object });
|
||||||
@ -217,6 +225,7 @@ export function createNavigationLayers(
|
|||||||
getPosition: (d) => [d.lng, d.lat],
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
getText: (d) => d.nameKo.replace('국제공항', '').replace('공항', ''),
|
getText: (d) => d.nameKo.replace('국제공항', '').replace('공항', ''),
|
||||||
getSize: 9 * sc,
|
getSize: 9 * sc,
|
||||||
|
updateTriggers: { getSize: [sc] },
|
||||||
getColor: (d) => [...hexToRgb(apColor(d)), 255] as [number, number, number, number],
|
getColor: (d) => [...hexToRgb(apColor(d)), 255] as [number, number, number, number],
|
||||||
getTextAnchor: 'middle',
|
getTextAnchor: 'middle',
|
||||||
getAlignmentBaseline: 'top',
|
getAlignmentBaseline: 'top',
|
||||||
@ -233,7 +242,6 @@ export function createNavigationLayers(
|
|||||||
|
|
||||||
// ── NavWarning ─────────────────────────────────────────────────────────
|
// ── NavWarning ─────────────────────────────────────────────────────────
|
||||||
if (config.navWarning) {
|
if (config.navWarning) {
|
||||||
const nwIconCache = new Map<string, string>();
|
|
||||||
function getNwIconUrl(w: NavWarning): string {
|
function getNwIconUrl(w: NavWarning): string {
|
||||||
const key = `${w.level}-${w.org}`;
|
const key = `${w.level}-${w.org}`;
|
||||||
if (!nwIconCache.has(key)) {
|
if (!nwIconCache.has(key)) {
|
||||||
@ -253,6 +261,7 @@ export function createNavigationLayers(
|
|||||||
return { url: getNwIconUrl(d), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 };
|
return { url: getNwIconUrl(d), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 };
|
||||||
},
|
},
|
||||||
getSize: (d) => (d.level === 'danger' ? 16 : 14) * sc,
|
getSize: (d) => (d.level === 'danger' ? 16 : 14) * sc,
|
||||||
|
updateTriggers: { getSize: [sc] },
|
||||||
pickable: true,
|
pickable: true,
|
||||||
onClick: (info: PickingInfo<NavWarning>) => {
|
onClick: (info: PickingInfo<NavWarning>) => {
|
||||||
if (info.object) onPick({ kind: 'navWarning', object: info.object });
|
if (info.object) onPick({ kind: 'navWarning', object: info.object });
|
||||||
@ -265,6 +274,7 @@ export function createNavigationLayers(
|
|||||||
getPosition: (d) => [d.lng, d.lat],
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
getText: (d) => d.id,
|
getText: (d) => d.id,
|
||||||
getSize: 8 * sc,
|
getSize: 8 * sc,
|
||||||
|
updateTriggers: { getSize: [sc] },
|
||||||
getColor: (d) => [...hexToRgb(NW_ORG_COLOR[d.org]), 255] as [number, number, number, number],
|
getColor: (d) => [...hexToRgb(NW_ORG_COLOR[d.org]), 255] as [number, number, number, number],
|
||||||
getTextAnchor: 'middle',
|
getTextAnchor: 'middle',
|
||||||
getAlignmentBaseline: 'top',
|
getAlignmentBaseline: 'top',
|
||||||
@ -281,7 +291,6 @@ export function createNavigationLayers(
|
|||||||
|
|
||||||
// ── Piracy ─────────────────────────────────────────────────────────────
|
// ── Piracy ─────────────────────────────────────────────────────────────
|
||||||
if (config.piracy) {
|
if (config.piracy) {
|
||||||
const piracyIconCache = new Map<string, string>();
|
|
||||||
function getPiracyIconUrl(zone: PiracyZone): string {
|
function getPiracyIconUrl(zone: PiracyZone): string {
|
||||||
const key = zone.level;
|
const key = zone.level;
|
||||||
if (!piracyIconCache.has(key)) {
|
if (!piracyIconCache.has(key)) {
|
||||||
@ -302,6 +311,7 @@ export function createNavigationLayers(
|
|||||||
return { url: getPiracyIconUrl(d), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 };
|
return { url: getPiracyIconUrl(d), width: sz, height: sz, anchorX: sz / 2, anchorY: sz / 2 };
|
||||||
},
|
},
|
||||||
getSize: (d) => (d.level === 'critical' ? 28 : d.level === 'high' ? 24 : 20) * sc,
|
getSize: (d) => (d.level === 'critical' ? 28 : d.level === 'high' ? 24 : 20) * sc,
|
||||||
|
updateTriggers: { getSize: [sc] },
|
||||||
pickable: true,
|
pickable: true,
|
||||||
onClick: (info: PickingInfo<PiracyZone>) => {
|
onClick: (info: PickingInfo<PiracyZone>) => {
|
||||||
if (info.object) onPick({ kind: 'piracy', object: info.object });
|
if (info.object) onPick({ kind: 'piracy', object: info.object });
|
||||||
@ -314,6 +324,7 @@ export function createNavigationLayers(
|
|||||||
getPosition: (d) => [d.lng, d.lat],
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
getText: (d) => d.nameKo,
|
getText: (d) => d.nameKo,
|
||||||
getSize: 9 * sc,
|
getSize: 9 * sc,
|
||||||
|
updateTriggers: { getSize: [sc] },
|
||||||
getColor: (d) => [...hexToRgb(PIRACY_LEVEL_COLOR[d.level] ?? '#ef4444'), 255] as [number, number, number, number],
|
getColor: (d) => [...hexToRgb(PIRACY_LEVEL_COLOR[d.level] ?? '#ef4444'), 255] as [number, number, number, number],
|
||||||
getTextAnchor: 'middle',
|
getTextAnchor: 'middle',
|
||||||
getAlignmentBaseline: 'top',
|
getAlignmentBaseline: 'top',
|
||||||
|
|||||||
@ -45,6 +45,11 @@ function windTurbineSvg(size: number): string {
|
|||||||
</svg>`;
|
</svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Module-level icon caches ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const portIconCache = new Map<string, string>();
|
||||||
|
const WIND_ICON_URL = svgToDataUri(windTurbineSvg(36));
|
||||||
|
|
||||||
export function createPortLayers(
|
export function createPortLayers(
|
||||||
config: { ports: boolean; windFarm: boolean },
|
config: { ports: boolean; windFarm: boolean },
|
||||||
fc: LayerFactoryConfig,
|
fc: LayerFactoryConfig,
|
||||||
@ -55,7 +60,6 @@ export function createPortLayers(
|
|||||||
|
|
||||||
// ── Ports ───────────────────────────────────────────────────────────────
|
// ── Ports ───────────────────────────────────────────────────────────────
|
||||||
if (config.ports) {
|
if (config.ports) {
|
||||||
const portIconCache = new Map<string, string>();
|
|
||||||
function getPortIconUrl(p: Port): string {
|
function getPortIconUrl(p: Port): string {
|
||||||
const key = `${p.country}-${p.type}`;
|
const key = `${p.country}-${p.type}`;
|
||||||
if (!portIconCache.has(key)) {
|
if (!portIconCache.has(key)) {
|
||||||
@ -79,6 +83,7 @@ export function createPortLayers(
|
|||||||
anchorY: d.type === 'major' ? 16 : 12,
|
anchorY: d.type === 'major' ? 16 : 12,
|
||||||
}),
|
}),
|
||||||
getSize: (d) => (d.type === 'major' ? 16 : 12) * sc,
|
getSize: (d) => (d.type === 'major' ? 16 : 12) * sc,
|
||||||
|
updateTriggers: { getSize: [sc] },
|
||||||
pickable: true,
|
pickable: true,
|
||||||
onClick: (info: PickingInfo<Port>) => {
|
onClick: (info: PickingInfo<Port>) => {
|
||||||
if (info.object) onPick({ kind: 'port', object: info.object });
|
if (info.object) onPick({ kind: 'port', object: info.object });
|
||||||
@ -91,6 +96,7 @@ export function createPortLayers(
|
|||||||
getPosition: (d) => [d.lng, d.lat],
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
getText: (d) => d.nameKo.replace('항', ''),
|
getText: (d) => d.nameKo.replace('항', ''),
|
||||||
getSize: 9 * sc,
|
getSize: 9 * sc,
|
||||||
|
updateTriggers: { getSize: [sc] },
|
||||||
getColor: (d) => [...hexToRgb(PORT_COUNTRY_COLOR[d.country] ?? PORT_COUNTRY_COLOR.KR), 255] as [number, number, number, number],
|
getColor: (d) => [...hexToRgb(PORT_COUNTRY_COLOR[d.country] ?? PORT_COUNTRY_COLOR.KR), 255] as [number, number, number, number],
|
||||||
getTextAnchor: 'middle',
|
getTextAnchor: 'middle',
|
||||||
getAlignmentBaseline: 'top',
|
getAlignmentBaseline: 'top',
|
||||||
@ -107,14 +113,14 @@ export function createPortLayers(
|
|||||||
|
|
||||||
// ── Wind Farms ─────────────────────────────────────────────────────────
|
// ── Wind Farms ─────────────────────────────────────────────────────────
|
||||||
if (config.windFarm) {
|
if (config.windFarm) {
|
||||||
const windUrl = svgToDataUri(windTurbineSvg(36));
|
|
||||||
layers.push(
|
layers.push(
|
||||||
new IconLayer<WindFarm>({
|
new IconLayer<WindFarm>({
|
||||||
id: 'static-windfarm-icon',
|
id: 'static-windfarm-icon',
|
||||||
data: KOREA_WIND_FARMS,
|
data: KOREA_WIND_FARMS,
|
||||||
getPosition: (d) => [d.lng, d.lat],
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
getIcon: () => ({ url: windUrl, width: 36, height: 36, anchorX: 18, anchorY: 18 }),
|
getIcon: () => ({ url: WIND_ICON_URL, width: 36, height: 36, anchorX: 18, anchorY: 18 }),
|
||||||
getSize: 18 * sc,
|
getSize: 18 * sc,
|
||||||
|
updateTriggers: { getSize: [sc] },
|
||||||
pickable: true,
|
pickable: true,
|
||||||
onClick: (info: PickingInfo<WindFarm>) => {
|
onClick: (info: PickingInfo<WindFarm>) => {
|
||||||
if (info.object) onPick({ kind: 'windFarm', object: info.object });
|
if (info.object) onPick({ kind: 'windFarm', object: info.object });
|
||||||
@ -127,6 +133,7 @@ export function createPortLayers(
|
|||||||
getPosition: (d) => [d.lng, d.lat],
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name),
|
getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name),
|
||||||
getSize: 9 * sc,
|
getSize: 9 * sc,
|
||||||
|
updateTriggers: { getSize: [sc] },
|
||||||
getColor: [...hexToRgb(WIND_COLOR), 255] as [number, number, number, number],
|
getColor: [...hexToRgb(WIND_COLOR), 255] as [number, number, number, number],
|
||||||
getTextAnchor: 'middle',
|
getTextAnchor: 'middle',
|
||||||
getAlignmentBaseline: 'top',
|
getAlignmentBaseline: 'top',
|
||||||
|
|||||||
@ -41,6 +41,12 @@ const RISK_PRIORITY: Record<string, number> = {
|
|||||||
MEDIUM: 2,
|
MEDIUM: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface AnalysisData {
|
||||||
|
riskData: AnalyzedShip[];
|
||||||
|
darkData: AnalyzedShip[];
|
||||||
|
spoofData: AnalyzedShip[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 분석 결과 기반 deck.gl 레이어를 반환하는 훅.
|
* 분석 결과 기반 deck.gl 레이어를 반환하는 훅.
|
||||||
* AnalysisOverlay DOM Marker 대체 — GPU 렌더링으로 성능 향상.
|
* AnalysisOverlay DOM Marker 대체 — GPU 렌더링으로 성능 향상.
|
||||||
@ -51,8 +57,11 @@ export function useAnalysisDeckLayers(
|
|||||||
activeFilter: string | null,
|
activeFilter: string | null,
|
||||||
sizeScale: number = 1.0,
|
sizeScale: number = 1.0,
|
||||||
): Layer[] {
|
): Layer[] {
|
||||||
return useMemo(() => {
|
// 데이터 준비: ships 필터/정렬/슬라이스 — sizeScale 변경 시 재실행 안 됨
|
||||||
if (analysisMap.size === 0) return [];
|
const { riskData, darkData, spoofData } = useMemo<AnalysisData>(() => {
|
||||||
|
if (analysisMap.size === 0) {
|
||||||
|
return { riskData: [], darkData: [], spoofData: [] };
|
||||||
|
}
|
||||||
|
|
||||||
const analyzedShips: AnalyzedShip[] = ships
|
const analyzedShips: AnalyzedShip[] = ships
|
||||||
.filter(s => analysisMap.has(s.mmsi))
|
.filter(s => analysisMap.has(s.mmsi))
|
||||||
@ -70,6 +79,19 @@ export function useAnalysisDeckLayers(
|
|||||||
})
|
})
|
||||||
.slice(0, 100);
|
.slice(0, 100);
|
||||||
|
|
||||||
|
const darkData = analyzedShips.filter(({ dto }) => dto.algorithms.darkVessel.isDark);
|
||||||
|
|
||||||
|
const spoofData = analyzedShips.filter(
|
||||||
|
({ dto }) => dto.algorithms.gpsSpoofing.spoofingScore > 0.5,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { riskData, darkData, spoofData };
|
||||||
|
}, [analysisMap, ships, activeFilter]);
|
||||||
|
|
||||||
|
// 레이어 생성: sizeScale 변경 시에만 재실행 (데이터 연산 없음)
|
||||||
|
return useMemo<Layer[]>(() => {
|
||||||
|
if (riskData.length === 0 && darkData.length === 0 && spoofData.length === 0) return [];
|
||||||
|
|
||||||
const layers: Layer[] = [];
|
const layers: Layer[] = [];
|
||||||
|
|
||||||
// 위험도 원형 마커
|
// 위험도 원형 마커
|
||||||
@ -86,6 +108,7 @@ export function useAnalysisDeckLayers(
|
|||||||
radiusUnits: 'pixels',
|
radiusUnits: 'pixels',
|
||||||
lineWidthUnits: 'pixels',
|
lineWidthUnits: 'pixels',
|
||||||
getLineWidth: 2,
|
getLineWidth: 2,
|
||||||
|
updateTriggers: { getRadius: [sizeScale] },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -101,6 +124,7 @@ export function useAnalysisDeckLayers(
|
|||||||
return `${name}\n${label} ${d.dto.algorithms.riskScore.score}`;
|
return `${name}\n${label} ${d.dto.algorithms.riskScore.score}`;
|
||||||
},
|
},
|
||||||
getSize: 10 * sizeScale,
|
getSize: 10 * sizeScale,
|
||||||
|
updateTriggers: { getSize: [sizeScale] },
|
||||||
getColor: (d) => RISK_RGBA_BORDER[d.dto.algorithms.riskScore.level] ?? [200, 200, 200, 255],
|
getColor: (d) => RISK_RGBA_BORDER[d.dto.algorithms.riskScore.level] ?? [200, 200, 200, 255],
|
||||||
getTextAnchor: 'middle',
|
getTextAnchor: 'middle',
|
||||||
getAlignmentBaseline: 'top',
|
getAlignmentBaseline: 'top',
|
||||||
@ -114,10 +138,7 @@ export function useAnalysisDeckLayers(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 다크베셀 (activeFilter === 'darkVessel' 일 때만)
|
// 다크베셀 (activeFilter === 'darkVessel' 일 때만)
|
||||||
if (activeFilter === 'darkVessel') {
|
if (activeFilter === 'darkVessel' && darkData.length > 0) {
|
||||||
const darkData = analyzedShips.filter(({ dto }) => dto.algorithms.darkVessel.isDark);
|
|
||||||
|
|
||||||
if (darkData.length > 0) {
|
|
||||||
layers.push(
|
layers.push(
|
||||||
new ScatterplotLayer<AnalyzedShip>({
|
new ScatterplotLayer<AnalyzedShip>({
|
||||||
id: 'dark-vessel-markers',
|
id: 'dark-vessel-markers',
|
||||||
@ -131,6 +152,7 @@ export function useAnalysisDeckLayers(
|
|||||||
radiusUnits: 'pixels',
|
radiusUnits: 'pixels',
|
||||||
lineWidthUnits: 'pixels',
|
lineWidthUnits: 'pixels',
|
||||||
getLineWidth: 2,
|
getLineWidth: 2,
|
||||||
|
updateTriggers: { getRadius: [sizeScale] },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -145,6 +167,7 @@ export function useAnalysisDeckLayers(
|
|||||||
return gap > 0 ? `AIS 소실 ${Math.round(gap)}분` : 'DARK';
|
return gap > 0 ? `AIS 소실 ${Math.round(gap)}분` : 'DARK';
|
||||||
},
|
},
|
||||||
getSize: 10 * sizeScale,
|
getSize: 10 * sizeScale,
|
||||||
|
updateTriggers: { getSize: [sizeScale] },
|
||||||
getColor: [168, 85, 247, 255],
|
getColor: [168, 85, 247, 255],
|
||||||
getTextAnchor: 'middle',
|
getTextAnchor: 'middle',
|
||||||
getAlignmentBaseline: 'top',
|
getAlignmentBaseline: 'top',
|
||||||
@ -157,10 +180,8 @@ export function useAnalysisDeckLayers(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// GPS 스푸핑 라벨
|
// GPS 스푸핑 라벨
|
||||||
const spoofData = analyzedShips.filter(({ dto }) => dto.algorithms.gpsSpoofing.spoofingScore > 0.5);
|
|
||||||
if (spoofData.length > 0) {
|
if (spoofData.length > 0) {
|
||||||
layers.push(
|
layers.push(
|
||||||
new TextLayer<AnalyzedShip>({
|
new TextLayer<AnalyzedShip>({
|
||||||
@ -183,5 +204,5 @@ export function useAnalysisDeckLayers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return layers;
|
return layers;
|
||||||
}, [analysisMap, ships, activeFilter, sizeScale]);
|
}, [riskData, darkData, spoofData, sizeScale, activeFilter]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -145,13 +145,29 @@ export function useKoreaData({
|
|||||||
// Propagate Korea aircraft (live only — no waypoint propagation needed)
|
// Propagate Korea aircraft (live only — no waypoint propagation needed)
|
||||||
const aircraft = useMemo(() => propagateAircraft(baseAircraftKorea, currentTime), [baseAircraftKorea, currentTime]);
|
const aircraft = useMemo(() => propagateAircraft(baseAircraftKorea, currentTime), [baseAircraftKorea, currentTime]);
|
||||||
|
|
||||||
// Korea region ships
|
// LIVE: baseShipsKorea 변경(5분 polling) 시에만 재계산. currentTime 무관.
|
||||||
const ships = useMemo(
|
// REPLAY: currentTime으로 활성 선박 + 웨이포인트 보간 필요.
|
||||||
() => propagateShips(baseShipsKorea, currentTime, isLive),
|
const liveShips = useMemo(() =>
|
||||||
[baseShipsKorea, currentTime, isLive],
|
baseShipsKorea.map(s => ({
|
||||||
);
|
...s,
|
||||||
|
mtCategory: getMarineTrafficCategory(s.typecode, s.category),
|
||||||
|
natGroup: getNationalityGroup(s.flag),
|
||||||
|
})),
|
||||||
|
[baseShipsKorea]);
|
||||||
|
|
||||||
// Category-filtered data for map rendering
|
const replayShips = useMemo(() => {
|
||||||
|
if (isLive) return liveShips; // REPLAY 아니면 계산 안 함
|
||||||
|
const propagated = propagateShips(baseShipsKorea, currentTime, false);
|
||||||
|
for (const s of propagated) {
|
||||||
|
s.mtCategory = getMarineTrafficCategory(s.typecode, s.category);
|
||||||
|
s.natGroup = getNationalityGroup(s.flag);
|
||||||
|
}
|
||||||
|
return propagated;
|
||||||
|
}, [isLive, baseShipsKorea, currentTime, liveShips]);
|
||||||
|
|
||||||
|
const ships = isLive ? liveShips : replayShips;
|
||||||
|
|
||||||
|
// Category-filtered data for map rendering (Set.has = O(1) per ship)
|
||||||
const visibleAircraft = useMemo(
|
const visibleAircraft = useMemo(
|
||||||
() => aircraft.filter(a => !hiddenAcCategories.has(a.category)),
|
() => aircraft.filter(a => !hiddenAcCategories.has(a.category)),
|
||||||
[aircraft, hiddenAcCategories],
|
[aircraft, hiddenAcCategories],
|
||||||
@ -159,8 +175,8 @@ export function useKoreaData({
|
|||||||
|
|
||||||
const visibleShips = useMemo(
|
const visibleShips = useMemo(
|
||||||
() => ships.filter(s =>
|
() => ships.filter(s =>
|
||||||
!hiddenShipCategories.has(getMarineTrafficCategory(s.typecode, s.category))
|
!hiddenShipCategories.has(s.mtCategory!)
|
||||||
&& !hiddenNationalities.has(getNationalityGroup(s.flag)),
|
&& !hiddenNationalities.has(s.natGroup!),
|
||||||
),
|
),
|
||||||
[ships, hiddenShipCategories, hiddenNationalities],
|
[ships, hiddenShipCategories, hiddenNationalities],
|
||||||
);
|
);
|
||||||
@ -172,8 +188,7 @@ export function useKoreaData({
|
|||||||
const shipsByCategory = useMemo(() => {
|
const shipsByCategory = useMemo(() => {
|
||||||
const counts: Record<string, number> = {};
|
const counts: Record<string, number> = {};
|
||||||
for (const s of ships) {
|
for (const s of ships) {
|
||||||
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
|
counts[s.mtCategory!] = (counts[s.mtCategory!] || 0) + 1;
|
||||||
counts[mtCat] = (counts[mtCat] || 0) + 1;
|
|
||||||
}
|
}
|
||||||
return counts;
|
return counts;
|
||||||
}, [ships]);
|
}, [ships]);
|
||||||
@ -181,8 +196,7 @@ export function useKoreaData({
|
|||||||
const shipsByNationality = useMemo(() => {
|
const shipsByNationality = useMemo(() => {
|
||||||
const counts: Record<string, number> = {};
|
const counts: Record<string, number> = {};
|
||||||
for (const s of ships) {
|
for (const s of ships) {
|
||||||
const nat = getNationalityGroup(s.flag);
|
counts[s.natGroup!] = (counts[s.natGroup!] || 0) + 1;
|
||||||
counts[nat] = (counts[nat] || 0) + 1;
|
|
||||||
}
|
}
|
||||||
return counts;
|
return counts;
|
||||||
}, [ships]);
|
}, [ships]);
|
||||||
@ -190,7 +204,7 @@ export function useKoreaData({
|
|||||||
const fishingByNationality = useMemo(() => {
|
const fishingByNationality = useMemo(() => {
|
||||||
const counts: Record<string, number> = {};
|
const counts: Record<string, number> = {};
|
||||||
for (const s of ships) {
|
for (const s of ships) {
|
||||||
if (getMarineTrafficCategory(s.typecode, s.category) !== 'fishing') continue;
|
if (s.mtCategory !== 'fishing') continue;
|
||||||
const flag = s.flag || 'unknown';
|
const flag = s.flag || 'unknown';
|
||||||
const group = flag === 'CN' ? 'CN' : flag === 'KR' ? 'KR' : flag === 'JP' ? 'JP' : 'other';
|
const group = flag === 'CN' ? 'CN' : flag === 'KR' ? 'KR' : flag === 'JP' ? 'JP' : 'other';
|
||||||
counts[group] = (counts[group] || 0) + 1;
|
counts[group] = (counts[group] || 0) + 1;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState, useMemo, useRef } from 'react';
|
import { useState, useMemo, useRef } from 'react';
|
||||||
import { useLocalStorage } from './useLocalStorage';
|
import { useLocalStorage } from './useLocalStorage';
|
||||||
import { KOREA_SUBMARINE_CABLES } from '../services/submarineCable';
|
import { KOREA_SUBMARINE_CABLES } from '../services/submarineCable';
|
||||||
import { getMarineTrafficCategory } from '../utils/marineTraffic';
|
// mtCategory는 Ship 객체에 사전 계산됨 (useKoreaData)
|
||||||
import { classifyFishingZone } from '../utils/fishingAnalysis';
|
import { classifyFishingZone } from '../utils/fishingAnalysis';
|
||||||
import type { Ship, VesselAnalysisDto } from '../types';
|
import type { Ship, VesselAnalysisDto } from '../types';
|
||||||
|
|
||||||
@ -12,6 +12,7 @@ interface KoreaFilters {
|
|||||||
cableWatch: boolean;
|
cableWatch: boolean;
|
||||||
dokdoWatch: boolean;
|
dokdoWatch: boolean;
|
||||||
ferryWatch: boolean;
|
ferryWatch: boolean;
|
||||||
|
cnFishing: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DokdoAlert {
|
interface DokdoAlert {
|
||||||
@ -28,11 +29,11 @@ interface UseKoreaFiltersResult {
|
|||||||
transshipSuspects: Set<string>;
|
transshipSuspects: Set<string>;
|
||||||
cableWatchSuspects: Set<string>;
|
cableWatchSuspects: Set<string>;
|
||||||
dokdoWatchSuspects: Set<string>;
|
dokdoWatchSuspects: Set<string>;
|
||||||
|
cnFishingSuspects: Set<string>;
|
||||||
dokdoAlerts: DokdoAlert[];
|
dokdoAlerts: DokdoAlert[];
|
||||||
anyFilterOn: boolean;
|
anyFilterOn: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TRANSSHIP_DURATION_MS = 60 * 60 * 1000; // 1시간
|
|
||||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||||
const CABLE_DURATION_MS = 3 * 60 * 60 * 1000; // 3시간
|
const CABLE_DURATION_MS = 3 * 60 * 60 * 1000; // 3시간
|
||||||
const DOKDO = { lat: 37.2417, lng: 131.8647 };
|
const DOKDO = { lat: 37.2417, lng: 131.8647 };
|
||||||
@ -44,7 +45,6 @@ export function useKoreaFilters(
|
|||||||
visibleShips: Ship[],
|
visibleShips: Ship[],
|
||||||
currentTime: number,
|
currentTime: number,
|
||||||
analysisMap?: Map<string, VesselAnalysisDto>,
|
analysisMap?: Map<string, VesselAnalysisDto>,
|
||||||
cnFishingOn = false,
|
|
||||||
): UseKoreaFiltersResult {
|
): UseKoreaFiltersResult {
|
||||||
const [filters, setFilters] = useLocalStorage<KoreaFilters>('koreaFilters', {
|
const [filters, setFilters] = useLocalStorage<KoreaFilters>('koreaFilters', {
|
||||||
illegalFishing: false,
|
illegalFishing: false,
|
||||||
@ -53,10 +53,10 @@ export function useKoreaFilters(
|
|||||||
cableWatch: false,
|
cableWatch: false,
|
||||||
dokdoWatch: false,
|
dokdoWatch: false,
|
||||||
ferryWatch: false,
|
ferryWatch: false,
|
||||||
|
cnFishing: false,
|
||||||
});
|
});
|
||||||
const [dokdoAlerts, setDokdoAlerts] = useState<DokdoAlert[]>([]);
|
const [dokdoAlerts, setDokdoAlerts] = useState<DokdoAlert[]>([]);
|
||||||
|
|
||||||
const proximityStartRef = useRef<Map<string, number>>(new Map());
|
|
||||||
const aisHistoryRef = useRef<Map<string, { seen: number[]; lastGapStart: number | null }>>(new Map());
|
const aisHistoryRef = useRef<Map<string, { seen: number[]; lastGapStart: number | null }>>(new Map());
|
||||||
const cableNearStartRef = useRef<Map<string, number>>(new Map());
|
const cableNearStartRef = useRef<Map<string, number>>(new Map());
|
||||||
const dokdoAlertedRef = useRef<Set<string>>(new Set());
|
const dokdoAlertedRef = useRef<Set<string>>(new Set());
|
||||||
@ -72,73 +72,19 @@ export function useKoreaFilters(
|
|||||||
filters.cableWatch ||
|
filters.cableWatch ||
|
||||||
filters.dokdoWatch ||
|
filters.dokdoWatch ||
|
||||||
filters.ferryWatch ||
|
filters.ferryWatch ||
|
||||||
cnFishingOn;
|
filters.cnFishing;
|
||||||
|
|
||||||
// 불법환적 의심 선박 탐지
|
// 불법환적 의심 선박 탐지 (Python 분석 결과 소비)
|
||||||
const transshipSuspects = useMemo(() => {
|
const transshipSuspects = useMemo(() => {
|
||||||
if (!filters.illegalTransship) return new Set<string>();
|
if (!filters.illegalTransship) return new Set<string>();
|
||||||
|
const result = new Set<string>();
|
||||||
const suspects = new Set<string>();
|
if (analysisMap) {
|
||||||
const isOffshore = (s: Ship) => {
|
for (const [mmsi, dto] of analysisMap) {
|
||||||
const nearCoastWest = s.lng > 125.5 && s.lng < 130.0 && s.lat > 33.5 && s.lat < 38.5;
|
if (dto.algorithms.transship?.isSuspect) result.add(mmsi);
|
||||||
if (nearCoastWest) {
|
|
||||||
const distFromEastCoast = s.lng - 129.5;
|
|
||||||
const distFromWestCoast = 126.0 - s.lng;
|
|
||||||
const distFromSouthCoast = 34.5 - s.lat;
|
|
||||||
if (distFromEastCoast > 0.15 || distFromWestCoast > 0.15 || distFromSouthCoast > 0.15) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isNearForeignCoast = (s: Ship) => {
|
|
||||||
if (s.lng < 123.5 && s.lat > 25 && s.lat < 40) return true;
|
|
||||||
if (s.lng > 130.5 && s.lat > 30 && s.lat < 46) return true;
|
|
||||||
if (s.lng > 129.1 && s.lng < 129.6 && s.lat > 34.0 && s.lat < 34.8) return true;
|
|
||||||
if (s.lng > 129.5 && s.lat > 31 && s.lat < 34) return true;
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const candidates = koreaShips.filter(s => {
|
|
||||||
if (s.speed >= 2) return false;
|
|
||||||
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
|
|
||||||
if (mtCat !== 'tanker' && mtCat !== 'cargo' && mtCat !== 'fishing') return false;
|
|
||||||
if (isNearForeignCoast(s)) return false;
|
|
||||||
return isOffshore(s);
|
|
||||||
});
|
|
||||||
|
|
||||||
const now = currentTime;
|
|
||||||
const prevMap = proximityStartRef.current;
|
|
||||||
const currentPairs = new Set<string>();
|
|
||||||
const PROXIMITY_DEG = 0.001; // ~110m
|
|
||||||
|
|
||||||
for (let i = 0; i < candidates.length; i++) {
|
|
||||||
for (let j = i + 1; j < candidates.length; j++) {
|
|
||||||
const a = candidates[i];
|
|
||||||
const b = candidates[j];
|
|
||||||
const dlat = Math.abs(a.lat - b.lat);
|
|
||||||
const dlng = Math.abs(a.lng - b.lng) * Math.cos((a.lat * Math.PI) / 180);
|
|
||||||
if (dlat < PROXIMITY_DEG && dlng < PROXIMITY_DEG) {
|
|
||||||
const pairKey = [a.mmsi, b.mmsi].sort().join(':');
|
|
||||||
currentPairs.add(pairKey);
|
|
||||||
if (!prevMap.has(pairKey)) {
|
|
||||||
prevMap.set(pairKey, now);
|
|
||||||
}
|
|
||||||
const pairStartTime = prevMap.get(pairKey)!;
|
|
||||||
if (now - pairStartTime >= TRANSSHIP_DURATION_MS) {
|
|
||||||
suspects.add(a.mmsi);
|
|
||||||
suspects.add(b.mmsi);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
return result;
|
||||||
}
|
}, [filters.illegalTransship, analysisMap]);
|
||||||
|
|
||||||
for (const key of prevMap.keys()) {
|
|
||||||
if (!currentPairs.has(key)) prevMap.delete(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
return suspects;
|
|
||||||
}, [koreaShips, filters.illegalTransship, currentTime]);
|
|
||||||
|
|
||||||
// 다크베셀 탐지: AIS 신호 이력 추적
|
// 다크베셀 탐지: AIS 신호 이력 추적
|
||||||
const darkVesselSet = useMemo(() => {
|
const darkVesselSet = useMemo(() => {
|
||||||
@ -306,14 +252,25 @@ export function useKoreaFilters(
|
|||||||
return result;
|
return result;
|
||||||
}, [koreaShips, filters.dokdoWatch, currentTime]);
|
}, [koreaShips, filters.dokdoWatch, currentTime]);
|
||||||
|
|
||||||
|
// 중국어선 의심 선박 Set
|
||||||
|
const cnFishingSuspects = useMemo(() => {
|
||||||
|
if (!filters.cnFishing) return new Set<string>();
|
||||||
|
const result = new Set<string>();
|
||||||
|
for (const s of koreaShips) {
|
||||||
|
const isCnFishing = s.flag === 'CN' && s.mtCategory === 'fishing';
|
||||||
|
const isGearPattern = /^.+?_\d+_\d+_?$/.test(s.name || '');
|
||||||
|
if (isCnFishing || isGearPattern) result.add(s.mmsi);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [filters.cnFishing, koreaShips]);
|
||||||
|
|
||||||
// 필터링된 선박 목록
|
// 필터링된 선박 목록
|
||||||
const filteredShips = useMemo(() => {
|
const filteredShips = useMemo(() => {
|
||||||
if (!anyFilterOn) return visibleShips;
|
if (!anyFilterOn) return visibleShips;
|
||||||
return visibleShips.filter(s => {
|
return visibleShips.filter(s => {
|
||||||
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
|
|
||||||
if (filters.illegalFishing) {
|
if (filters.illegalFishing) {
|
||||||
// 특정어업수역 Ⅰ~Ⅳ 내 비한국 어선만 불법어선으로 판별
|
// 특정어업수역 Ⅰ~Ⅳ 내 비한국 어선만 불법어선으로 판별
|
||||||
if (mtCat === 'fishing' && s.flag !== 'KR') {
|
if (s.mtCategory === 'fishing' && s.flag !== 'KR') {
|
||||||
const zoneInfo = classifyFishingZone(s.lat, s.lng);
|
const zoneInfo = classifyFishingZone(s.lat, s.lng);
|
||||||
if (zoneInfo.zone !== 'OUTSIDE') return true;
|
if (zoneInfo.zone !== 'OUTSIDE') return true;
|
||||||
}
|
}
|
||||||
@ -328,15 +285,11 @@ export function useKoreaFilters(
|
|||||||
if (filters.darkVessel && darkVesselSet.has(s.mmsi)) return true;
|
if (filters.darkVessel && darkVesselSet.has(s.mmsi)) return true;
|
||||||
if (filters.cableWatch && cableWatchSet.has(s.mmsi)) return true;
|
if (filters.cableWatch && cableWatchSet.has(s.mmsi)) return true;
|
||||||
if (filters.dokdoWatch && dokdoWatchSet.has(s.mmsi)) return true;
|
if (filters.dokdoWatch && dokdoWatchSet.has(s.mmsi)) return true;
|
||||||
if (filters.ferryWatch && getMarineTrafficCategory(s.typecode, s.category) === 'passenger') return true;
|
if (filters.ferryWatch && s.mtCategory === 'passenger') return true;
|
||||||
if (cnFishingOn) {
|
if (filters.cnFishing && cnFishingSuspects.has(s.mmsi)) return true;
|
||||||
const isCnFishing = s.flag === 'CN' && getMarineTrafficCategory(s.typecode, s.category) === 'fishing';
|
|
||||||
const isGearPattern = /^.+?_\d+_\d+_?$/.test(s.name || '');
|
|
||||||
if (isCnFishing || isGearPattern) return true;
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
}, [visibleShips, filters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet, analysisMap, cnFishingOn]);
|
}, [visibleShips, filters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet, analysisMap, cnFishingSuspects]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filters,
|
filters,
|
||||||
@ -345,6 +298,7 @@ export function useKoreaFilters(
|
|||||||
transshipSuspects,
|
transshipSuspects,
|
||||||
cableWatchSuspects: cableWatchSet,
|
cableWatchSuspects: cableWatchSet,
|
||||||
dokdoWatchSuspects: dokdoWatchSet,
|
dokdoWatchSuspects: dokdoWatchSet,
|
||||||
|
cnFishingSuspects,
|
||||||
dokdoAlerts,
|
dokdoAlerts,
|
||||||
anyFilterOn,
|
anyFilterOn,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -107,6 +107,8 @@ export interface Ship {
|
|||||||
activeEnd?: number; // unix ms - when ship leaves area
|
activeEnd?: number; // unix ms - when ship leaves area
|
||||||
shipImagePath?: string | null; // signal-batch image path
|
shipImagePath?: string | null; // signal-batch image path
|
||||||
shipImageCount?: number; // number of available images
|
shipImageCount?: number; // number of available images
|
||||||
|
mtCategory?: string; // pre-computed getMarineTrafficCategory result (cached for O(1) filter)
|
||||||
|
natGroup?: string; // pre-computed getNationalityGroup result (cached for O(1) filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Iran oil/gas facility
|
// Iran oil/gas facility
|
||||||
@ -182,6 +184,7 @@ export interface VesselAnalysisDto {
|
|||||||
cluster: { clusterId: number; clusterSize: number };
|
cluster: { clusterId: number; clusterSize: number };
|
||||||
fleetRole: { isLeader: boolean; role: FleetRole };
|
fleetRole: { isLeader: boolean; role: FleetRole };
|
||||||
riskScore: { score: number; level: RiskLevel };
|
riskScore: { score: number; level: RiskLevel };
|
||||||
|
transship: { isSuspect: boolean; pairMmsi: string; durationMin: number };
|
||||||
};
|
};
|
||||||
features: Record<string, number>;
|
features: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|||||||
234
prediction/algorithms/transshipment.py
Normal file
234
prediction/algorithms/transshipment.py
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
"""환적(Transshipment) 의심 선박 탐지 — 서버사이드 O(n log n) 구현.
|
||||||
|
|
||||||
|
프론트엔드 useKoreaFilters.ts의 O(n²) 근접 탐지를 대체한다.
|
||||||
|
scipy 미설치 환경을 고려하여 그리드 기반 공간 인덱스를 사용한다.
|
||||||
|
|
||||||
|
알고리즘 개요:
|
||||||
|
1. 후보 선박 필터: sog < 2kn, 선종 (tanker/cargo/fishing), 외국 해안선 제외
|
||||||
|
2. 그리드 셀 기반 근접 쌍 탐지: O(n log n) ← 셀 분할 + 인접 9셀 조회
|
||||||
|
3. pair_history dict로 쌍별 최초 탐지 시각 영속화 (호출 간 유지)
|
||||||
|
4. 60분 이상 지속 근접 시 의심 쌍으로 판정
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 상수
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
SOG_THRESHOLD_KN = 2.0 # 정박/표류 기준 속도 (노트)
|
||||||
|
PROXIMITY_DEG = 0.001 # 근접 판정 임계값 (~110m)
|
||||||
|
SUSPECT_DURATION_MIN = 60 # 의심 판정 최소 지속 시간 (분)
|
||||||
|
PAIR_EXPIRY_MIN = 120 # pair_history 항목 만료 기준 (분)
|
||||||
|
|
||||||
|
# 외국 해안 근접 제외 경계
|
||||||
|
_CN_LON_MAX = 123.5 # 중국 해안: 경도 < 123.5
|
||||||
|
_JP_LON_MIN = 130.5 # 일본 해안: 경도 > 130.5
|
||||||
|
_TSUSHIMA_LAT_MIN = 33.8 # 대마도: 위도 > 33.8 AND 경도 > 129.0
|
||||||
|
_TSUSHIMA_LON_MIN = 129.0
|
||||||
|
|
||||||
|
# 탐지 대상 선종 (소문자 정규화 후 비교)
|
||||||
|
_CANDIDATE_TYPES: frozenset[str] = frozenset({'tanker', 'cargo', 'fishing'})
|
||||||
|
|
||||||
|
# 그리드 셀 크기 = PROXIMITY_DEG (셀 하나 = 근접 임계와 동일 크기)
|
||||||
|
_GRID_CELL_DEG = PROXIMITY_DEG
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 내부 헬퍼
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _is_near_foreign_coast(lat: float, lon: float) -> bool:
|
||||||
|
"""외국 해안 근처 여부 — 중국/일본/대마도 경계 확인."""
|
||||||
|
if lon < _CN_LON_MAX:
|
||||||
|
return True
|
||||||
|
if lon > _JP_LON_MIN:
|
||||||
|
return True
|
||||||
|
if lat > _TSUSHIMA_LAT_MIN and lon > _TSUSHIMA_LON_MIN:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _cell_key(lat: float, lon: float) -> tuple[int, int]:
|
||||||
|
"""위도/경도를 그리드 셀 인덱스로 변환."""
|
||||||
|
return (int(math.floor(lat / _GRID_CELL_DEG)),
|
||||||
|
int(math.floor(lon / _GRID_CELL_DEG)))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_grid(records: list[dict]) -> dict[tuple[int, int], list[int]]:
|
||||||
|
"""선박 리스트를 그리드 셀로 분류.
|
||||||
|
|
||||||
|
Returns: {(row, col): [record index, ...]}
|
||||||
|
"""
|
||||||
|
grid: dict[tuple[int, int], list[int]] = {}
|
||||||
|
for idx, rec in enumerate(records):
|
||||||
|
key = _cell_key(rec['lat'], rec['lon'])
|
||||||
|
if key not in grid:
|
||||||
|
grid[key] = []
|
||||||
|
grid[key].append(idx)
|
||||||
|
return grid
|
||||||
|
|
||||||
|
|
||||||
|
def _within_proximity(a: dict, b: dict) -> bool:
|
||||||
|
"""두 선박이 PROXIMITY_DEG 이내인지 확인 (위경도 직교 근사)."""
|
||||||
|
dlat = abs(a['lat'] - b['lat'])
|
||||||
|
if dlat >= PROXIMITY_DEG:
|
||||||
|
return False
|
||||||
|
cos_lat = math.cos(math.radians((a['lat'] + b['lat']) / 2.0))
|
||||||
|
dlon_scaled = abs(a['lon'] - b['lon']) * cos_lat
|
||||||
|
return dlon_scaled < PROXIMITY_DEG
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_type(raw: Optional[str]) -> str:
|
||||||
|
"""선종 문자열 소문자 정규화."""
|
||||||
|
if not raw:
|
||||||
|
return ''
|
||||||
|
return raw.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _pair_key(mmsi_a: str, mmsi_b: str) -> tuple[str, str]:
|
||||||
|
"""MMSI 순서를 정규화하여 중복 쌍 방지."""
|
||||||
|
return (mmsi_a, mmsi_b) if mmsi_a < mmsi_b else (mmsi_b, mmsi_a)
|
||||||
|
|
||||||
|
|
||||||
|
def _evict_expired_pairs(
|
||||||
|
pair_history: dict[tuple[str, str], datetime],
|
||||||
|
now: datetime,
|
||||||
|
) -> None:
|
||||||
|
"""PAIR_EXPIRY_MIN 이상 갱신 없는 pair_history 항목 제거."""
|
||||||
|
expired = [
|
||||||
|
key for key, first_seen in pair_history.items()
|
||||||
|
if (now - first_seen).total_seconds() / 60 > PAIR_EXPIRY_MIN
|
||||||
|
]
|
||||||
|
for key in expired:
|
||||||
|
del pair_history[key]
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 공개 API
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def detect_transshipment(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
pair_history: dict[tuple[str, str], datetime],
|
||||||
|
) -> list[tuple[str, str, int]]:
|
||||||
|
"""환적 의심 쌍 탐지.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
df: 선박 위치 DataFrame.
|
||||||
|
필수 컬럼: mmsi, lat, lon, sog
|
||||||
|
선택 컬럼: ship_type (없으면 전체 선종 허용)
|
||||||
|
pair_history: 쌍별 최초 탐지 시각을 저장하는 영속 dict.
|
||||||
|
스케줄러에서 호출 간 유지하여 전달해야 한다.
|
||||||
|
키: (mmsi_a, mmsi_b) — mmsi_a < mmsi_b 정규화 적용.
|
||||||
|
값: 최초 탐지 시각 (UTC datetime, timezone-aware).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
[(mmsi_a, mmsi_b, duration_minutes), ...] — 60분 이상 지속된 의심 쌍.
|
||||||
|
mmsi_a < mmsi_b 정규화 적용.
|
||||||
|
"""
|
||||||
|
if df.empty:
|
||||||
|
return []
|
||||||
|
|
||||||
|
required_cols = {'mmsi', 'lat', 'lon', 'sog'}
|
||||||
|
missing = required_cols - set(df.columns)
|
||||||
|
if missing:
|
||||||
|
logger.error('detect_transshipment: missing required columns: %s', missing)
|
||||||
|
return []
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# ── 1. 후보 선박 필터 ──────────────────────────────────────
|
||||||
|
has_type_col = 'ship_type' in df.columns
|
||||||
|
|
||||||
|
candidate_mask = df['sog'] < SOG_THRESHOLD_KN
|
||||||
|
|
||||||
|
if has_type_col:
|
||||||
|
type_mask = df['ship_type'].apply(_normalize_type).isin(_CANDIDATE_TYPES)
|
||||||
|
candidate_mask = candidate_mask & type_mask
|
||||||
|
|
||||||
|
candidates = df[candidate_mask].copy()
|
||||||
|
|
||||||
|
if candidates.empty:
|
||||||
|
_evict_expired_pairs(pair_history, now)
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 외국 해안 근처 제외
|
||||||
|
coast_mask = candidates.apply(
|
||||||
|
lambda row: not _is_near_foreign_coast(row['lat'], row['lon']),
|
||||||
|
axis=1,
|
||||||
|
)
|
||||||
|
candidates = candidates[coast_mask]
|
||||||
|
|
||||||
|
if len(candidates) < 2:
|
||||||
|
_evict_expired_pairs(pair_history, now)
|
||||||
|
return []
|
||||||
|
|
||||||
|
records = candidates[['mmsi', 'lat', 'lon']].to_dict('records')
|
||||||
|
for rec in records:
|
||||||
|
rec['mmsi'] = str(rec['mmsi'])
|
||||||
|
|
||||||
|
# ── 2. 그리드 기반 근접 쌍 탐지 ──────────────────────────
|
||||||
|
grid = _build_grid(records)
|
||||||
|
active_pairs: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
|
for (row, col), indices in grid.items():
|
||||||
|
# 현재 셀 내부 쌍
|
||||||
|
for i in range(len(indices)):
|
||||||
|
for j in range(i + 1, len(indices)):
|
||||||
|
a = records[indices[i]]
|
||||||
|
b = records[indices[j]]
|
||||||
|
if _within_proximity(a, b):
|
||||||
|
active_pairs.add(_pair_key(a['mmsi'], b['mmsi']))
|
||||||
|
|
||||||
|
# 인접 셀 (우측 3셀 + 아래 3셀 = 중복 없는 방향성 순회)
|
||||||
|
for dr, dc in ((0, 1), (1, -1), (1, 0), (1, 1)):
|
||||||
|
neighbor_key = (row + dr, col + dc)
|
||||||
|
if neighbor_key not in grid:
|
||||||
|
continue
|
||||||
|
for ai in indices:
|
||||||
|
for bi in grid[neighbor_key]:
|
||||||
|
a = records[ai]
|
||||||
|
b = records[bi]
|
||||||
|
if _within_proximity(a, b):
|
||||||
|
active_pairs.add(_pair_key(a['mmsi'], b['mmsi']))
|
||||||
|
|
||||||
|
# ── 3. pair_history 갱신 ─────────────────────────────────
|
||||||
|
# 현재 활성 쌍 → 최초 탐지 시각 등록
|
||||||
|
for pair in active_pairs:
|
||||||
|
if pair not in pair_history:
|
||||||
|
pair_history[pair] = now
|
||||||
|
|
||||||
|
# 비활성 쌍 → pair_history에서 제거 (다음 접근 시 재시작)
|
||||||
|
inactive = [key for key in pair_history if key not in active_pairs]
|
||||||
|
for key in inactive:
|
||||||
|
del pair_history[key]
|
||||||
|
|
||||||
|
# 만료 항목 정리 (비활성 제거 후 잔여 방어용)
|
||||||
|
_evict_expired_pairs(pair_history, now)
|
||||||
|
|
||||||
|
# ── 4. 의심 쌍 판정 ──────────────────────────────────────
|
||||||
|
suspects: list[tuple[str, str, int]] = []
|
||||||
|
|
||||||
|
for pair, first_seen in pair_history.items():
|
||||||
|
duration_min = int((now - first_seen).total_seconds() / 60)
|
||||||
|
if duration_min >= SUSPECT_DURATION_MIN:
|
||||||
|
suspects.append((pair[0], pair[1], duration_min))
|
||||||
|
|
||||||
|
if suspects:
|
||||||
|
logger.info(
|
||||||
|
'transshipment detection: %d suspect pairs (candidates=%d)',
|
||||||
|
len(suspects),
|
||||||
|
len(candidates),
|
||||||
|
)
|
||||||
|
|
||||||
|
return suspects
|
||||||
@ -74,7 +74,9 @@ def upsert_results(results: list['AnalysisResult']) -> int:
|
|||||||
ucaf_score, ucft_score, is_dark, gap_duration_min,
|
ucaf_score, ucft_score, is_dark, gap_duration_min,
|
||||||
spoofing_score, bd09_offset_m, speed_jump_count,
|
spoofing_score, bd09_offset_m, speed_jump_count,
|
||||||
cluster_size, is_leader, fleet_role,
|
cluster_size, is_leader, fleet_role,
|
||||||
risk_score, risk_level, features, analyzed_at
|
risk_score, risk_level,
|
||||||
|
is_transship_suspect, transship_pair_mmsi, transship_duration_min,
|
||||||
|
features, analyzed_at
|
||||||
) VALUES %s
|
) VALUES %s
|
||||||
ON CONFLICT (mmsi, timestamp) DO UPDATE SET
|
ON CONFLICT (mmsi, timestamp) DO UPDATE SET
|
||||||
vessel_type = EXCLUDED.vessel_type,
|
vessel_type = EXCLUDED.vessel_type,
|
||||||
@ -97,6 +99,9 @@ def upsert_results(results: list['AnalysisResult']) -> int:
|
|||||||
fleet_role = EXCLUDED.fleet_role,
|
fleet_role = EXCLUDED.fleet_role,
|
||||||
risk_score = EXCLUDED.risk_score,
|
risk_score = EXCLUDED.risk_score,
|
||||||
risk_level = EXCLUDED.risk_level,
|
risk_level = EXCLUDED.risk_level,
|
||||||
|
is_transship_suspect = EXCLUDED.is_transship_suspect,
|
||||||
|
transship_pair_mmsi = EXCLUDED.transship_pair_mmsi,
|
||||||
|
transship_duration_min = EXCLUDED.transship_duration_min,
|
||||||
features = EXCLUDED.features,
|
features = EXCLUDED.features,
|
||||||
analyzed_at = EXCLUDED.analyzed_at
|
analyzed_at = EXCLUDED.analyzed_at
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -44,6 +44,11 @@ class AnalysisResult:
|
|||||||
risk_score: int = 0
|
risk_score: int = 0
|
||||||
risk_level: str = 'LOW'
|
risk_level: str = 'LOW'
|
||||||
|
|
||||||
|
# ALGO 08: 환적 의심
|
||||||
|
is_transship_suspect: bool = False
|
||||||
|
transship_pair_mmsi: str = ''
|
||||||
|
transship_duration_min: int = 0
|
||||||
|
|
||||||
# 특징 벡터
|
# 특징 벡터
|
||||||
features: dict = field(default_factory=dict)
|
features: dict = field(default_factory=dict)
|
||||||
|
|
||||||
@ -91,6 +96,9 @@ class AnalysisResult:
|
|||||||
str(self.fleet_role),
|
str(self.fleet_role),
|
||||||
_i(self.risk_score),
|
_i(self.risk_score),
|
||||||
str(self.risk_level),
|
str(self.risk_level),
|
||||||
|
bool(self.is_transship_suspect),
|
||||||
|
str(self.transship_pair_mmsi),
|
||||||
|
_i(self.transship_duration_min),
|
||||||
json.dumps(safe_features),
|
json.dumps(safe_features),
|
||||||
self.analyzed_at,
|
self.analyzed_at,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -18,6 +18,8 @@ _last_run: dict = {
|
|||||||
'error': None,
|
'error': None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_transship_pair_history: dict = {}
|
||||||
|
|
||||||
|
|
||||||
def get_last_run() -> dict:
|
def get_last_run() -> dict:
|
||||||
return _last_run.copy()
|
return _last_run.copy()
|
||||||
@ -158,7 +160,22 @@ def run_analysis_cycle():
|
|||||||
features=c.get('features', {}),
|
features=c.get('features', {}),
|
||||||
))
|
))
|
||||||
|
|
||||||
# 6. 결과 저장
|
# 6. 환적 의심 탐지 (pair_history 모듈 레벨로 사이클 간 유지)
|
||||||
|
from algorithms.transshipment import detect_transshipment
|
||||||
|
|
||||||
|
results_map = {r.mmsi: r for r in results}
|
||||||
|
transship_pairs = detect_transshipment(df_targets, _transship_pair_history)
|
||||||
|
for mmsi_a, mmsi_b, dur in transship_pairs:
|
||||||
|
if mmsi_a in results_map:
|
||||||
|
results_map[mmsi_a].is_transship_suspect = True
|
||||||
|
results_map[mmsi_a].transship_pair_mmsi = mmsi_b
|
||||||
|
results_map[mmsi_a].transship_duration_min = dur
|
||||||
|
if mmsi_b in results_map:
|
||||||
|
results_map[mmsi_b].is_transship_suspect = True
|
||||||
|
results_map[mmsi_b].transship_pair_mmsi = mmsi_a
|
||||||
|
results_map[mmsi_b].transship_duration_min = dur
|
||||||
|
|
||||||
|
# 7. 결과 저장
|
||||||
upserted = kcgdb.upsert_results(results)
|
upserted = kcgdb.upsert_results(results)
|
||||||
kcgdb.cleanup_old(hours=48)
|
kcgdb.cleanup_old(hours=48)
|
||||||
|
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user