import { useMemo } from 'react'; import { ScatterplotLayer, TextLayer } from '@deck.gl/layers'; import type { Layer } from '@deck.gl/core'; import type { Ship, VesselAnalysisDto } from '../types'; import { useFontScale } from './useFontScale'; interface AnalyzedShip { ship: Ship; dto: VesselAnalysisDto; } // RISK_RGBA: [r, g, b, a] 충전색 const RISK_RGBA: Record = { CRITICAL: [239, 68, 68, 60], HIGH: [249, 115, 22, 50], MEDIUM: [234, 179, 8, 40], }; // 테두리색 const RISK_RGBA_BORDER: Record = { CRITICAL: [239, 68, 68, 230], HIGH: [249, 115, 22, 210], MEDIUM: [234, 179, 8, 190], }; // 픽셀 반경 const RISK_SIZE: Record = { CRITICAL: 18, HIGH: 14, MEDIUM: 12, }; const RISK_LABEL: Record = { CRITICAL: '긴급', HIGH: '경고', MEDIUM: '주의', }; const RISK_PRIORITY: Record = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, }; interface AnalysisData { riskData: AnalyzedShip[]; darkData: AnalyzedShip[]; spoofData: AnalyzedShip[]; } /** * 분석 결과 기반 deck.gl 레이어를 반환하는 훅. * AnalysisOverlay DOM Marker 대체 — GPU 렌더링으로 성능 향상. */ export function useAnalysisDeckLayers( analysisMap: Map, ships: Ship[], activeFilter: string | null, sizeScale: number = 1.0, ): Layer[] { const { fontScale } = useFontScale(); const afs = fontScale.analysis; // 데이터 준비: 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)) .map(s => ({ ship: s, dto: analysisMap.get(s.mmsi)! })); const riskData = analyzedShips .filter(({ dto }) => { const level = dto.algorithms.riskScore.level; return level === 'CRITICAL' || level === 'HIGH' || level === 'MEDIUM'; }) .sort((a, b) => { const pa = RISK_PRIORITY[a.dto.algorithms.riskScore.level] ?? 99; const pb = RISK_PRIORITY[b.dto.algorithms.riskScore.level] ?? 99; return pa - pb; }) .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[] = []; // 위험도 원형 마커 layers.push( new ScatterplotLayer({ id: 'risk-markers', data: riskData, getPosition: (d) => [d.ship.lng, d.ship.lat], getRadius: (d) => (RISK_SIZE[d.dto.algorithms.riskScore.level] ?? 12) * sizeScale, getFillColor: (d) => RISK_RGBA[d.dto.algorithms.riskScore.level] ?? [100, 100, 100, 40], getLineColor: (d) => RISK_RGBA_BORDER[d.dto.algorithms.riskScore.level] ?? [100, 100, 100, 200], stroked: true, filled: true, radiusUnits: 'pixels', lineWidthUnits: 'pixels', getLineWidth: 2, updateTriggers: { getRadius: [sizeScale] }, }), ); // 위험도 라벨 (선박명 + 위험도 등급) layers.push( new TextLayer({ id: 'risk-labels', data: riskData, getPosition: (d) => [d.ship.lng, d.ship.lat], getText: (d) => { const label = RISK_LABEL[d.dto.algorithms.riskScore.level] ?? d.dto.algorithms.riskScore.level; const name = d.ship.name || d.ship.mmsi; return `${name}\n${label} ${d.dto.algorithms.riskScore.score}`; }, getSize: 10 * sizeScale * afs, updateTriggers: { getSize: [sizeScale] }, getColor: (d) => RISK_RGBA_BORDER[d.dto.algorithms.riskScore.level] ?? [200, 200, 200, 255], getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 16], fontFamily: 'monospace', fontSettings: { sdf: true }, outlineWidth: 3, outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', }), ); // 다크베셀 (activeFilter === 'darkVessel' 일 때만) 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] }, }), ); // 다크베셀 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 * afs, updateTriggers: { getSize: [sizeScale] }, getColor: [168, 85, 247, 255], getTextAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: [0, 14], fontFamily: 'monospace', fontSettings: { sdf: true }, outlineWidth: 3, outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', }), ); } // GPS 스푸핑 라벨 if (spoofData.length > 0) { layers.push( new TextLayer({ id: 'spoof-labels', data: spoofData, getPosition: (d) => [d.ship.lng, d.ship.lat], getText: (d) => `GPS ${Math.round(d.dto.algorithms.gpsSpoofing.spoofingScore * 100)}%`, getSize: 10 * sizeScale * afs, getColor: [239, 68, 68, 255], getTextAnchor: 'start', getPixelOffset: [12, -8], fontFamily: 'monospace', fontWeight: 700, fontSettings: { sdf: true }, outlineWidth: 3, outlineColor: [0, 0, 0, 255], billboard: false, characterSet: 'auto', }), ); } return layers; }, [riskData, darkData, spoofData, sizeScale, activeFilter, afs]); }