kcg-monitoring/frontend/src/hooks/useAnalysisDeckLayers.ts

215 lines
6.8 KiB
TypeScript

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<string, [number, number, number, number]> = {
CRITICAL: [239, 68, 68, 60],
HIGH: [249, 115, 22, 50],
MEDIUM: [234, 179, 8, 40],
};
// 테두리색
const RISK_RGBA_BORDER: Record<string, [number, number, number, number]> = {
CRITICAL: [239, 68, 68, 230],
HIGH: [249, 115, 22, 210],
MEDIUM: [234, 179, 8, 190],
};
// 픽셀 반경
const RISK_SIZE: Record<string, number> = {
CRITICAL: 18,
HIGH: 14,
MEDIUM: 12,
};
const RISK_LABEL: Record<string, string> = {
CRITICAL: '긴급',
HIGH: '경고',
MEDIUM: '주의',
};
const RISK_PRIORITY: Record<string, number> = {
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<string, VesselAnalysisDto>,
ships: Ship[],
activeFilter: string | null,
sizeScale: number = 1.0,
): Layer[] {
const { fontScale } = useFontScale();
const afs = fontScale.analysis;
// 데이터 준비: ships 필터/정렬/슬라이스 — sizeScale 변경 시 재실행 안 됨
const { riskData, darkData, spoofData } = useMemo<AnalysisData>(() => {
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<Layer[]>(() => {
if (riskData.length === 0 && darkData.length === 0 && spoofData.length === 0) return [];
const layers: Layer[] = [];
// 위험도 원형 마커
layers.push(
new ScatterplotLayer<AnalyzedShip>({
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<AnalyzedShip>({
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<AnalyzedShip>({
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<AnalyzedShip>({
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<AnalyzedShip>({
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]);
}