kcg-monitoring/frontend/src/hooks/useAnalysisDeckLayers.ts
htlee f0c991c9ec refactor: deck.gl 전면 전환 — DOM Marker → GPU 렌더링
- deck.gl 9.2 설치 + DeckGLOverlay(MapboxOverlay interleaved) 통합
- 정적 마커 11종 → useStaticDeckLayers (IconLayer/TextLayer, SVG DataURI)
- 분석 오버레이 → useAnalysisDeckLayers (ScatterplotLayer/TextLayer)
- 불법어선/어구/수역 라벨 → deck.gl ScatterplotLayer/TextLayer
- 줌 레벨별 스케일 (0~6: 0.6x, 7~9: 1.0x, 10~12: 1.4x, 13+: 1.8x)
- NK 미사일 궤적 PathLayer 추가 + 정적 마커 클릭 Popup
- 해저케이블 날짜변경선(180도) 좌표 보정
- 기존 DOM Marker 제거로 렌더링 성능 대폭 개선

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 21:11:56 +09:00

188 lines
5.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';
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,
};
/**
* 분석 결과 기반 deck.gl 레이어를 반환하는 훅.
* AnalysisOverlay DOM Marker 대체 — GPU 렌더링으로 성능 향상.
*/
export function useAnalysisDeckLayers(
analysisMap: Map<string, VesselAnalysisDto>,
ships: Ship[],
activeFilter: string | null,
sizeScale: number = 1.0,
): Layer[] {
return useMemo(() => {
if (analysisMap.size === 0) return [];
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 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,
}),
);
// 위험도 라벨 (선박명 + 위험도 등급)
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,
getColor: (d) => RISK_RGBA_BORDER[d.dto.algorithms.riskScore.level] ?? [200, 200, 200, 255],
getTextAnchor: 'middle',
getAlignmentBaseline: 'top',
getPixelOffset: [0, 16],
fontFamily: 'monospace',
outlineWidth: 2,
outlineColor: [0, 0, 0, 200],
billboard: false,
characterSet: 'auto',
}),
);
// 다크베셀 (activeFilter === 'darkVessel' 일 때만)
if (activeFilter === 'darkVessel') {
const darkData = analyzedShips.filter(({ dto }) => dto.algorithms.darkVessel.isDark);
if (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,
}),
);
// 다크베셀 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,
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<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,
getColor: [239, 68, 68, 255],
getTextAnchor: 'start',
getPixelOffset: [12, -8],
fontFamily: 'monospace',
fontWeight: 700,
outlineWidth: 2,
outlineColor: [0, 0, 0, 200],
billboard: false,
characterSet: 'auto',
}),
);
}
return layers;
}, [analysisMap, ships, activeFilter, sizeScale]);
}