215 lines
6.8 KiB
TypeScript
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]);
|
|
}
|