From 13427f32bbdde46094fa4528cdbc22484223ae67 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 23 Mar 2026 12:24:36 +0900 Subject: [PATCH] =?UTF-8?q?perf:=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=EC=84=B1?= =?UTF-8?q?=EB=8A=A5=20=EC=B5=9C=EC=A0=81=ED=99=94=20=E2=80=94=20deck.gl?= =?UTF-8?q?=20updateTriggers=20+=20=EC=84=A0=EB=B0=95=20=ED=86=A0=EA=B8=80?= =?UTF-8?q?=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