From 13427f32bbdde46094fa4528cdbc22484223ae67 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 12:24:36 +0900 Subject: [PATCH 1/9] =?UTF-8?q?perf:=20=EB=A0=8C=EB=8D=94=EB=A7=81=20?= =?UTF-8?q?=EC=84=B1=EB=8A=A5=20=EC=B5=9C=EC=A0=81=ED=99=94=20=E2=80=94=20?= =?UTF-8?q?deck.gl=20updateTriggers=20+=20=EC=84=A0=EB=B0=95=20=ED=86=A0?= =?UTF-8?q?=EA=B8=80=20MapLibre=20filter=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deck.gl updateTriggers 적용: 정적 레이어(4개 sub-hook) + 분석 레이어 + KoreaMap 인라인 레이어 → 줌 변경 시 accessor 재평가 최소화 - 선박 카테고리/국적 토글: JS-level 배열 필터링 → MapLibre GPU-side filter 표현식 → 토글 시 13K GeoJSON 재생성 + GPU 재업로드 제거 - Ship.mtCategory/natGroup 사전 계산: propagateShips 후 1회 계산, 이후 Set.has() O(1) → getMarineTrafficCategory() 13K×N회 호출 제거 - onPick useCallback 안정화: 매 렌더마다 28개 정적 레이어 불필요 재생성 방지 - SVG 데이터 URI 모듈 레벨 캐싱: 함수 호출 간 캐시 유지 - useAnalysisDeckLayers 데이터/스타일 분리: 줌 변경 시 ships 필터링 스킵 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/korea/KoreaDashboard.tsx | 4 +- frontend/src/components/korea/KoreaMap.tsx | 19 ++- frontend/src/components/layers/ShipLayer.tsx | 46 +++++-- .../src/hooks/layers/createFacilityLayers.ts | 14 ++- .../src/hooks/layers/createMilitaryLayers.ts | 16 ++- .../hooks/layers/createNavigationLayers.ts | 19 ++- frontend/src/hooks/layers/createPortLayers.ts | 13 +- frontend/src/hooks/useAnalysisDeckLayers.ts | 113 +++++++++++------- frontend/src/hooks/useKoreaData.ts | 28 +++-- frontend/src/hooks/useKoreaFilters.ts | 11 +- frontend/src/types.ts | 2 + 11 files changed, 193 insertions(+), 92 deletions(-) diff --git a/frontend/src/components/korea/KoreaDashboard.tsx b/frontend/src/components/korea/KoreaDashboard.tsx index d0a8e28..84ec81b 100644 --- a/frontend/src/components/korea/KoreaDashboard.tsx +++ b/frontend/src/components/korea/KoreaDashboard.tsx @@ -229,7 +229,7 @@ export const KoreaDashboard = ({ )}
; dokdoAlerts: { mmsi: string; name: string; dist: number; time: number }[]; vesselAnalysis?: UseVesselAnalysisResult; + hiddenShipCategories?: Set; + hiddenNationalities?: Set; } // MarineTraffic-style: satellite + dark ocean + nautical overlay @@ -133,7 +135,7 @@ const FILTER_I18N_KEY: Record = { ferryWatch: 'filters.ferryWatchMonitor', }; -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 mapRef = useRef(null); const [infra, setInfra] = useState([]); @@ -152,6 +154,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF } }, []); const [staticPickInfo, setStaticPickInfo] = useState(null); + const handleStaticPick = useCallback((info: StaticPickInfo) => setStaticPickInfo(info), []); const [analysisPanelOpen, setAnalysisPanelOpen] = useLocalStorage('analysisPanelOpen', false); useEffect(() => { @@ -224,6 +227,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF filled: true, radiusUnits: 'meters', lineWidthUnits: 'pixels', + updateTriggers: { getRadius: [zoomScale] }, }), [illegalFishingData, zoomScale]); const illegalFishingLabelLayer = useMemo(() => new TextLayer({ @@ -241,6 +245,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF outlineColor: [0, 0, 0, 200], billboard: false, characterSet: 'auto', + updateTriggers: { getSize: [zoomScale] }, }), [illegalFishingData, zoomScale]); // 수역 라벨 TextLayer — illegalFishing 필터 활성 시만 표시 @@ -277,6 +282,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF outlineColor: [0, 0, 0, 200], billboard: false, characterSet: 'auto', + updateTriggers: { getSize: [zoomScale] }, }); }, [koreaFilters.illegalFishing, zoomScale]); @@ -309,7 +315,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF cnMilitary: !!layers.cnMilitary, jpPower: !!layers.jpPower, jpMilitary: !!layers.jpMilitary, - onPick: (info) => setStaticPickInfo(info), + onPick: handleStaticPick, sizeScale: zoomScale, }); @@ -332,6 +338,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF radiusUnits: 'pixels', lineWidthUnits: 'pixels', getLineWidth: 1.5, + updateTriggers: { getRadius: [zoomScale] }, })); // 어구 이름 라벨 @@ -350,6 +357,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF outlineColor: [0, 0, 0, 220], billboard: false, characterSet: 'auto', + updateTriggers: { getSize: [zoomScale] }, })); // 모선 강조 — 큰 원 + 라벨 @@ -366,6 +374,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF radiusUnits: 'pixels', lineWidthUnits: 'pixels', getLineWidth: 3, + updateTriggers: { getRadius: [zoomScale] }, })); layers.push(new TextLayer({ id: 'selected-gear-parent-label', @@ -383,6 +392,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF outlineColor: [0, 0, 0, 220], billboard: false, characterSet: 'auto', + updateTriggers: { getSize: [zoomScale] }, })); } @@ -421,6 +431,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF radiusUnits: 'pixels', lineWidthUnits: 'pixels', getLineWidth: 2, + updateTriggers: { getRadius: [zoomScale] }, })); // 소속 선박 이름 라벨 @@ -445,6 +456,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF outlineColor: [0, 0, 0, 220], billboard: false, characterSet: 'auto', + updateTriggers: { getSize: [zoomScale], getText: [vesselAnalysis] }, })); // 리더 선박 추가 강조 (큰 외곽 링) @@ -465,6 +477,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF radiusUnits: 'pixels', lineWidthUnits: 'pixels', getLineWidth: 3, + updateTriggers: { getRadius: [zoomScale] }, })); } @@ -555,7 +568,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF /> - {layers.ships && } + {layers.ships && } {/* Transship suspect labels — Marker DOM, inline styles kept for dynamic border color */} {transshipSuspects.size > 0 && ships.filter(s => transshipSuspects.has(s.mmsi)).map(s => ( diff --git a/frontend/src/components/layers/ShipLayer.tsx b/frontend/src/components/layers/ShipLayer.tsx index afe2ad0..f1b92c3 100644 --- a/frontend/src/components/layers/ShipLayer.tsx +++ b/frontend/src/components/layers/ShipLayer.tsx @@ -3,7 +3,9 @@ import { Marker, Popup, Source, Layer, useMap } from 'react-map-gl/maplibre'; import { useTranslation } from 'react-i18next'; import type { Ship, VesselAnalysisDto } from '../../types'; 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 { ships: Ship[]; @@ -13,6 +15,8 @@ interface Props { focusMmsi?: string | null; onFocusClear?: () => void; analysisMap?: Map; + hiddenShipCategories?: Set; + hiddenNationalities?: Set; } @@ -268,7 +272,7 @@ function ensureTriangleImage(map: maplibregl.Map) { } // ── 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 [selectedMmsi, setSelectedMmsi] = useState(null); const [imageReady, setImageReady] = useState(false); @@ -283,12 +287,6 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM } }, [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 useEffect(() => { if (!map) return; @@ -302,9 +300,9 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM return () => { m.off('load', addIcon); }; }, [map]); - // Build GeoJSON for all ships + // Build GeoJSON from ALL ships (category/nationality filtering is GPU-side via MapLibre filter) const shipGeoJson = useMemo(() => { - const features: GeoJSON.Feature[] = filtered.map(ship => ({ + const features: GeoJSON.Feature[] = ships.map(ship => ({ type: 'Feature' as const, properties: { mmsi: ship.mmsi, @@ -315,6 +313,8 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM isKorean: ship.flag === 'KR' ? 1 : 0, isCheonghae: ship.mmsi === '440001981' ? 1 : 0, heading: ship.heading, + mtCategory: getMarineTrafficCategory(ship.typecode, ship.category), + natGroup: getNationalityGroup(ship.flag), }, geometry: { type: 'Point' as const, @@ -322,7 +322,25 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM }, })); 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 재생성 없이) useEffect(() => { @@ -369,7 +387,7 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM }; }, [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로 그룹핑) const selectedFleetMembers = useMemo(() => { @@ -415,7 +433,7 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM }, [selectedFleetMembers]); // 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 `; } +// ─── Module-level icon caches ───────────────────────────────────────────────── + +const infraIconCache = new Map(); + // ─── createFacilityLayers ───────────────────────────────────────────────────── export function createFacilityLayers( @@ -79,7 +83,6 @@ export function createFacilityLayers( // ── Infra ────────────────────────────────────────────────────────────── if (config.infra && config.infraFacilities.length > 0) { - const infraIconCache = new Map(); function getInfraIconUrl(f: PowerFacility): string { const key = `${f.type}-${f.source ?? ''}`; if (!infraIconCache.has(key)) { @@ -98,6 +101,7 @@ export function createFacilityLayers( getPosition: (d) => [d.lng, d.lat], getIcon: (d) => ({ url: getInfraIconUrl(d), width: 14, height: 14, anchorX: 7, anchorY: 7 }), getSize: 7 * sc, + updateTriggers: { getSize: [sc] }, pickable: true, onClick: (info: PickingInfo) => { if (info.object) onPick({ kind: 'infra', object: info.object }); @@ -110,6 +114,7 @@ export function createFacilityLayers( getPosition: (d) => [d.lng, d.lat], getIcon: (d) => ({ url: getInfraIconUrl(d), width: 24, height: 24, anchorX: 12, anchorY: 12 }), getSize: 12 * sc, + updateTriggers: { getSize: [sc] }, pickable: true, onClick: (info: PickingInfo) => { if (info.object) onPick({ kind: 'infra', object: info.object }); @@ -122,6 +127,7 @@ export function createFacilityLayers( getPosition: (d) => [d.lng, d.lat], getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name), getSize: 8 * sc, + updateTriggers: { getSize: [sc] }, getColor: (d) => [...hexToRgb(infraColor(d)), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -161,6 +167,7 @@ export function createFacilityLayers( getPosition: (d) => [d.lng, d.lat], getText: (d) => HAZARD_META[d.type]?.icon ?? '⚠️', getSize: 16 * sc, + updateTriggers: { getSize: [sc] }, getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 255], getTextAnchor: 'middle', getAlignmentBaseline: 'center', @@ -180,6 +187,7 @@ export function createFacilityLayers( getPosition: (d) => [d.lng, d.lat], getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo, getSize: 9 * sc, + updateTriggers: { getSize: [sc] }, getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 200], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -217,6 +225,7 @@ export function createFacilityLayers( getPosition: (d) => [d.lng, d.lat], getText: (d) => CN_META[d.subType]?.icon ?? '📍', getSize: 16 * sc, + updateTriggers: { getSize: [sc] }, getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 255], getTextAnchor: 'middle', getAlignmentBaseline: 'center', @@ -236,6 +245,7 @@ export function createFacilityLayers( getPosition: (d) => [d.lng, d.lat], getText: (d) => d.name, getSize: 9 * sc, + updateTriggers: { getSize: [sc] }, getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 200], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -272,6 +282,7 @@ export function createFacilityLayers( getPosition: (d) => [d.lng, d.lat], getText: (d) => JP_META[d.subType]?.icon ?? '📍', getSize: 16 * sc, + updateTriggers: { getSize: [sc] }, getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 255], getTextAnchor: 'middle', getAlignmentBaseline: 'center', @@ -291,6 +302,7 @@ export function createFacilityLayers( getPosition: (d) => [d.lng, d.lat], getText: (d) => d.name, getSize: 9 * sc, + updateTriggers: { getSize: [sc] }, getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 200], getTextAnchor: 'middle', getAlignmentBaseline: 'top', diff --git a/frontend/src/hooks/layers/createMilitaryLayers.ts b/frontend/src/hooks/layers/createMilitaryLayers.ts index 5ab8570..b1de305 100644 --- a/frontend/src/hooks/layers/createMilitaryLayers.ts +++ b/frontend/src/hooks/layers/createMilitaryLayers.ts @@ -35,6 +35,11 @@ function missileImpactSvg(color: string): string { `; } +// ─── Module-level icon caches ───────────────────────────────────────────────── + +const launchIconCache = new Map(); +const impactIconCache = new Map(); + export function createMilitaryLayers( config: { militaryBases: boolean; govBuildings: boolean; nkLaunch: boolean; nkMissile: boolean }, fc: LayerFactoryConfig, @@ -59,6 +64,7 @@ export function createMilitaryLayers( getPosition: (d) => [d.lng, d.lat], getText: (d) => TYPE_ICON[d.type] ?? '⭐', getSize: 14 * sc, + updateTriggers: { getSize: [sc] }, getColor: [255, 255, 255, 220], getTextAnchor: 'middle', getAlignmentBaseline: 'center', @@ -76,6 +82,7 @@ export function createMilitaryLayers( getPosition: (d) => [d.lng, d.lat], getText: (d) => (d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo), getSize: 8 * sc, + updateTriggers: { getSize: [sc] }, getColor: (d) => [...hexToRgb(TYPE_COLOR[d.type] ?? '#a78bfa'), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -107,6 +114,7 @@ export function createMilitaryLayers( getPosition: (d) => [d.lng, d.lat], getText: (d) => GOV_TYPE_ICON[d.type] ?? '🏛', getSize: 12 * sc, + updateTriggers: { getSize: [sc] }, getColor: [255, 255, 255, 220], getTextAnchor: 'middle', getAlignmentBaseline: 'center', @@ -124,6 +132,7 @@ export function createMilitaryLayers( getPosition: (d) => [d.lng, d.lat], getText: (d) => (d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo), getSize: 8 * sc, + updateTriggers: { getSize: [sc] }, getColor: (d) => [...hexToRgb(GOV_TYPE_COLOR[d.type] ?? '#f59e0b'), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -147,6 +156,7 @@ export function createMilitaryLayers( getPosition: (d) => [d.lng, d.lat], getText: (d) => NK_LAUNCH_TYPE_META[d.type]?.icon ?? '🚀', getSize: (d) => (d.type === 'artillery' || d.type === 'mlrs' ? 10 : 13) * sc, + updateTriggers: { getSize: [sc] }, getColor: [255, 255, 255, 220], getTextAnchor: 'middle', getAlignmentBaseline: 'center', @@ -164,6 +174,7 @@ export function createMilitaryLayers( getPosition: (d) => [d.lng, d.lat], getText: (d) => (d.nameKo.length > 10 ? d.nameKo.slice(0, 10) + '..' : d.nameKo), getSize: 8 * sc, + updateTriggers: { getSize: [sc] }, getColor: (d) => [...hexToRgb(NK_LAUNCH_TYPE_META[d.type]?.color ?? '#f97316'), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -180,14 +191,12 @@ export function createMilitaryLayers( // ── NK Missile Events — IconLayer ───────────────────────────────────── if (config.nkMissile) { - const launchIconCache = new Map(); function getLaunchIconUrl(type: string): string { if (!launchIconCache.has(type)) { launchIconCache.set(type, svgToDataUri(missileLaunchSvg(getMissileColor(type)))); } return launchIconCache.get(type)!; } - const impactIconCache = new Map(); function getImpactIconUrl(type: string): string { if (!impactIconCache.has(type)) { impactIconCache.set(type, svgToDataUri(missileImpactSvg(getMissileColor(type)))); @@ -227,6 +236,7 @@ export function createMilitaryLayers( getPosition: (d) => [d.lng, d.lat], getIcon: (d) => ({ url: getLaunchIconUrl(d.ev.type), width: 24, height: 24, anchorX: 12, anchorY: 12 }), getSize: 12 * sc, + updateTriggers: { getSize: [sc] }, getColor: (d) => { const today = new Date().toISOString().slice(0, 10) === d.ev.date; 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], getIcon: (d) => ({ url: getImpactIconUrl(d.ev.type), width: 32, height: 32, anchorX: 16, anchorY: 16 }), getSize: 16 * sc, + updateTriggers: { getSize: [sc] }, getColor: (d) => { const today = new Date().toISOString().slice(0, 10) === d.ev.date; 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], getText: (d) => `${d.ev.date.slice(5)} ${d.ev.time} ← ${d.ev.launchNameKo}`, getSize: 8 * sc, + updateTriggers: { getSize: [sc] }, getColor: (d) => [...hexToRgb(getMissileColor(d.ev.type)), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', diff --git a/frontend/src/hooks/layers/createNavigationLayers.ts b/frontend/src/hooks/layers/createNavigationLayers.ts index 69a8df2..3095496 100644 --- a/frontend/src/hooks/layers/createNavigationLayers.ts +++ b/frontend/src/hooks/layers/createNavigationLayers.ts @@ -123,6 +123,13 @@ function piracySvg(color: string, size: number): string { `; } +// ─── Module-level icon caches ───────────────────────────────────────────────── + +const cgIconCache = new Map(); +const apIconCache = new Map(); +const nwIconCache = new Map(); +const piracyIconCache = new Map(); + export function createNavigationLayers( config: { coastGuard: boolean; airports: boolean; navWarning: boolean; piracy: boolean }, fc: LayerFactoryConfig, @@ -133,7 +140,6 @@ export function createNavigationLayers( // ── Coast Guard ──────────────────────────────────────────────────────── if (config.coastGuard) { - const cgIconCache = new Map(); function getCgIconUrl(type: CoastGuardType): string { if (!cgIconCache.has(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 }; }, getSize: (d) => CG_TYPE_SIZE[d.type] * sc, + updateTriggers: { getSize: [sc] }, pickable: true, onClick: (info: PickingInfo) => { if (info.object) onPick({ kind: 'coastGuard', object: info.object }); @@ -168,6 +175,7 @@ export function createNavigationLayers( return d.name.replace('해양경찰청', '').replace('지방', '').trim() || '본청'; }, getSize: 8 * sc, + updateTriggers: { getSize: [sc] }, getColor: (d) => [...hexToRgb(CG_TYPE_COLOR[d.type]), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -184,7 +192,6 @@ export function createNavigationLayers( // ── Airports ─────────────────────────────────────────────────────────── if (config.airports) { - const apIconCache = new Map(); function getApIconUrl(ap: KoreanAirport): string { const color = apColor(ap); 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 }; }, getSize: (d) => (d.intl ? 20 : 16) * sc, + updateTriggers: { getSize: [sc] }, pickable: true, onClick: (info: PickingInfo) => { if (info.object) onPick({ kind: 'airport', object: info.object }); @@ -217,6 +225,7 @@ export function createNavigationLayers( getPosition: (d) => [d.lng, d.lat], getText: (d) => d.nameKo.replace('국제공항', '').replace('공항', ''), getSize: 9 * sc, + updateTriggers: { getSize: [sc] }, getColor: (d) => [...hexToRgb(apColor(d)), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -233,7 +242,6 @@ export function createNavigationLayers( // ── NavWarning ───────────────────────────────────────────────────────── if (config.navWarning) { - const nwIconCache = new Map(); function getNwIconUrl(w: NavWarning): string { const key = `${w.level}-${w.org}`; 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 }; }, getSize: (d) => (d.level === 'danger' ? 16 : 14) * sc, + updateTriggers: { getSize: [sc] }, pickable: true, onClick: (info: PickingInfo) => { if (info.object) onPick({ kind: 'navWarning', object: info.object }); @@ -265,6 +274,7 @@ export function createNavigationLayers( getPosition: (d) => [d.lng, d.lat], getText: (d) => d.id, getSize: 8 * sc, + updateTriggers: { getSize: [sc] }, getColor: (d) => [...hexToRgb(NW_ORG_COLOR[d.org]), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -281,7 +291,6 @@ export function createNavigationLayers( // ── Piracy ───────────────────────────────────────────────────────────── if (config.piracy) { - const piracyIconCache = new Map(); function getPiracyIconUrl(zone: PiracyZone): string { const key = zone.level; 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 }; }, getSize: (d) => (d.level === 'critical' ? 28 : d.level === 'high' ? 24 : 20) * sc, + updateTriggers: { getSize: [sc] }, pickable: true, onClick: (info: PickingInfo) => { if (info.object) onPick({ kind: 'piracy', object: info.object }); @@ -314,6 +324,7 @@ export function createNavigationLayers( getPosition: (d) => [d.lng, d.lat], getText: (d) => d.nameKo, getSize: 9 * sc, + updateTriggers: { getSize: [sc] }, getColor: (d) => [...hexToRgb(PIRACY_LEVEL_COLOR[d.level] ?? '#ef4444'), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', diff --git a/frontend/src/hooks/layers/createPortLayers.ts b/frontend/src/hooks/layers/createPortLayers.ts index a336c1b..5629d44 100644 --- a/frontend/src/hooks/layers/createPortLayers.ts +++ b/frontend/src/hooks/layers/createPortLayers.ts @@ -45,6 +45,11 @@ function windTurbineSvg(size: number): string { `; } +// ─── Module-level icon caches ───────────────────────────────────────────────── + +const portIconCache = new Map(); +const WIND_ICON_URL = svgToDataUri(windTurbineSvg(36)); + export function createPortLayers( config: { ports: boolean; windFarm: boolean }, fc: LayerFactoryConfig, @@ -55,7 +60,6 @@ export function createPortLayers( // ── Ports ─────────────────────────────────────────────────────────────── if (config.ports) { - const portIconCache = new Map(); function getPortIconUrl(p: Port): string { const key = `${p.country}-${p.type}`; if (!portIconCache.has(key)) { @@ -79,6 +83,7 @@ export function createPortLayers( anchorY: d.type === 'major' ? 16 : 12, }), getSize: (d) => (d.type === 'major' ? 16 : 12) * sc, + updateTriggers: { getSize: [sc] }, pickable: true, onClick: (info: PickingInfo) => { if (info.object) onPick({ kind: 'port', object: info.object }); @@ -91,6 +96,7 @@ export function createPortLayers( getPosition: (d) => [d.lng, d.lat], getText: (d) => d.nameKo.replace('항', ''), getSize: 9 * sc, + updateTriggers: { getSize: [sc] }, getColor: (d) => [...hexToRgb(PORT_COUNTRY_COLOR[d.country] ?? PORT_COUNTRY_COLOR.KR), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -107,14 +113,14 @@ export function createPortLayers( // ── Wind Farms ───────────────────────────────────────────────────────── if (config.windFarm) { - const windUrl = svgToDataUri(windTurbineSvg(36)); layers.push( new IconLayer({ id: 'static-windfarm-icon', data: KOREA_WIND_FARMS, 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, + updateTriggers: { getSize: [sc] }, pickable: true, onClick: (info: PickingInfo) => { if (info.object) onPick({ kind: 'windFarm', object: info.object }); @@ -127,6 +133,7 @@ export function createPortLayers( getPosition: (d) => [d.lng, d.lat], getText: (d) => (d.name.length > 10 ? d.name.slice(0, 10) + '..' : d.name), getSize: 9 * sc, + updateTriggers: { getSize: [sc] }, getColor: [...hexToRgb(WIND_COLOR), 255] as [number, number, number, number], getTextAnchor: 'middle', getAlignmentBaseline: 'top', diff --git a/frontend/src/hooks/useAnalysisDeckLayers.ts b/frontend/src/hooks/useAnalysisDeckLayers.ts index 07d16c8..bf38f58 100644 --- a/frontend/src/hooks/useAnalysisDeckLayers.ts +++ b/frontend/src/hooks/useAnalysisDeckLayers.ts @@ -41,6 +41,12 @@ const RISK_PRIORITY: Record = { MEDIUM: 2, }; +interface AnalysisData { + riskData: AnalyzedShip[]; + darkData: AnalyzedShip[]; + spoofData: AnalyzedShip[]; +} + /** * 분석 결과 기반 deck.gl 레이어를 반환하는 훅. * AnalysisOverlay DOM Marker 대체 — GPU 렌더링으로 성능 향상. @@ -51,8 +57,11 @@ export function useAnalysisDeckLayers( activeFilter: string | null, sizeScale: number = 1.0, ): Layer[] { - return useMemo(() => { - if (analysisMap.size === 0) return []; + // 데이터 준비: ships 필터/정렬/슬라이스 — sizeScale 변경 시 재실행 안 됨 + const { riskData, darkData, spoofData } = useMemo(() => { + if (analysisMap.size === 0) { + return { riskData: [], darkData: [], spoofData: [] }; + } const analyzedShips: AnalyzedShip[] = ships .filter(s => analysisMap.has(s.mmsi)) @@ -70,6 +79,19 @@ export function useAnalysisDeckLayers( }) .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(() => { + if (riskData.length === 0 && darkData.length === 0 && spoofData.length === 0) return []; + const layers: Layer[] = []; // 위험도 원형 마커 @@ -86,6 +108,7 @@ export function useAnalysisDeckLayers( radiusUnits: 'pixels', lineWidthUnits: 'pixels', getLineWidth: 2, + updateTriggers: { getRadius: [sizeScale] }, }), ); @@ -101,6 +124,7 @@ export function useAnalysisDeckLayers( return `${name}\n${label} ${d.dto.algorithms.riskScore.score}`; }, getSize: 10 * sizeScale, + updateTriggers: { getSize: [sizeScale] }, getColor: (d) => RISK_RGBA_BORDER[d.dto.algorithms.riskScore.level] ?? [200, 200, 200, 255], getTextAnchor: 'middle', getAlignmentBaseline: 'top', @@ -114,53 +138,50 @@ export function useAnalysisDeckLayers( ); // 다크베셀 (activeFilter === 'darkVessel' 일 때만) - if (activeFilter === 'darkVessel') { - const darkData = analyzedShips.filter(({ dto }) => dto.algorithms.darkVessel.isDark); + if (activeFilter === 'darkVessel' && darkData.length > 0) { + layers.push( + new ScatterplotLayer({ + id: 'dark-vessel-markers', + data: darkData, + getPosition: (d) => [d.ship.lng, d.ship.lat], + getRadius: 12 * sizeScale, + getFillColor: [168, 85, 247, 40], + getLineColor: [168, 85, 247, 200], + stroked: true, + filled: true, + radiusUnits: 'pixels', + lineWidthUnits: 'pixels', + getLineWidth: 2, + updateTriggers: { getRadius: [sizeScale] }, + }), + ); - if (darkData.length > 0) { - layers.push( - new ScatterplotLayer({ - id: 'dark-vessel-markers', - data: darkData, - getPosition: (d) => [d.ship.lng, d.ship.lat], - getRadius: 12 * sizeScale, - getFillColor: [168, 85, 247, 40], - getLineColor: [168, 85, 247, 200], - stroked: true, - filled: true, - radiusUnits: 'pixels', - lineWidthUnits: 'pixels', - getLineWidth: 2, - }), - ); - - // 다크베셀 gap 라벨 - layers.push( - new TextLayer({ - id: 'dark-vessel-labels', - data: darkData, - getPosition: (d) => [d.ship.lng, d.ship.lat], - getText: (d) => { - const gap = d.dto.algorithms.darkVessel.gapDurationMin; - return gap > 0 ? `AIS 소실 ${Math.round(gap)}분` : 'DARK'; - }, - getSize: 10 * sizeScale, - getColor: [168, 85, 247, 255], - getTextAnchor: 'middle', - getAlignmentBaseline: 'top', - getPixelOffset: [0, 14], - fontFamily: 'monospace', - outlineWidth: 2, - outlineColor: [0, 0, 0, 200], - billboard: false, - characterSet: 'auto', - }), - ); - } + // 다크베셀 gap 라벨 + layers.push( + new TextLayer({ + id: 'dark-vessel-labels', + data: darkData, + getPosition: (d) => [d.ship.lng, d.ship.lat], + getText: (d) => { + const gap = d.dto.algorithms.darkVessel.gapDurationMin; + return gap > 0 ? `AIS 소실 ${Math.round(gap)}분` : 'DARK'; + }, + getSize: 10 * sizeScale, + updateTriggers: { getSize: [sizeScale] }, + getColor: [168, 85, 247, 255], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + getPixelOffset: [0, 14], + fontFamily: 'monospace', + outlineWidth: 2, + outlineColor: [0, 0, 0, 200], + billboard: false, + characterSet: 'auto', + }), + ); } // GPS 스푸핑 라벨 - const spoofData = analyzedShips.filter(({ dto }) => dto.algorithms.gpsSpoofing.spoofingScore > 0.5); if (spoofData.length > 0) { layers.push( new TextLayer({ @@ -183,5 +204,5 @@ export function useAnalysisDeckLayers( } return layers; - }, [analysisMap, ships, activeFilter, sizeScale]); + }, [riskData, darkData, spoofData, sizeScale, activeFilter]); } diff --git a/frontend/src/hooks/useKoreaData.ts b/frontend/src/hooks/useKoreaData.ts index 99ba14a..95b3e1a 100644 --- a/frontend/src/hooks/useKoreaData.ts +++ b/frontend/src/hooks/useKoreaData.ts @@ -145,13 +145,17 @@ export function useKoreaData({ // 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], - ); + // Korea region ships — pre-compute mtCategory/natGroup for O(1) filter lookups + const ships = useMemo(() => { + const propagated = propagateShips(baseShipsKorea, currentTime, isLive); + for (const s of propagated) { + s.mtCategory = getMarineTrafficCategory(s.typecode, s.category); + s.natGroup = getNationalityGroup(s.flag); + } + return propagated; + }, [baseShipsKorea, currentTime, isLive]); - // Category-filtered data for map rendering + // Category-filtered data for map rendering (Set.has = O(1) per ship) const visibleAircraft = useMemo( () => aircraft.filter(a => !hiddenAcCategories.has(a.category)), [aircraft, hiddenAcCategories], @@ -159,8 +163,8 @@ export function useKoreaData({ const visibleShips = useMemo( () => ships.filter(s => - !hiddenShipCategories.has(getMarineTrafficCategory(s.typecode, s.category)) - && !hiddenNationalities.has(getNationalityGroup(s.flag)), + !hiddenShipCategories.has(s.mtCategory!) + && !hiddenNationalities.has(s.natGroup!), ), [ships, hiddenShipCategories, hiddenNationalities], ); @@ -172,8 +176,7 @@ export function useKoreaData({ const shipsByCategory = useMemo(() => { const counts: Record = {}; for (const s of ships) { - const mtCat = getMarineTrafficCategory(s.typecode, s.category); - counts[mtCat] = (counts[mtCat] || 0) + 1; + counts[s.mtCategory!] = (counts[s.mtCategory!] || 0) + 1; } return counts; }, [ships]); @@ -181,8 +184,7 @@ export function useKoreaData({ const shipsByNationality = useMemo(() => { const counts: Record = {}; for (const s of ships) { - const nat = getNationalityGroup(s.flag); - counts[nat] = (counts[nat] || 0) + 1; + counts[s.natGroup!] = (counts[s.natGroup!] || 0) + 1; } return counts; }, [ships]); @@ -190,7 +192,7 @@ export function useKoreaData({ const fishingByNationality = useMemo(() => { const counts: Record = {}; for (const s of ships) { - if (getMarineTrafficCategory(s.typecode, s.category) !== 'fishing') continue; + if (s.mtCategory !== 'fishing') continue; const flag = s.flag || 'unknown'; const group = flag === 'CN' ? 'CN' : flag === 'KR' ? 'KR' : flag === 'JP' ? 'JP' : 'other'; counts[group] = (counts[group] || 0) + 1; diff --git a/frontend/src/hooks/useKoreaFilters.ts b/frontend/src/hooks/useKoreaFilters.ts index 253599d..b1f4d1e 100644 --- a/frontend/src/hooks/useKoreaFilters.ts +++ b/frontend/src/hooks/useKoreaFilters.ts @@ -1,7 +1,7 @@ import { useState, useMemo, useRef } from 'react'; import { useLocalStorage } from './useLocalStorage'; import { KOREA_SUBMARINE_CABLES } from '../services/submarineCable'; -import { getMarineTrafficCategory } from '../utils/marineTraffic'; +// mtCategory는 Ship 객체에 사전 계산됨 (useKoreaData) import { classifyFishingZone } from '../utils/fishingAnalysis'; import type { Ship, VesselAnalysisDto } from '../types'; @@ -101,7 +101,7 @@ export function useKoreaFilters( const candidates = koreaShips.filter(s => { if (s.speed >= 2) return false; - const mtCat = getMarineTrafficCategory(s.typecode, s.category); + const mtCat = s.mtCategory; if (mtCat !== 'tanker' && mtCat !== 'cargo' && mtCat !== 'fishing') return false; if (isNearForeignCoast(s)) return false; return isOffshore(s); @@ -310,10 +310,9 @@ export function useKoreaFilters( const filteredShips = useMemo(() => { if (!anyFilterOn) return visibleShips; return visibleShips.filter(s => { - const mtCat = getMarineTrafficCategory(s.typecode, s.category); if (filters.illegalFishing) { // 특정어업수역 Ⅰ~Ⅳ 내 비한국 어선만 불법어선으로 판별 - if (mtCat === 'fishing' && s.flag !== 'KR') { + if (s.mtCategory === 'fishing' && s.flag !== 'KR') { const zoneInfo = classifyFishingZone(s.lat, s.lng); if (zoneInfo.zone !== 'OUTSIDE') return true; } @@ -328,9 +327,9 @@ export function useKoreaFilters( 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; + if (filters.ferryWatch && s.mtCategory === 'passenger') return true; if (cnFishingOn) { - const isCnFishing = s.flag === 'CN' && getMarineTrafficCategory(s.typecode, s.category) === 'fishing'; + const isCnFishing = s.flag === 'CN' && s.mtCategory === 'fishing'; const isGearPattern = /^.+?_\d+_\d+_?$/.test(s.name || ''); if (isCnFishing || isGearPattern) return true; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index f2b582e..816e4aa 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -107,6 +107,8 @@ export interface Ship { activeEnd?: number; // unix ms - when ship leaves area shipImagePath?: string | null; // signal-batch image path 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 -- 2.45.2 From d9ba1b0e1ab24559af96e3f6cfc872dba855b97d Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 12:29:44 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20=ED=99=98=EC=A0=81=ED=83=90?= =?UTF-8?q?=EC=A7=80=20Python=20=EC=9D=B4=EA=B4=80=20=E2=80=94=20O(n=C2=B2?= =?UTF-8?q?)=20=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94=EB=93=9C=20=EA=B7=BC?= =?UTF-8?q?=EC=A0=91=ED=83=90=EC=A7=80=20=E2=86=92=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EB=93=9C=20=EA=B3=B5=EA=B0=84=EC=9D=B8?= =?UTF-8?q?=EB=8D=B1=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - prediction/algorithms/transshipment.py 신규: 그리드 공간인덱스 O(n log n) 환적 쌍 탐지 → 후보 필터(sog<2, tanker/cargo/fishing, 외국해안 제외) + 110m 근접 + 60분 지속 - prediction/scheduler.py: 8단계 환적탐지 사이클 추가, pair_history 영속화 - prediction/models/result.py: is_transship_suspect, transship_pair_mmsi, transship_duration_min - prediction/db/kcgdb.py: UPSERT 쿼리에 3개 컬럼 추가 - database/migration/008_transshipment.sql: ALTER TABLE 3개 컬럼 추가 - backend VesselAnalysisResult + VesselAnalysisDto: TransshipInfo 중첩 DTO 추가 - frontend types.ts: algorithms.transship 타입 추가 - frontend useKoreaFilters.ts: O(n²) 65줄 → analysisMap 소비 8줄 → currentTime 매초 의존성 제거, proximityStartRef 제거 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../domain/analysis/VesselAnalysisDto.java | 15 ++ .../domain/analysis/VesselAnalysisResult.java | 11 + database/migration/008_transshipment.sql | 7 + frontend/src/hooks/useKoreaFilters.ts | 70 +----- frontend/src/types.ts | 1 + prediction/algorithms/transshipment.py | 234 ++++++++++++++++++ prediction/db/kcgdb.py | 7 +- prediction/models/result.py | 8 + prediction/scheduler.py | 19 +- 9 files changed, 307 insertions(+), 65 deletions(-) create mode 100644 database/migration/008_transshipment.sql create mode 100644 prediction/algorithms/transshipment.py diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisDto.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisDto.java index a8d2e52..e6ce3cd 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisDto.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisDto.java @@ -39,6 +39,7 @@ public class VesselAnalysisDto { private ClusterInfo cluster; private FleetRoleInfo fleetRole; private RiskScoreInfo riskScore; + private TransshipInfo transship; } @Getter @@ -99,6 +100,15 @@ public class VesselAnalysisDto { 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) { return VesselAnalysisDto.builder() .mmsi(r.getMmsi()) @@ -141,6 +151,11 @@ public class VesselAnalysisDto { .score(r.getRiskScore()) .level(r.getRiskLevel()) .build()) + .transship(TransshipInfo.builder() + .isSuspect(r.getIsTransshipSuspect()) + .pairMmsi(r.getTransshipPairMmsi()) + .durationMin(r.getTransshipDurationMin()) + .build()) .build()) .features(r.getFeatures()) .build(); diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java index 0ba31d5..4a4e926 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java @@ -76,6 +76,14 @@ public class VesselAnalysisResult { @Column(length = 20) private String riskLevel; + @Column(nullable = false) + private Boolean isTransshipSuspect; + + @Column(length = 15) + private String transshipPairMmsi; + + private Integer transshipDurationMin; + @JdbcTypeCode(SqlTypes.JSON) @Column(columnDefinition = "jsonb") private Map features; @@ -94,5 +102,8 @@ public class VesselAnalysisResult { if (isLeader == null) { isLeader = false; } + if (isTransshipSuspect == null) { + isTransshipSuspect = false; + } } } diff --git a/database/migration/008_transshipment.sql b/database/migration/008_transshipment.sql new file mode 100644 index 0000000..b82e508 --- /dev/null +++ b/database/migration/008_transshipment.sql @@ -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; diff --git a/frontend/src/hooks/useKoreaFilters.ts b/frontend/src/hooks/useKoreaFilters.ts index b1f4d1e..5a7dcde 100644 --- a/frontend/src/hooks/useKoreaFilters.ts +++ b/frontend/src/hooks/useKoreaFilters.ts @@ -32,7 +32,6 @@ interface UseKoreaFiltersResult { 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 }; @@ -56,7 +55,6 @@ export function useKoreaFilters( }); const [dokdoAlerts, setDokdoAlerts] = useState([]); - const proximityStartRef = useRef>(new Map()); const aisHistoryRef = useRef>(new Map()); const cableNearStartRef = useRef>(new Map()); const dokdoAlertedRef = useRef>(new Set()); @@ -74,71 +72,17 @@ export function useKoreaFilters( filters.ferryWatch || cnFishingOn; - // 불법환적 의심 선박 탐지 + // 불법환적 의심 선박 탐지 (Python 분석 결과 소비) const transshipSuspects = useMemo(() => { if (!filters.illegalTransship) return new Set(); - - const suspects = new Set(); - 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 = s.mtCategory; - 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(); - 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); - } - } + const result = new Set(); + if (analysisMap) { + for (const [mmsi, dto] of analysisMap) { + if (dto.algorithms.transship?.isSuspect) result.add(mmsi); } } - - for (const key of prevMap.keys()) { - if (!currentPairs.has(key)) prevMap.delete(key); - } - - return suspects; - }, [koreaShips, filters.illegalTransship, currentTime]); + return result; + }, [filters.illegalTransship, analysisMap]); // 다크베셀 탐지: AIS 신호 이력 추적 const darkVesselSet = useMemo(() => { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 816e4aa..5752ef4 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -184,6 +184,7 @@ export interface VesselAnalysisDto { cluster: { clusterId: number; clusterSize: number }; fleetRole: { isLeader: boolean; role: FleetRole }; riskScore: { score: number; level: RiskLevel }; + transship: { isSuspect: boolean; pairMmsi: string; durationMin: number }; }; features: Record; } diff --git a/prediction/algorithms/transshipment.py b/prediction/algorithms/transshipment.py new file mode 100644 index 0000000..9e26b95 --- /dev/null +++ b/prediction/algorithms/transshipment.py @@ -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 diff --git a/prediction/db/kcgdb.py b/prediction/db/kcgdb.py index 1014d3d..12a362b 100644 --- a/prediction/db/kcgdb.py +++ b/prediction/db/kcgdb.py @@ -74,7 +74,9 @@ def upsert_results(results: list['AnalysisResult']) -> int: ucaf_score, ucft_score, is_dark, gap_duration_min, spoofing_score, bd09_offset_m, speed_jump_count, 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 ON CONFLICT (mmsi, timestamp) DO UPDATE SET vessel_type = EXCLUDED.vessel_type, @@ -97,6 +99,9 @@ def upsert_results(results: list['AnalysisResult']) -> int: fleet_role = EXCLUDED.fleet_role, risk_score = EXCLUDED.risk_score, 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, analyzed_at = EXCLUDED.analyzed_at """ diff --git a/prediction/models/result.py b/prediction/models/result.py index e1680a5..3ef41a1 100644 --- a/prediction/models/result.py +++ b/prediction/models/result.py @@ -44,6 +44,11 @@ class AnalysisResult: risk_score: int = 0 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) @@ -91,6 +96,9 @@ class AnalysisResult: str(self.fleet_role), _i(self.risk_score), str(self.risk_level), + bool(self.is_transship_suspect), + str(self.transship_pair_mmsi), + _i(self.transship_duration_min), json.dumps(safe_features), self.analyzed_at, ) diff --git a/prediction/scheduler.py b/prediction/scheduler.py index 9d889d3..ff48c3b 100644 --- a/prediction/scheduler.py +++ b/prediction/scheduler.py @@ -18,6 +18,8 @@ _last_run: dict = { 'error': None, } +_transship_pair_history: dict = {} + def get_last_run() -> dict: return _last_run.copy() @@ -158,7 +160,22 @@ def run_analysis_cycle(): 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) kcgdb.cleanup_old(hours=48) -- 2.45.2 From 2a2e4e3590baafb9375d56afcf4f0934ef0b200d Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 12:37:58 +0900 Subject: [PATCH 3/9] =?UTF-8?q?perf:=20LIVE=20=EB=AA=A8=EB=93=9C=20?= =?UTF-8?q?=EB=A7=A4=EC=B4=88=20=EC=84=A0=EB=B0=95=20=EC=9E=AC=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EC=A0=9C=EA=B1=B0=20=E2=80=94=20=EB=8F=99=EC=9D=BC?= =?UTF-8?q?=20=EC=B0=B8=EC=A1=B0=20=EB=B0=98=ED=99=98=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=ED=95=98=EC=9C=84=20useMemo=20=EC=97=B0=EC=87=84=20=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LIVE 모드: enrichedShipsRef 동일 참조 반환 → currentTime 매초 변경 시에도 visibleShips/filteredShips/shipGeoJson 재실행 안 함 - mtCategory/natGroup: baseShipsKorea 변경 시 1회 새 배열 생성 (useState 불변성 준수) - REPLAY 모드: propagateShips로 위치 보간 유지 (mtCategory는 spread로 상속) Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/hooks/useKoreaData.ts | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/frontend/src/hooks/useKoreaData.ts b/frontend/src/hooks/useKoreaData.ts index 95b3e1a..cec7547 100644 --- a/frontend/src/hooks/useKoreaData.ts +++ b/frontend/src/hooks/useKoreaData.ts @@ -145,15 +145,24 @@ export function useKoreaData({ // Propagate Korea aircraft (live only — no waypoint propagation needed) const aircraft = useMemo(() => propagateAircraft(baseAircraftKorea, currentTime), [baseAircraftKorea, currentTime]); - // Korea region ships — pre-compute mtCategory/natGroup for O(1) filter lookups - const ships = useMemo(() => { - const propagated = propagateShips(baseShipsKorea, currentTime, isLive); - for (const s of propagated) { - s.mtCategory = getMarineTrafficCategory(s.typecode, s.category); - s.natGroup = getNationalityGroup(s.flag); - } - return propagated; - }, [baseShipsKorea, currentTime, isLive]); + // Pre-compute mtCategory/natGroup on base data change (5-min polling) + // useState 값 직접 변경 불가 → 새 배열을 ref에 저장 + const enrichedShipsRef = useRef([]); + useMemo(() => { + enrichedShipsRef.current = baseShipsKorea.map(s => ({ + ...s, + mtCategory: getMarineTrafficCategory(s.typecode, s.category), + natGroup: getNationalityGroup(s.flag), + })); + }, [baseShipsKorea]); + + // Korea region ships + // LIVE: enrichedShipsRef 동일 참조 반환 → 하위 useMemo 재실행 방지 (매초 currentTime 변경 무시) + // REPLAY: propagateShips로 위치 보간 → 새 배열 반환 (mtCategory는 spread로 상속) + const ships = useMemo( + () => isLive ? enrichedShipsRef.current : propagateShips(enrichedShipsRef.current, currentTime, false), + [baseShipsKorea, currentTime, isLive], + ); // Category-filtered data for map rendering (Set.has = O(1) per ship) const visibleAircraft = useMemo( -- 2.45.2 From 459a0e3d6ebd7d13084d601981396e065331b120 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 12:39:50 +0900 Subject: [PATCH 4/9] =?UTF-8?q?perf:=20LIVE=20=EB=AA=A8=EB=93=9C=20?= =?UTF-8?q?=EB=A7=A4=EC=B4=88=20=EC=84=A0=EB=B0=95=20=EC=9E=AC=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EC=A0=9C=EA=B1=B0=20=E2=80=94=20currentTime=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=99=84=EC=A0=84=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - liveShips: baseShipsKorea 변경(5분 polling) 시에만 계산. currentTime 의존성 없음. - replayShips: REPLAY 모드에서만 currentTime으로 위치 보간. - LIVE 모드: 매초 useMemo 실행 자체가 발생하지 않음. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/hooks/useKoreaData.ts | 31 ++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/frontend/src/hooks/useKoreaData.ts b/frontend/src/hooks/useKoreaData.ts index cec7547..71272b0 100644 --- a/frontend/src/hooks/useKoreaData.ts +++ b/frontend/src/hooks/useKoreaData.ts @@ -145,24 +145,27 @@ export function useKoreaData({ // Propagate Korea aircraft (live only — no waypoint propagation needed) const aircraft = useMemo(() => propagateAircraft(baseAircraftKorea, currentTime), [baseAircraftKorea, currentTime]); - // Pre-compute mtCategory/natGroup on base data change (5-min polling) - // useState 값 직접 변경 불가 → 새 배열을 ref에 저장 - const enrichedShipsRef = useRef([]); - useMemo(() => { - enrichedShipsRef.current = baseShipsKorea.map(s => ({ + // LIVE: baseShipsKorea 변경(5분 polling) 시에만 재계산. currentTime 무관. + // REPLAY: currentTime으로 활성 선박 + 웨이포인트 보간 필요. + const liveShips = useMemo(() => + baseShipsKorea.map(s => ({ ...s, mtCategory: getMarineTrafficCategory(s.typecode, s.category), natGroup: getNationalityGroup(s.flag), - })); - }, [baseShipsKorea]); + })), + [baseShipsKorea]); - // Korea region ships - // LIVE: enrichedShipsRef 동일 참조 반환 → 하위 useMemo 재실행 방지 (매초 currentTime 변경 무시) - // REPLAY: propagateShips로 위치 보간 → 새 배열 반환 (mtCategory는 spread로 상속) - const ships = useMemo( - () => isLive ? enrichedShipsRef.current : propagateShips(enrichedShipsRef.current, currentTime, false), - [baseShipsKorea, currentTime, isLive], - ); + 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( -- 2.45.2 From 18b827ced03b2341a08b3f84592cacbca99da378 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 12:46:23 +0900 Subject: [PATCH 5/9] =?UTF-8?q?fix:=20=ED=95=9C=EA=B5=AD=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=ED=86=A0=EA=B8=80=20=EC=8B=9C=20=EC=84=A0=EB=B0=95?= =?UTF-8?q?=20=ED=91=9C=EC=8B=9C=20=EB=B3=B5=EC=9B=90=20+=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=EB=B3=84=20=EA=B0=9C=EB=B3=84=20=ED=83=90=EC=A7=80=20?= =?UTF-8?q?=EC=B9=B4=EC=9A=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - anyKoreaFilterOn 시 filteredShips(필터 결과) 전달, 비활성 시 allShips(전체) 전달 - 상단 배너: 합산 "N척 탐지" → 필터별 개별 카운트 (불법어선 3척, 다크베셀 5척 등) Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/components/korea/KoreaMap.tsx | 32 ++++++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index 844990d..ac3db2e 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -189,7 +189,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; + // 줌 레벨별 아이콘/심볼 스케일 배율 — z4=0.8x, z6=1.0x 시작, 2단계씩 상향 const zoomScale = useMemo(() => { if (zoomLevel <= 4) return 0.8; @@ -568,7 +569,13 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF /> - {layers.ships && } + {layers.ships && } {/* Transship suspect labels — Marker DOM, inline styles kept for dynamic border color */} {transshipSuspects.size > 0 && ships.filter(s => transshipSuspects.has(s.mmsi)).map(s => ( @@ -671,14 +678,29 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF {layers.osint && } {layers.eez && } - {/* Filter Status Banner */} + {/* Filter Status Banner — 필터별 개별 탐지 카운트 */} {(() => { const active = (Object.keys(koreaFilters) as (keyof KoreaFiltersState)[]).filter(k => koreaFilters[k]); if (active.length === 0) return null; + const filterCount: Record = { + illegalFishing: (allShips ?? ships).filter(s => { + if (s.mtCategory !== 'fishing' || s.flag === 'KR') return false; + return classifyFishingZone(s.lat, s.lng).zone !== 'OUTSIDE'; + }).length, + illegalTransship: transshipSuspects.size, + darkVessel: ships.filter(s => { + const dto = vesselAnalysis?.analysisMap.get(s.mmsi); + return dto?.algorithms.darkVessel.isDark || (s.lastSeen && currentTime - s.lastSeen > 3600000); + }).length, + cableWatch: cableWatchSuspects.size, + dokdoWatch: dokdoWatchSuspects.size, + ferryWatch: (allShips ?? ships).filter(s => s.mtCategory === 'passenger').length, + }; return (
{active.map(k => { const color = FILTER_COLOR[k]; + const count = filterCount[k] ?? 0; return (
{FILTER_ICON[k]} {t(FILTER_I18N_KEY[k])} + {count}척
); })} -
- {t('korea.detected', { count: ships.length })} -
); })()} -- 2.45.2 From 0da477c53c13041aa853c62ab3a5ad4847f9c299 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 12:55:52 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20=EC=A4=91=EA=B5=AD=EC=96=B4?= =?UTF-8?q?=EC=84=A0=EA=B0=90=EC=8B=9C=20KoreaFilters=20=ED=86=B5=ED=95=A9?= =?UTF-8?q?=20+=20=ED=95=84=ED=84=B0=20=EB=B0=B0=EC=A7=80=20=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=20=EC=84=A0=EB=B0=95=EB=AA=A9=EB=A1=9D/CSV=20?= =?UTF-8?q?=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cnFishing을 KoreaFilters 인터페이스에 통합 (koreaLayers → koreaFilters) → 다른 필터 탭과 동일한 선박 비활성화/상단 배지/카운트 동작 - 상단 필터 배지 클릭 → 대상 선박 목록 패널 (MMSI/이름/국적/유형/속도) → 선박 클릭 시 flyTo, 200척까지 표시 - CSV 다운로드: BOM 포함 UTF-8, 필터별 파일명 (e.g. cnFishing_2026-03-23.csv) - cnFishingSuspects Set 추가 (useKoreaFilters 반환값) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/korea/KoreaDashboard.tsx | 6 +- frontend/src/components/korea/KoreaMap.tsx | 146 +++++++++++++----- frontend/src/hooks/useKoreaFilters.ts | 27 +++- 3 files changed, 130 insertions(+), 49 deletions(-) diff --git a/frontend/src/components/korea/KoreaDashboard.tsx b/frontend/src/components/korea/KoreaDashboard.tsx index 84ec81b..a733c68 100644 --- a/frontend/src/components/korea/KoreaDashboard.tsx +++ b/frontend/src/components/korea/KoreaDashboard.tsx @@ -159,7 +159,6 @@ export const KoreaDashboard = ({ koreaData.visibleShips, currentTime, vesselAnalysis.analysisMap, - koreaLayers.cnFishing, ); const handleTabChange = useCallback((_tab: DashboardTab) => { @@ -198,8 +197,8 @@ export const KoreaDashboard = ({ onClick={() => koreaFiltersResult.setFilter('ferryWatch', !koreaFiltersResult.filters.ferryWatch)} title={t('filters.ferryWatch')}> 🚢{t('filters.ferryWatch')} - + +
- ); - })} - +
+ + + + + + + + + + + + {badgeShips.slice(0, 200).map(s => ( + setFlyToTarget({ lng: s.lng, lat: s.lat, zoom: 12 })} + > + + + + + + + ))} + +
MMSINameFlagTypeSpeed
{s.mmsi}{s.name || '-'}{s.flag || '??'}{s.mtCategory || '-'}{s.speed?.toFixed(1)}kn
+ {badgeShips.length > 200 &&
...외 {badgeShips.length - 200}척
} +
+ + )} + ); })()} diff --git a/frontend/src/hooks/useKoreaFilters.ts b/frontend/src/hooks/useKoreaFilters.ts index 5a7dcde..e5a679d 100644 --- a/frontend/src/hooks/useKoreaFilters.ts +++ b/frontend/src/hooks/useKoreaFilters.ts @@ -12,6 +12,7 @@ interface KoreaFilters { cableWatch: boolean; dokdoWatch: boolean; ferryWatch: boolean; + cnFishing: boolean; } interface DokdoAlert { @@ -28,6 +29,7 @@ interface UseKoreaFiltersResult { transshipSuspects: Set; cableWatchSuspects: Set; dokdoWatchSuspects: Set; + cnFishingSuspects: Set; dokdoAlerts: DokdoAlert[]; anyFilterOn: boolean; } @@ -43,7 +45,6 @@ export function useKoreaFilters( visibleShips: Ship[], currentTime: number, analysisMap?: Map, - cnFishingOn = false, ): UseKoreaFiltersResult { const [filters, setFilters] = useLocalStorage('koreaFilters', { illegalFishing: false, @@ -52,6 +53,7 @@ export function useKoreaFilters( cableWatch: false, dokdoWatch: false, ferryWatch: false, + cnFishing: false, }); const [dokdoAlerts, setDokdoAlerts] = useState([]); @@ -70,7 +72,7 @@ export function useKoreaFilters( filters.cableWatch || filters.dokdoWatch || filters.ferryWatch || - cnFishingOn; + filters.cnFishing; // 불법환적 의심 선박 탐지 (Python 분석 결과 소비) const transshipSuspects = useMemo(() => { @@ -250,6 +252,18 @@ export function useKoreaFilters( return result; }, [koreaShips, filters.dokdoWatch, currentTime]); + // 중국어선 의심 선박 Set + const cnFishingSuspects = useMemo(() => { + if (!filters.cnFishing) return new Set(); + const result = new Set(); + 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(() => { if (!anyFilterOn) return visibleShips; @@ -272,14 +286,10 @@ export function useKoreaFilters( if (filters.cableWatch && cableWatchSet.has(s.mmsi)) return true; if (filters.dokdoWatch && dokdoWatchSet.has(s.mmsi)) return true; if (filters.ferryWatch && s.mtCategory === 'passenger') return true; - if (cnFishingOn) { - const isCnFishing = s.flag === 'CN' && s.mtCategory === 'fishing'; - const isGearPattern = /^.+?_\d+_\d+_?$/.test(s.name || ''); - if (isCnFishing || isGearPattern) return true; - } + if (filters.cnFishing && cnFishingSuspects.has(s.mmsi)) return true; return false; }); - }, [visibleShips, filters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet, analysisMap, cnFishingOn]); + }, [visibleShips, filters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet, analysisMap, cnFishingSuspects]); return { filters, @@ -288,6 +298,7 @@ export function useKoreaFilters( transshipSuspects, cableWatchSuspects: cableWatchSet, dokdoWatchSuspects: dokdoWatchSet, + cnFishingSuspects, dokdoAlerts, anyFilterOn, }; -- 2.45.2 From c515975185b522533e97a81e5ce4664d6c5d3a82 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 13:03:03 +0900 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20=ED=97=A4=EB=8D=94=201=ED=96=89=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=98=20+=20=EB=B9=84=ED=97=88=EA=B0=80=20?= =?UTF-8?q?=EC=96=B4=EA=B5=AC=202=EA=B0=9C=20=EC=9D=B4=EC=83=81=EB=A7=8C?= =?UTF-8?q?=20=ED=83=90=EC=A7=80=20+=20=EC=A4=91=EA=B5=AD=20=EC=96=B4?= =?UTF-8?q?=EA=B5=AC=EA=B7=B8=EB=A3=B9=20=EA=B0=90=EC=8B=9C=20=EB=B0=B0?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 헤더 mode-toggle: flex-wrap:nowrap + overflow-x:auto → 1행 가로 스크롤 - 비허가 어구 그룹: 어구 2개 이상일 때만 그룹 탐지/폴리곤 생성 (1개는 제외) → 조업구역내 어구 + 선단 현황은 현행 유지 - cnFishing 배지: '중국 어구그룹 감시 N개' (어구그룹 수=고유 모선명 수) → 어구 패턴 매칭 선박만 집계 (중국 어선 단독은 제외) Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/App.css | 4 ++++ .../components/korea/FleetClusterLayer.tsx | 18 ++++++++++++++---- frontend/src/components/korea/KoreaMap.tsx | 19 ++++++++++++++----- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index de8a637..bc9f6e4 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1996,12 +1996,16 @@ /* ======================== */ .mode-toggle { display: flex; + flex-wrap: nowrap; gap: 4px; background: var(--kcg-subtle); border: 1px solid var(--kcg-border); border-radius: 6px; padding: 3px; + overflow-x: auto; + scrollbar-width: none; } +.mode-toggle::-webkit-scrollbar { display: none; } .mode-btn { display: flex; diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index f73cf35..30f56ac 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -304,12 +304,17 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster const outZone: { name: string; parent: Ship | null; gears: Ship[] }[] = []; for (const [name, { parent, gears }] of gearGroupMap) { 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); if (zoneInfo.zone !== 'OUTSIDE') { inZone.push({ name, parent, gears, zone: zoneInfo.name }); } 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); @@ -318,10 +323,15 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster }, [gearGroupMap]); // 어구 클러스터 GeoJSON (수역 내: 붉은색, 수역 외: 오렌지) + // 비허가 어구(outZone)는 2개 이상만 폴리곤 생성 const gearClusterGeoJson = useMemo((): GeoJSON => { const inZoneNames = new Set(inZoneGearGroups.map(g => g.name)); + const outZoneNames = new Set(outZoneGearGroups.map(g => g.name)); const features: GeoJSON.Feature[] = []; 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]); if (parent) points.push([parent.lng, parent.lat]); if (points.length < 3) continue; @@ -330,12 +340,12 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster padded.push(padded[0]); features.push({ 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] }, }); } return { type: 'FeatureCollection', features }; - }, [gearGroupMap, inZoneGearGroups]); + }, [gearGroupMap, inZoneGearGroups, outZoneGearGroups]); const handleGearGroupZoom = useCallback((parentName: string) => { setSelectedGearGroup(prev => prev === parentName ? null : parentName); diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index 740366d..1699aef 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -698,7 +698,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF 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 => (s.flag === 'CN' && s.mtCategory === 'fishing') || gearPattern.test(s.name || '')); + case 'cnFishing': return all.filter(s => gearPattern.test(s.name || '')); default: return []; } }; @@ -721,12 +721,21 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
{active.map(k => { const color = FILTER_COLOR[k]; - const count = getShipsForFilter(k).length; + 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 (
setActiveBadgeFilter(prev => prev === k ? null : k)} > {FILTER_ICON[k]} - {FILTER_I18N_KEY[k] ? t(FILTER_I18N_KEY[k]) : k} - {count}척 + {badgeName} + {badgeLabel}
); })} -- 2.45.2 From 1e9d5cd935b4949197bbc98bce6f16e5473908ef Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 13:14:33 +0900 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20=ED=97=A4=EB=8D=94=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=A0=95=EB=A6=AC=20=E2=80=94=20?= =?UTF-8?q?=EC=9D=B4=EB=9E=80=20mode-toggle=20=EC=A2=8C=EC=B8=A1=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=98=20+=20Flag=20=EB=B9=88=EA=B0=92=20=ED=91=9C?= =?UTF-8?q?=EA=B8=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #dashboard-header-slot: flex + justify-content:center 기본 중앙 배치 - 이란 mode-toggle-left: position:absolute left:0 → 탭 오른쪽 배치 - 한국 필터 탭: 중앙 배치 유지 - 감시 목록 Flag 빈값: '??' → '-' Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/App.css | 13 +++++++++++++ frontend/src/components/iran/IranDashboard.tsx | 2 +- frontend/src/components/korea/KoreaMap.tsx | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index bc9f6e4..6ac030e 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1991,6 +1991,19 @@ 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) */ /* ======================== */ diff --git a/frontend/src/components/iran/IranDashboard.tsx b/frontend/src/components/iran/IranDashboard.tsx index ae63a59..a1a5d63 100644 --- a/frontend/src/components/iran/IranDashboard.tsx +++ b/frontend/src/components/iran/IranDashboard.tsx @@ -121,7 +121,7 @@ const IranDashboard = ({ <> {headerSlot && createPortal( <> -
+
⚔️ D+{Math.floor((currentTime - new Date('2026-03-01T00:00:00Z').getTime()) / (1000 * 60 * 60 * 24))} diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index 1699aef..02497da 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -784,7 +784,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF > {s.mmsi} {s.name || '-'} - {s.flag || '??'} + {s.flag || '-'} {s.mtCategory || '-'} {s.speed?.toFixed(1)}kn -- 2.45.2 From e06a35cd1e6cd1db4c5b6800f295079b732d66bd Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 13:15:34 +0900 Subject: [PATCH 9/9] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 5fe7636..94983a1 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,28 @@ ## [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] ### 변경 -- 2.45.2