- 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>
188 lines
5.8 KiB
TypeScript
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]);
|
|
}
|