refactor(frontend): 패키지 구조 리팩토링 — 공통/탭별 분리 + 데이터 훅 추출

- components/ 서브디렉토리 재배치: common/, layers/, iran/, korea/
- App.tsx God Component 분해: 1,179줄 → 588줄 (50% 감소)
- useIranData: 이란 데이터 로딩 + propagation + OSINT 병합
- useKoreaData: 한국 데이터 로딩 + propagation
- useKoreaFilters: 감시 로직 (환적/다크베셀/케이블/독도) 분리
- getMarineTrafficCategory → utils/marineTraffic.ts 추출

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-03-18 07:25:35 +09:00
부모 9f3eb4814a
커밋 5e55a495bc
33개의 변경된 파일920개의 추가작업 그리고 728개의 파일을 삭제

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -2,10 +2,10 @@ import { useEffect, useMemo, useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Map, Marker, Popup, Source, Layer, NavigationControl } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre';
import { AircraftLayer } from './AircraftLayer';
import { SatelliteLayer } from './SatelliteLayer';
import { ShipLayer } from './ShipLayer';
import { DamagedShipLayer } from './DamagedShipLayer';
import { AircraftLayer } from '../layers/AircraftLayer';
import { SatelliteLayer } from '../layers/SatelliteLayer';
import { ShipLayer } from '../layers/ShipLayer';
import { DamagedShipLayer } from '../layers/DamagedShipLayer';
import { OilFacilityLayer } from './OilFacilityLayer';
import { AirportLayer } from './AirportLayer';
import { iranOilFacilities } from '../data/oilFacilities';

파일 보기

@ -2,10 +2,10 @@ import { useMemo, useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Map, Marker, Popup, Source, Layer, NavigationControl } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre';
import { AircraftLayer } from './AircraftLayer';
import { SatelliteLayer } from './SatelliteLayer';
import { ShipLayer } from './ShipLayer';
import { DamagedShipLayer } from './DamagedShipLayer';
import { AircraftLayer } from '../layers/AircraftLayer';
import { SatelliteLayer } from '../layers/SatelliteLayer';
import { ShipLayer } from '../layers/ShipLayer';
import { DamagedShipLayer } from '../layers/DamagedShipLayer';
import { OilFacilityLayer } from './OilFacilityLayer';
import { AirportLayer } from './AirportLayer';
import { iranOilFacilities } from '../data/oilFacilities';

파일 보기

@ -2,10 +2,10 @@ import { useRef, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Map, NavigationControl, Marker, Source, Layer } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre';
import { ShipLayer } from './ShipLayer';
import { ShipLayer } from '../layers/ShipLayer';
import { InfraLayer } from './InfraLayer';
import { SatelliteLayer } from './SatelliteLayer';
import { AircraftLayer } from './AircraftLayer';
import { SatelliteLayer } from '../layers/SatelliteLayer';
import { AircraftLayer } from '../layers/AircraftLayer';
import { SubmarineCableLayer } from './SubmarineCableLayer';
import { CctvLayer } from './CctvLayer';
import { KoreaAirportLayer } from './KoreaAirportLayer';

파일 보기

@ -0,0 +1,256 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { fetchEvents, fetchSensorData } from '../services/api';
import { fetchAircraftFromBackend } from '../services/aircraftApi';
import { getSampleAircraft } from '../data/sampleAircraft';
import { fetchSatelliteTLE, propagateAll } from '../services/celestrak';
import { fetchShips } from '../services/ships';
import { fetchOsintFeed } from '../services/osint';
import type { OsintItem } from '../services/osint';
import { propagateAircraft, propagateShips } from '../services/propagation';
import { getMarineTrafficCategory } from '../utils/marineTraffic';
import type { GeoEvent, SensorLog, Aircraft, Ship, Satellite, SatellitePosition, AppMode } from '../types';
interface UseIranDataArgs {
appMode: AppMode;
currentTime: number;
isLive: boolean;
hiddenAcCategories: Set<string>;
hiddenShipCategories: Set<string>;
refreshKey: number;
dashboardTab: 'iran' | 'korea';
}
interface UseIranDataResult {
aircraft: Aircraft[];
ships: Ship[];
visibleAircraft: Aircraft[];
visibleShips: Ship[];
satPositions: SatellitePosition[];
events: GeoEvent[];
mergedEvents: GeoEvent[];
sensorData: SensorLog[];
osintFeed: OsintItem[];
aircraftByCategory: Record<string, number>;
militaryCount: number;
shipsByCategory: Record<string, number>;
koreanShips: Ship[];
koreanShipsByCategory: Record<string, number>;
}
export function useIranData({
appMode,
currentTime,
isLive,
hiddenAcCategories,
hiddenShipCategories,
refreshKey,
dashboardTab,
}: UseIranDataArgs): UseIranDataResult {
const [events, setEvents] = useState<GeoEvent[]>([]);
const [sensorData, setSensorData] = useState<SensorLog[]>([]);
const [baseAircraft, setBaseAircraft] = useState<Aircraft[]>([]);
const [baseShips, setBaseShips] = useState<Ship[]>([]);
const [satellites, setSatellites] = useState<Satellite[]>([]);
const [satPositions, setSatPositions] = useState<SatellitePosition[]>([]);
const [osintFeed, setOsintFeed] = useState<OsintItem[]>([]);
const satTimeRef = useRef(0);
// Load initial data
useEffect(() => {
fetchEvents().then(setEvents).catch(() => {});
fetchSensorData().then(setSensorData).catch(() => {});
fetchSatelliteTLE().then(setSatellites).catch(() => {});
}, [refreshKey]);
// Fetch base aircraft data (LIVE: backend, REPLAY: sample)
useEffect(() => {
const load = async () => {
if (appMode === 'live') {
const result = await fetchAircraftFromBackend('iran');
if (result.length > 0) setBaseAircraft(result);
} else {
setBaseAircraft(getSampleAircraft());
}
};
load();
const interval = setInterval(load, 60_000);
return () => clearInterval(interval);
}, [appMode, refreshKey]);
// Fetch Iran ship data (signal-batch + sample military, 5-min cycle)
useEffect(() => {
const load = async () => {
try {
const data = await fetchShips();
if (data.length > 0) {
setBaseShips(data);
}
} catch {
// keep previous data
}
};
load();
const interval = setInterval(load, 300_000);
return () => clearInterval(interval);
}, [appMode, refreshKey]);
// Fetch OSINT feed — live mode (both tabs) + replay mode (iran tab)
useEffect(() => {
const shouldFetch = isLive || dashboardTab === 'iran';
if (!shouldFetch) { setOsintFeed([]); return; }
const load = async () => {
try {
const data = await fetchOsintFeed('iran');
if (data.length > 0) setOsintFeed(data);
} catch { /* keep previous */ }
};
setOsintFeed([]);
load();
const interval = setInterval(load, 120_000);
return () => clearInterval(interval);
}, [isLive, dashboardTab, refreshKey]);
// Propagate satellite positions — throttle to every 2s of real time
useEffect(() => {
if (satellites.length === 0) return;
const now = Date.now();
if (now - satTimeRef.current < 2000) return;
satTimeRef.current = now;
const positions = propagateAll(satellites, new Date(currentTime));
setSatPositions(positions);
}, [satellites, currentTime]);
// Propagate aircraft positions based on current time
const aircraft = useMemo(
() => propagateAircraft(baseAircraft, currentTime),
[baseAircraft, currentTime],
);
// Propagate ship positions based on current time
const ships = useMemo(
() => propagateShips(baseShips, currentTime, isLive),
[baseShips, currentTime, isLive],
);
// Category-filtered data for map rendering
const visibleAircraft = useMemo(
() => aircraft.filter(a => !hiddenAcCategories.has(a.category)),
[aircraft, hiddenAcCategories],
);
const visibleShips = useMemo(
() => ships.filter(s => !hiddenShipCategories.has(getMarineTrafficCategory(s.typecode, s.category))),
[ships, hiddenShipCategories],
);
// OSINT → GeoEvent 변환
const osintEvents = useMemo((): GeoEvent[] => {
if (dashboardTab !== 'iran' || osintFeed.length === 0) return [];
const STRIKE_PATTERN = /strike|attack|bomb|airstrike|hit|destroy|blast|공습|타격|폭격|파괴|피격/i;
const MISSILE_PATTERN = /missile|launch|drone|발사|미사일|드론/i;
const EXPLOSION_PATTERN = /explo|blast|deton|fire|폭발|화재|폭파/i;
const INTERCEPT_PATTERN = /intercept|shoot.*down|defense|요격|격추|방어/i;
const IMPACT_PATTERN = /impact|hit|struck|damage|casualt|피격|타격|피해|사상/i;
const categoryToType: Record<string, GeoEvent['type']> = {
military: 'osint',
shipping: 'osint',
oil: 'osint',
nuclear: 'osint',
diplomacy: 'osint',
};
return osintFeed
.filter(item => {
if (!item.lat || !item.lng) return false;
return item.category in categoryToType;
})
.map((item): GeoEvent => {
let eventType: GeoEvent['type'] = 'osint';
const title = item.title;
if (IMPACT_PATTERN.test(title)) eventType = 'impact';
else if (STRIKE_PATTERN.test(title)) eventType = 'airstrike';
else if (MISSILE_PATTERN.test(title)) eventType = 'missile_launch';
else if (EXPLOSION_PATTERN.test(title)) eventType = 'explosion';
else if (INTERCEPT_PATTERN.test(title)) eventType = 'intercept';
let source: GeoEvent['source'] | undefined;
if (/US|미국|America|Pentagon|CENTCOM/i.test(title)) source = 'US';
else if (/Israel|이스라엘|IAF|IDF/i.test(title)) source = 'IL';
else if (/Iran|이란|IRGC/i.test(title)) source = 'IR';
else if (/Houthi|후티|Hezbollah|헤즈볼라|PMF|proxy|대리/i.test(title)) source = 'proxy';
return {
id: `osint-${item.id}`,
timestamp: item.timestamp,
lat: item.lat!,
lng: item.lng!,
type: eventType,
source,
label: `[OSINT] ${item.title}`,
description: `출처: ${item.source} | ${item.url}`,
intensity: eventType === 'impact' ? 80 : eventType === 'airstrike' ? 70 : 50,
};
});
}, [osintFeed, dashboardTab]);
// 기본 이벤트 + OSINT 이벤트 병합 (시간순 정렬)
const mergedEvents = useMemo(() => {
if (osintEvents.length === 0) return events;
return [...events, ...osintEvents].sort((a, b) => a.timestamp - b.timestamp);
}, [events, osintEvents]);
// Aircraft stats
const aircraftByCategory = useMemo(() => {
const counts: Record<string, number> = {};
for (const ac of aircraft) {
counts[ac.category] = (counts[ac.category] || 0) + 1;
}
return counts;
}, [aircraft]);
const militaryCount = useMemo(
() => aircraft.filter(a => a.category !== 'civilian' && a.category !== 'unknown').length,
[aircraft],
);
// Ship stats — MT classification
const shipsByCategory = useMemo(() => {
const counts: Record<string, number> = {};
for (const s of ships) {
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
counts[mtCat] = (counts[mtCat] || 0) + 1;
}
return counts;
}, [ships]);
// Korean ship stats
const koreanShips = useMemo(() => ships.filter(s => s.flag === 'KR'), [ships]);
const koreanShipsByCategory = useMemo(() => {
const counts: Record<string, number> = {};
for (const s of koreanShips) {
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
counts[mtCat] = (counts[mtCat] || 0) + 1;
}
return counts;
}, [koreanShips]);
return {
aircraft,
ships,
visibleAircraft,
visibleShips,
satPositions,
events,
mergedEvents,
sensorData,
osintFeed,
aircraftByCategory,
militaryCount,
shipsByCategory,
koreanShips,
koreanShipsByCategory,
};
}

파일 보기

@ -0,0 +1,160 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { fetchAircraftFromBackend } from '../services/aircraftApi';
import { fetchSatelliteTLEKorea, propagateAll } from '../services/celestrak';
import { fetchShipsKorea } from '../services/ships';
import { fetchOsintFeed } from '../services/osint';
import type { OsintItem } from '../services/osint';
import { propagateAircraft, propagateShips } from '../services/propagation';
import { getMarineTrafficCategory } from '../utils/marineTraffic';
import type { Aircraft, Ship, Satellite, SatellitePosition } from '../types';
interface UseKoreaDataArgs {
currentTime: number;
isLive: boolean;
hiddenAcCategories: Set<string>;
hiddenShipCategories: Set<string>;
refreshKey: number;
}
interface UseKoreaDataResult {
aircraft: Aircraft[];
ships: Ship[];
visibleAircraft: Aircraft[];
visibleShips: Ship[];
satPositions: SatellitePosition[];
osintFeed: OsintItem[];
koreaKoreanShips: Ship[];
koreaChineseShips: Ship[];
shipsByCategory: Record<string, number>;
aircraftByCategory: Record<string, number>;
militaryCount: number;
}
export function useKoreaData({
currentTime,
isLive,
hiddenAcCategories,
hiddenShipCategories,
refreshKey,
}: UseKoreaDataArgs): UseKoreaDataResult {
const [baseAircraftKorea, setBaseAircraftKorea] = useState<Aircraft[]>([]);
const [baseShipsKorea, setBaseShipsKorea] = useState<Ship[]>([]);
const [satellitesKorea, setSatellitesKorea] = useState<Satellite[]>([]);
const [satPositionsKorea, setSatPositionsKorea] = useState<SatellitePosition[]>([]);
const [osintFeed, setOsintFeed] = useState<OsintItem[]>([]);
const satTimeKoreaRef = useRef(0);
// Fetch Korea satellite TLE data
useEffect(() => {
fetchSatelliteTLEKorea().then(setSatellitesKorea).catch(() => {});
}, [refreshKey]);
// Fetch Korea aircraft data
useEffect(() => {
const load = async () => {
const result = await fetchAircraftFromBackend('korea');
if (result.length > 0) setBaseAircraftKorea(result);
};
load();
const interval = setInterval(load, 60_000);
return () => clearInterval(interval);
}, [refreshKey]);
// Fetch Korea region ship data (signal-batch, 4-min cycle)
useEffect(() => {
const load = async () => {
try {
const data = await fetchShipsKorea();
if (data.length > 0) setBaseShipsKorea(data);
} catch { /* keep previous */ }
};
load();
const interval = setInterval(load, 240_000);
return () => clearInterval(interval);
}, [refreshKey]);
// Fetch OSINT feed for Korea tab
useEffect(() => {
const load = async () => {
try {
const data = await fetchOsintFeed('korea');
if (data.length > 0) setOsintFeed(data);
} catch { /* keep previous */ }
};
load();
const interval = setInterval(load, 120_000);
return () => clearInterval(interval);
}, [refreshKey]);
// Propagate Korea satellite positions
useEffect(() => {
if (satellitesKorea.length === 0) return;
const now = Date.now();
if (now - satTimeKoreaRef.current < 2000) return;
satTimeKoreaRef.current = now;
const positions = propagateAll(satellitesKorea, new Date(currentTime));
setSatPositionsKorea(positions);
}, [satellitesKorea, currentTime]);
// Propagate Korea aircraft (live only — no waypoint propagation needed)
const aircraft = useMemo(() => propagateAircraft(baseAircraftKorea, currentTime), [baseAircraftKorea, currentTime]);
// Korea region ships
const ships = useMemo(
() => propagateShips(baseShipsKorea, currentTime, isLive),
[baseShipsKorea, currentTime, isLive],
);
// Category-filtered data for map rendering
const visibleAircraft = useMemo(
() => aircraft.filter(a => !hiddenAcCategories.has(a.category)),
[aircraft, hiddenAcCategories],
);
const visibleShips = useMemo(
() => ships.filter(s => !hiddenShipCategories.has(getMarineTrafficCategory(s.typecode, s.category))),
[ships, hiddenShipCategories],
);
// Korea region stats
const koreaKoreanShips = useMemo(() => ships.filter(s => s.flag === 'KR'), [ships]);
const koreaChineseShips = useMemo(() => ships.filter(s => s.flag === 'CN'), [ships]);
const shipsByCategory = useMemo(() => {
const counts: Record<string, number> = {};
for (const s of ships) {
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
counts[mtCat] = (counts[mtCat] || 0) + 1;
}
return counts;
}, [ships]);
// Korea aircraft stats
const aircraftByCategory = useMemo(() => {
const counts: Record<string, number> = {};
for (const ac of aircraft) {
counts[ac.category] = (counts[ac.category] || 0) + 1;
}
return counts;
}, [aircraft]);
const militaryCount = useMemo(
() => aircraft.filter(a => a.category !== 'civilian' && a.category !== 'unknown').length,
[aircraft],
);
return {
aircraft,
ships,
visibleAircraft,
visibleShips,
satPositions: satPositionsKorea,
osintFeed,
koreaKoreanShips,
koreaChineseShips,
shipsByCategory,
aircraftByCategory,
militaryCount,
};
}

파일 보기

@ -0,0 +1,320 @@
import { useState, useMemo, useRef } from 'react';
import { KOREA_SUBMARINE_CABLES } from '../services/submarineCable';
import { getMarineTrafficCategory } from '../utils/marineTraffic';
import type { Ship } from '../types';
interface KoreaFilters {
illegalFishing: boolean;
illegalTransship: boolean;
darkVessel: boolean;
cableWatch: boolean;
dokdoWatch: boolean;
ferryWatch: boolean;
}
interface DokdoAlert {
mmsi: string;
name: string;
dist: number;
time: number;
}
interface UseKoreaFiltersResult {
filters: KoreaFilters;
setFilter: (key: keyof KoreaFilters, value: boolean) => void;
filteredShips: Ship[];
transshipSuspects: Set<string>;
cableWatchSuspects: Set<string>;
dokdoWatchSuspects: Set<string>;
dokdoAlerts: DokdoAlert[];
anyFilterOn: boolean;
}
const TRANSSHIP_DURATION_MS = 60 * 60 * 1000; // 1시간
const ONE_HOUR_MS = 60 * 60 * 1000;
const CABLE_DURATION_MS = 3 * 60 * 60 * 1000; // 3시간
const DOKDO = { lat: 37.2417, lng: 131.8647 };
const TERRITORIAL_DEG = 0.2; // ~22km (12해리)
const ALERT_DEG = 0.4; // ~44km
export function useKoreaFilters(
koreaShips: Ship[],
visibleShips: Ship[],
currentTime: number,
): UseKoreaFiltersResult {
const [filters, setFilters] = useState<KoreaFilters>({
illegalFishing: false,
illegalTransship: false,
darkVessel: false,
cableWatch: false,
dokdoWatch: false,
ferryWatch: false,
});
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 cableNearStartRef = useRef<Map<string, number>>(new Map());
const dokdoAlertedRef = useRef<Set<string>>(new Set());
const setFilter = (key: keyof KoreaFilters, value: boolean) => {
setFilters(prev => ({ ...prev, [key]: value }));
};
const anyFilterOn =
filters.illegalFishing ||
filters.illegalTransship ||
filters.darkVessel ||
filters.cableWatch ||
filters.dokdoWatch ||
filters.ferryWatch;
// 불법환적 의심 선박 탐지
const transshipSuspects = useMemo(() => {
if (!filters.illegalTransship) return new Set<string>();
const suspects = new Set<string>();
const isOffshore = (s: Ship) => {
const nearCoastWest = s.lng > 125.5 && s.lng < 130.0 && s.lat > 33.5 && s.lat < 38.5;
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);
}
}
}
}
for (const key of prevMap.keys()) {
if (!currentPairs.has(key)) prevMap.delete(key);
}
return suspects;
}, [koreaShips, filters.illegalTransship, currentTime]);
// 다크베셀 탐지: AIS 신호 이력 추적
const darkVesselSet = useMemo(() => {
if (!filters.darkVessel) return new Set<string>();
const now = currentTime;
const history = aisHistoryRef.current;
const result = new Set<string>();
const currentMmsis = new Set(koreaShips.map(s => s.mmsi));
for (const s of koreaShips) {
let h = history.get(s.mmsi);
if (!h) {
h = { seen: [], lastGapStart: null };
history.set(s.mmsi, h);
}
if (h.lastGapStart !== null) {
const gapDuration = now - h.lastGapStart;
if (gapDuration >= ONE_HOUR_MS) {
result.add(s.mmsi);
}
h.lastGapStart = null;
}
h.seen.push(now);
if (h.seen.length > 20) h.seen = h.seen.slice(-20);
const aisAge = now - s.lastSeen;
if (aisAge > ONE_HOUR_MS) {
result.add(s.mmsi);
}
if (h.seen.length >= 4) {
let gapCount = 0;
for (let k = 1; k < h.seen.length; k++) {
const gap = h.seen[k] - h.seen[k - 1];
if (gap > 150_000) gapCount++;
}
if (gapCount >= 3) {
result.add(s.mmsi);
}
}
}
for (const [mmsi, h] of history.entries()) {
if (!currentMmsis.has(mmsi) && h.lastGapStart === null) {
h.lastGapStart = now;
}
}
const SIX_HOURS = 6 * ONE_HOUR_MS;
for (const [mmsi, h] of history.entries()) {
if (h.seen.length > 0 && now - h.seen[h.seen.length - 1] > SIX_HOURS && !currentMmsis.has(mmsi)) {
history.delete(mmsi);
}
}
return result;
}, [koreaShips, filters.darkVessel, currentTime]);
// 해저케이블 감시
const cableWatchSet = useMemo(() => {
if (!filters.cableWatch) return new Set<string>();
const result = new Set<string>();
const CABLE_PROX_DEG = 0.01; // ~1.1km
const segments: [number, number, number, number][] = [];
for (const cable of KOREA_SUBMARINE_CABLES) {
for (let k = 0; k < cable.route.length - 1; k++) {
segments.push([cable.route[k][0], cable.route[k][1], cable.route[k + 1][0], cable.route[k + 1][1]]);
}
}
const distToSegment = (px: number, py: number, x1: number, y1: number, x2: number, y2: number) => {
const dx = x2 - x1;
const dy = y2 - y1;
if (dx === 0 && dy === 0) return Math.hypot(px - x1, py - y1);
const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / (dx * dx + dy * dy)));
const cx = x1 + t * dx;
const cy = y1 + t * dy;
const dlng = (px - cx) * Math.cos((py * Math.PI) / 180);
return Math.hypot(dlng, py - cy);
};
const now = currentTime;
const prevMap = cableNearStartRef.current;
const currentNear = new Set<string>();
for (const s of koreaShips) {
if (s.speed > 0.6) continue;
let nearCable = false;
for (const [x1, y1, x2, y2] of segments) {
if (distToSegment(s.lng, s.lat, x1, y1, x2, y2) < CABLE_PROX_DEG) {
nearCable = true;
break;
}
}
if (!nearCable) continue;
currentNear.add(s.mmsi);
if (!prevMap.has(s.mmsi)) {
prevMap.set(s.mmsi, now);
}
const cableStartTime = prevMap.get(s.mmsi)!;
if (now - cableStartTime >= CABLE_DURATION_MS) {
result.add(s.mmsi);
}
}
for (const mmsi of prevMap.keys()) {
if (!currentNear.has(mmsi)) prevMap.delete(mmsi);
}
return result;
}, [koreaShips, filters.cableWatch, currentTime]);
// 독도감시
const dokdoWatchSet = useMemo(() => {
if (!filters.dokdoWatch) return new Set<string>();
const result = new Set<string>();
const newAlerts: DokdoAlert[] = [];
const alerted = dokdoAlertedRef.current;
for (const s of koreaShips) {
if (s.flag !== 'JP') continue;
const dDokdo = Math.hypot(
(s.lng - DOKDO.lng) * Math.cos((DOKDO.lat * Math.PI) / 180),
s.lat - DOKDO.lat,
);
if (dDokdo < TERRITORIAL_DEG) {
result.add(s.mmsi);
if (!alerted.has(s.mmsi)) {
alerted.add(s.mmsi);
const distKm = Math.round(dDokdo * 111);
newAlerts.push({ mmsi: s.mmsi, name: s.name || s.mmsi, dist: distKm, time: currentTime });
}
} else if (dDokdo < ALERT_DEG) {
result.add(s.mmsi);
if (!alerted.has(`warn-${s.mmsi}`)) {
alerted.add(`warn-${s.mmsi}`);
const distKm = Math.round(dDokdo * 111);
newAlerts.push({ mmsi: s.mmsi, name: s.name || s.mmsi, dist: distKm, time: currentTime });
}
}
}
const currentJP = new Set(koreaShips.filter(s => s.flag === 'JP').map(s => s.mmsi));
for (const key of alerted) {
const mmsi = key.replace('warn-', '');
if (!currentJP.has(mmsi)) alerted.delete(key);
}
if (newAlerts.length > 0) {
setDokdoAlerts(prev => [...newAlerts, ...prev].slice(0, 10));
}
return result;
}, [koreaShips, filters.dokdoWatch, currentTime]);
// 필터링된 선박 목록
const filteredShips = useMemo(() => {
if (!anyFilterOn) return visibleShips;
return visibleShips.filter(s => {
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
if (filters.illegalFishing && mtCat === 'fishing' && s.flag !== 'KR') return true;
if (filters.illegalTransship && transshipSuspects.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.dokdoWatch && dokdoWatchSet.has(s.mmsi)) return true;
if (filters.ferryWatch && getMarineTrafficCategory(s.typecode, s.category) === 'passenger') return true;
return false;
});
}, [visibleShips, filters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet]);
return {
filters,
setFilter,
filteredShips,
transshipSuspects,
cableWatchSuspects: cableWatchSet,
dokdoWatchSuspects: dokdoWatchSet,
dokdoAlerts,
anyFilterOn,
};
}

파일 보기

@ -9,14 +9,14 @@ const defaultConfig: ApiConfig = {
let cachedSensorData: SensorLog[] | null = null;
export async function fetchEvents(_config?: Partial<ApiConfig>): Promise<GeoEvent[]> { // eslint-disable-line @typescript-eslint/no-unused-vars
export async function fetchEvents(_config?: Partial<ApiConfig>): Promise<GeoEvent[]> {
// In production, replace with actual API call:
// const res = await fetch(config.eventsEndpoint);
// return res.json();
return Promise.resolve(sampleEvents);
}
export async function fetchSensorData(_config?: Partial<ApiConfig>): Promise<SensorLog[]> { // eslint-disable-line @typescript-eslint/no-unused-vars
export async function fetchSensorData(_config?: Partial<ApiConfig>): Promise<SensorLog[]> {
// In production, replace with actual API call:
// const res = await fetch(config.sensorEndpoint);
// return res.json();

파일 보기

@ -0,0 +1,47 @@
// MarineTraffic-style ship classification
// Maps S&P STAT5CODE prefixes and our custom typecodes to MT categories
export function getMarineTrafficCategory(typecode?: string, category?: string): string {
if (!typecode) {
// Fallback to our internal category
if (category === 'tanker') return 'tanker';
if (category === 'cargo') return 'cargo';
if (category === 'destroyer' || category === 'warship' || category === 'carrier' || category === 'patrol') return 'military';
return 'unspecified';
}
const code = typecode.toUpperCase();
// Our custom typecodes
if (code === 'VLCC' || code === 'LNG' || code === 'LPG') return 'tanker';
if (code === 'CONT' || code === 'BULK') return 'cargo';
if (code === 'DDH' || code === 'DDG' || code === 'CVN' || code === 'FFG' || code === 'LCS' || code === 'MCM' || code === 'PC') return 'military';
// S&P STAT5CODE (IHS StatCode5) — first 2 chars determine main category
// A1x = Tankers (crude, products, chemical, LPG, LNG)
if (code.startsWith('A1')) return 'tanker';
// A2x = Bulk carriers
if (code.startsWith('A2')) return 'cargo';
// A3x = General cargo / Container / Reefer / Ro-Ro
if (code.startsWith('A3')) return 'cargo';
// B1x / B2x = Passenger / Cruise / Ferry
if (code.startsWith('B')) return 'passenger';
// C1x = Fishing
if (code.startsWith('C')) return 'fishing';
// D1x = Offshore (tugs, supply, etc.)
if (code.startsWith('D')) return 'tug_special';
// E = Other activities (research, cable layers, dredgers)
if (code.startsWith('E')) return 'tug_special';
// X = Non-propelled (barges)
if (code.startsWith('X')) return 'unspecified';
// S&P VesselType strings
const lower = code.toLowerCase();
if (lower.includes('tanker')) return 'tanker';
if (lower.includes('cargo') || lower.includes('container') || lower.includes('bulk')) return 'cargo';
if (lower.includes('passenger') || lower.includes('cruise') || lower.includes('ferry')) return 'passenger';
if (lower.includes('fishing')) return 'fishing';
if (lower.includes('tug') || lower.includes('supply') || lower.includes('offshore')) return 'tug_special';
if (lower.includes('high speed')) return 'high_speed';
if (lower.includes('pleasure') || lower.includes('yacht') || lower.includes('sailing')) return 'pleasure';
return 'unspecified';
}