feat: 어구 리플레이 deck.gl + Zustand 전환 완료

Phase 3: DeckGLOverlay에 overlayRef 추가, KoreaMap에서
리플레이 레이어 합성 (imperative setProps → React 렌더 우회)

Phase 4: 기존 MapLibre 리플레이 레이어 → deck.gl 전환
- FleetClusterLayer: 애니메이션 state/ref/timer 제거 → Zustand 스토어
- useFleetClusterGeoJson: 리플레이 useMemo 15개 제거 (618→389줄)
- FleetClusterMapLayers: MapLibre 재생 레이어 6개 제거 (492→397줄)
- HistoryReplayController: React refs → Zustand subscribe 바인딩

성능: React re-render 20회/초 → 0회/초 (재생 중)
      GeoJSON 직렬화 15개/프레임 → 0 (raw 배열 → deck.gl)
      트레일: 매 프레임 재생성 → TripsLayer GPU 셰이더

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-03-31 07:54:50 +09:00
부모 bbbc326e38
커밋 87d1b31ef3
6개의 변경된 파일163개의 추가작업 그리고 526개의 파일을 삭제

파일 보기

@ -5,10 +5,11 @@ import type { Ship, VesselAnalysisDto } from '../../types';
import { fetchFleetCompanies, fetchGroupHistory, fetchGroupCorrelations, fetchCorrelationTracks } from '../../services/vesselAnalysis';
import type { FleetCompany, GearCorrelationItem, CorrelationVesselTrack } from '../../services/vesselAnalysis';
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
import { useGearReplayStore } from '../../stores/gearReplayStore';
// ── 분리된 모듈 ──
import type { HistoryFrame, PickerCandidate, HoverTooltipState, GearPickerPopupState } from './fleetClusterTypes';
import { TIMELINE_DURATION_MS, PLAYBACK_CYCLE_SEC, TICK_MS, EMPTY_ANALYSIS } from './fleetClusterTypes';
import type { PickerCandidate, HoverTooltipState, GearPickerPopupState } from './fleetClusterTypes';
import { EMPTY_ANALYSIS } from './fleetClusterTypes';
import { fillGapFrames } from './fleetClusterUtils';
import { useFleetClusterGeoJson } from './useFleetClusterGeoJson';
import FleetClusterMapLayers from './FleetClusterMapLayers';
@ -55,19 +56,8 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
const [enabledModels, setEnabledModels] = useState<Set<string>>(new Set(['identity', 'default', 'aggressive', 'conservative', 'proximity-heavy', 'visit-pattern']));
const [hoveredTarget, setHoveredTarget] = useState<{ mmsi: string; model: string } | null>(null);
// ── 히스토리 애니메이션 상태 ──
const [historyData, setHistoryData] = useState<HistoryFrame[] | null>(null);
const [, setHistoryGroupKey] = useState<string | null>(null);
const [isPlaying, setIsPlaying] = useState(true);
const [displayFrameIdx, setDisplayFrameIdx] = useState(-1);
const timelinePosRef = useRef(0);
const progressBarRef = useRef<HTMLInputElement>(null);
const progressIndicatorRef = useRef<HTMLDivElement>(null);
const timeDisplayRef = useRef<HTMLSpanElement>(null);
const animTimerRef = useRef<ReturnType<typeof setInterval>>();
const historyStartRef = useRef(0);
const historyEndRef = useRef(0);
const frameTimesRef = useRef<number[]>([]);
// ── Zustand store (히스토리 재생) ──
const historyActive = useGearReplayStore(s => s.historyFrames.length > 0);
// ── 맵 + ref ──
const { current: mapRef } = useMap();
@ -81,68 +71,37 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
// ── 히스토리 로드/닫기 ──
const loadHistory = async (groupKey: string) => {
setHistoryGroupKey(groupKey);
timelinePosRef.current = 0;
setDisplayFrameIdx(-1);
setIsPlaying(true);
const history = await fetchGroupHistory(groupKey, 12);
const sorted = history.reverse();
const filled = fillGapFrames(sorted);
const now = Date.now();
historyStartRef.current = now - TIMELINE_DURATION_MS;
historyEndRef.current = now;
frameTimesRef.current = filled.map(h => new Date(h.snapshotTime).getTime());
setHistoryData(filled);
useGearReplayStore.getState().loadHistory(
filled, correlationTracks, correlationData, enabledModels, enabledVessels,
);
};
const closeHistory = useCallback(() => {
setHistoryData(null);
setHistoryGroupKey(null);
timelinePosRef.current = 0;
setDisplayFrameIdx(-1);
setIsPlaying(true);
useGearReplayStore.getState().reset();
setSelectedGearGroup(null);
clearInterval(animTimerRef.current);
}, []);
// ── 재생 타이머 (ref 기반, 프레임 변경 시에만 setState) ──
// ── enabledModels/enabledVessels/hoveredTarget → store 동기화 ──
useEffect(() => {
if (!historyData || !isPlaying) {
clearInterval(animTimerRef.current);
return;
}
const step = TICK_MS / (PLAYBACK_CYCLE_SEC * 1000);
const ft = frameTimesRef.current;
animTimerRef.current = setInterval(() => {
timelinePosRef.current += step;
if (timelinePosRef.current >= 1) timelinePosRef.current = 0;
useGearReplayStore.getState().setEnabledModels(enabledModels);
}, [enabledModels]);
const pos = timelinePosRef.current;
if (progressBarRef.current) progressBarRef.current.value = String(Math.round(pos * 1000));
if (progressIndicatorRef.current) progressIndicatorRef.current.style.left = `${pos * 100}%`;
const t = historyStartRef.current + timelinePosRef.current * TIMELINE_DURATION_MS;
if (timeDisplayRef.current) {
timeDisplayRef.current.textContent = new Date(t).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' });
}
useEffect(() => {
useGearReplayStore.getState().setEnabledVessels(enabledVessels);
}, [enabledVessels]);
let best = 0, bestDiff = Infinity;
for (let i = 0; i < ft.length; i++) {
const d = Math.abs(ft[i] - t);
if (d < bestDiff) { bestDiff = d; best = i; }
}
const fi = bestDiff < 1_800_000 ? best : -1;
setDisplayFrameIdx(prev => prev === fi ? prev : fi);
}, TICK_MS);
return () => clearInterval(animTimerRef.current);
}, [historyData, isPlaying]);
const effectiveSnapIdx = displayFrameIdx;
useEffect(() => {
useGearReplayStore.getState().setHoveredMmsi(hoveredTarget?.mmsi ?? null);
}, [hoveredTarget]);
// ── ESC 키 ──
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (historyData) closeHistory();
if (historyActive) closeHistory();
setSelectedGearGroup(null);
setExpandedFleet(null);
setExpandedGearGroup(null);
@ -150,7 +109,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [historyData, closeHistory]);
}, [historyActive, closeHistory]);
// ── 맵 이벤트 등록 ──
useEffect(() => {
@ -299,9 +258,9 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
// ── 부모 콜백 동기화: 어구 그룹 선택 ──
useEffect(() => {
if (!selectedGearGroup || historyData) {
if (!selectedGearGroup || historyActive) {
onSelectedGearChange?.(null);
if (historyData) return;
if (historyActive) return;
return;
}
const allGroups = groupPolygons ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] : [];
@ -315,7 +274,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
category: 'fishing', lastSeen: Date.now(),
});
onSelectedGearChange?.({ parent: parent ? toShip(parent) : null, gears: gears.map(toShip), groupName: selectedGearGroup });
}, [selectedGearGroup, groupPolygons, onSelectedGearChange, historyData]);
}, [selectedGearGroup, groupPolygons, onSelectedGearChange, historyActive]);
// ── 연관성 데이터 로드 ──
useEffect(() => {
@ -346,9 +305,9 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
// ── 부모 콜백 동기화: 선단 선택 ──
useEffect(() => {
if (expandedFleet === null || historyData) {
if (expandedFleet === null || historyActive) {
onSelectedFleetChange?.(null);
if (historyData) return;
if (historyActive) return;
return;
}
const group = groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === expandedFleet);
@ -360,17 +319,16 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
category: 'fishing', lastSeen: Date.now(),
}));
onSelectedFleetChange?.({ clusterId: expandedFleet, ships: fleetShips, companyName: company?.nameCn || group.groupLabel || `선단 #${expandedFleet}` });
}, [expandedFleet, groupPolygons, companies, onSelectedFleetChange, historyData]);
}, [expandedFleet, groupPolygons, companies, onSelectedFleetChange, historyActive]);
// ── GeoJSON 훅 ──
const hoveredMmsi = hoveredTarget?.mmsi ?? null;
const geo = useFleetClusterGeoJson({
ships, shipMap, groupPolygons, analysisMap,
hoveredFleetId, selectedGearGroup, pickerHoveredGroup,
historyData, effectiveSnapIdx,
historyActive,
correlationData, correlationTracks,
enabledModels, enabledVessels, hoveredMmsi,
historyStartMs: historyStartRef.current,
});
// ── 어구 그룹 데이터 ──
@ -446,8 +404,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
hoveredMmsi={hoveredMmsi}
enabledModels={enabledModels}
expandedFleet={expandedFleet}
historyData={historyData}
effectiveSnapIdx={effectiveSnapIdx}
historyActive={historyActive}
hoverTooltip={hoverTooltip}
gearPickerPopup={gearPickerPopup}
pickerHoveredGroup={pickerHoveredGroup}
@ -472,8 +429,8 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
enabledModels={enabledModels}
enabledVessels={enabledVessels}
correlationLoading={correlationLoading}
historyData={historyData}
effectiveSnapIdx={effectiveSnapIdx}
historyData={null}
effectiveSnapIdx={-1}
hoveredTarget={hoveredTarget}
onEnabledModelsChange={(updater) => setEnabledModels(updater)}
onEnabledVesselsChange={(updater) => setEnabledVessels(updater)}
@ -482,20 +439,8 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
)}
{/* ── 재생 컨트롤러 ── */}
{historyData && (
{historyActive && (
<HistoryReplayController
historyData={historyData}
effectiveSnapIdx={effectiveSnapIdx}
isPlaying={isPlaying}
snapshotRanges={geo.snapshotRanges}
progressBarRef={progressBarRef}
progressIndicatorRef={progressIndicatorRef}
timeDisplayRef={timeDisplayRef}
historyStartRef={historyStartRef}
timelinePosRef={timelinePosRef}
frameTimesRef={frameTimesRef}
onTogglePlay={() => setIsPlaying(p => !p)}
onFrameChange={(idx) => { setIsPlaying(false); setDisplayFrameIdx(idx); }}
onClose={closeHistory}
/>
)}

파일 보기

@ -4,7 +4,6 @@ import type { FleetCompany } from '../../services/vesselAnalysis';
import type { VesselAnalysisDto } from '../../types';
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
import type {
HistoryFrame,
HoverTooltipState,
GearPickerPopupState,
PickerCandidate,
@ -18,8 +17,7 @@ interface FleetClusterMapLayersProps {
hoveredMmsi: string | null;
enabledModels: Set<string>;
expandedFleet: number | null;
historyData: HistoryFrame[] | null;
effectiveSnapIdx: number;
historyActive: boolean;
// Popup/tooltip state
hoverTooltip: HoverTooltipState | null;
gearPickerPopup: GearPickerPopupState | null;
@ -42,8 +40,7 @@ const FleetClusterMapLayers = ({
hoveredMmsi,
enabledModels,
expandedFleet,
historyData,
effectiveSnapIdx,
historyActive,
hoverTooltip,
gearPickerPopup,
pickerHoveredGroup,
@ -63,17 +60,11 @@ const FleetClusterMapLayers = ({
memberMarkersGeoJson,
pickerHighlightGeoJson,
operationalPolygons,
memberTrailsGeoJson,
centerTrailGeoJson,
currentCenterGeoJson,
animPolygonGeoJson,
animMembersGeoJson,
correlationVesselGeoJson,
correlationTrailGeoJson,
modelBadgesGeoJson,
hoverHighlightGeoJson,
hoverHighlightTrailGeoJson,
isStale,
} = geo;
return (
@ -126,7 +117,7 @@ const FleetClusterMapLayers = ({
</Source>
{/* 선택된 어구 그룹 — 이름 기반 하이라이트 폴리곤 */}
{selectedGearGroup && enabledModels.has('identity') && !historyData && (() => {
{selectedGearGroup && enabledModels.has('identity') && !historyActive && (() => {
const allGroups = groupPolygons
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
: [];
@ -149,7 +140,7 @@ const FleetClusterMapLayers = ({
})()}
{/* 선택된 어구 그룹 — 모델별 오퍼레이셔널 폴리곤 오버레이 */}
{selectedGearGroup && operationalPolygons.map(op => (
{selectedGearGroup && !historyActive && operationalPolygons.map(op => (
<Source key={`op-${op.modelName}`} id={`gear-op-${op.modelName}`} type="geojson" data={op.geojson}>
<Layer id={`gear-op-fill-${op.modelName}`} type="fill" paint={{
'fill-color': op.color, 'fill-opacity': 0.12,
@ -182,8 +173,8 @@ const FleetClusterMapLayers = ({
/>
</Source>
{/* 가상 선박 마커 — 히스토리 재생 모드에서는 숨김 (애니메이션 아이콘으로 대체) */}
<Source id="group-member-markers" type="geojson" data={historyData ? ({ type: 'FeatureCollection', features: [] } as GeoJSON) : memberMarkersGeoJson}>
{/* 가상 선박 마커 — 히스토리 재생 모드에서는 숨김 (deck.gl 레이어로 대체) */}
<Source id="group-member-markers" type="geojson" data={historyActive ? ({ type: 'FeatureCollection', features: [] } as GeoJSON.FeatureCollection) : memberMarkersGeoJson}>
<Layer
id="group-member-icon"
type="symbol"
@ -324,8 +315,8 @@ const FleetClusterMapLayers = ({
return null;
})()}
{/* ── 연관 대상 트레일 + 마커 (활성 모델 전체) ── */}
{selectedGearGroup && hasCorrelationTracks && (
{/* ── 연관 대상 트레일 + 마커 (비재생 모드) ── */}
{selectedGearGroup && !historyActive && hasCorrelationTracks && (
<Source id="correlation-trails" type="geojson" data={correlationTrailGeoJson}>
<Layer id="correlation-trails-line" type="line" paint={{
'line-color': ['get', 'color'], 'line-width': 2, 'line-opacity': 0.6,
@ -333,7 +324,7 @@ const FleetClusterMapLayers = ({
}} />
</Source>
)}
{selectedGearGroup && (
{selectedGearGroup && !historyActive && (
<Source id="correlation-vessels" type="geojson" data={correlationVesselGeoJson}>
<Layer id="correlation-vessels-icon" type="symbol" layout={{
'icon-image': ['case', ['==', ['get', 'isVessel'], 1], 'ship-triangle', 'gear-diamond'],
@ -359,8 +350,8 @@ const FleetClusterMapLayers = ({
</Source>
)}
{/* ── 모델 배지 (아이콘 우측 컬러 dot) ── */}
{selectedGearGroup && (
{/* ── 모델 배지 (비재생 모드) ── */}
{selectedGearGroup && !historyActive && (
<Source id="model-badges" type="geojson" data={modelBadgesGeoJson}>
{MODEL_ORDER.map((model, i) => (
enabledModels.has(model) ? (
@ -379,8 +370,8 @@ const FleetClusterMapLayers = ({
</Source>
)}
{/* ── 호버 하이라이트 (글로우 + 항적 강조) ── */}
{hoveredMmsi && (
{/* ── 호버 하이라이트 (비재생 모드) ── */}
{hoveredMmsi && !historyActive && (
<Source id="hover-highlight-point" type="geojson" data={hoverHighlightGeoJson}>
<Layer id="hover-highlight-glow" type="circle" paint={{
'circle-radius': 14, 'circle-color': '#ffffff', 'circle-opacity': 0.25,
@ -392,99 +383,13 @@ const FleetClusterMapLayers = ({
}} />
</Source>
)}
{hoveredMmsi && (
{hoveredMmsi && !historyActive && (
<Source id="hover-highlight-trail" type="geojson" data={hoverHighlightTrailGeoJson}>
<Layer id="hover-highlight-trail-line" type="line" paint={{
'line-color': '#ffffff', 'line-width': 3, 'line-opacity': 0.7,
}} />
</Source>
)}
{/* ── 히스토리 애니메이션 레이어 (최상위) ── */}
{historyData && (
<Source id="history-member-trails" type="geojson" data={memberTrailsGeoJson}>
<Layer id="history-member-trails-line" type="line" paint={{
'line-color': '#cbd5e1', 'line-width': 1.5, 'line-opacity': 0.65,
}} />
</Source>
)}
{historyData && (
<Source id="history-center-trail" type="geojson" data={centerTrailGeoJson}>
<Layer id="history-center-trail-line" type="line" paint={{
'line-color': ['case', ['==', ['get', 'interpolated'], 1], '#f97316', '#fbbf24'],
'line-width': 2,
'line-dasharray': [4, 4],
'line-opacity': ['case', ['==', ['get', 'interpolated'], 1], 0.8, 0.7],
}} filter={['==', '$type', 'LineString']} />
<Layer id="history-center-dots" type="circle" paint={{
'circle-radius': 2.5, 'circle-color': '#fbbf24', 'circle-opacity': 0.6,
}} filter={['==', '$type', 'Point']} />
</Source>
)}
{/* 보간 경로는 centerTrailGeoJson에 통합됨 (interpolated=1 세그먼트) */}
{/* 현재 재생 위치 포인트 (실데이터=빨강, 보간=주황) */}
{historyData && effectiveSnapIdx >= 0 && (
<Source id="history-current-center" type="geojson" data={currentCenterGeoJson}>
<Layer id="history-current-center-dot" type="circle" paint={{
'circle-radius': 7,
'circle-color': ['case', ['==', ['get', 'interpolated'], 1], '#f97316', '#ef4444'],
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff',
}} />
</Source>
)}
{historyData && (
<Source id="history-anim-polygon" type="geojson" data={animPolygonGeoJson}>
<Layer id="history-anim-fill" type="fill" paint={{
'fill-color': ['case', ['==', ['get', 'interpolated'], 1], '#94a3b8', isStale ? '#64748b' : '#fbbf24'],
'fill-opacity': ['case', ['==', ['get', 'interpolated'], 1], 0.12, isStale ? 0.08 : 0.15],
}} />
<Layer id="history-anim-line" type="line" paint={{
'line-color': ['case', ['==', ['get', 'interpolated'], 1], '#94a3b8', isStale ? '#64748b' : '#fbbf24'],
'line-width': ['case', ['==', ['get', 'interpolated'], 1], 1.5, isStale ? 1 : 2],
'line-opacity': ['case', ['==', ['get', 'interpolated'], 1], 0.5, isStale ? 0.4 : 0.7],
'line-dasharray': isStale ? [3, 3] : [1, 0],
}} />
</Source>
)}
{/* 가상 아이콘 — 현재 프레임 멤버 위치 (최상위) */}
{historyData && (
<Source id="history-anim-members" type="geojson" data={animMembersGeoJson}>
<Layer id="history-anim-members-icon" type="symbol" layout={{
'icon-image': ['case', ['==', ['get', 'isGear'], 1], 'gear-diamond', 'ship-triangle'],
'icon-size': ['case', ['==', ['get', 'isGear'], 1], 0.55, 0.7],
'icon-rotate': ['case', ['==', ['get', 'isGear'], 1], 0, ['get', 'cog']],
'icon-rotation-alignment': 'map',
'icon-allow-overlap': true,
}} paint={{
'icon-color': ['case',
['==', ['get', 'interpolated'], 1], '#94a3b8',
['==', ['get', 'stale'], 1], '#64748b',
['==', ['get', 'isGear'], 0], '#fbbf24',
'#a8b8c8',
],
'icon-opacity': ['case',
['==', ['get', 'interpolated'], 1], 0.5,
['==', ['get', 'stale'], 1], 0.4,
0.9,
],
}} />
<Layer id="history-anim-members-label" type="symbol" layout={{
'text-field': ['get', 'name'],
'text-size': 8,
'text-offset': [0, 1.5],
'text-allow-overlap': false,
}} paint={{
'text-color': ['case',
['==', ['get', 'interpolated'], 1], '#94a3b8',
['==', ['get', 'isGear'], 0], '#fbbf24',
'#e2e8f0',
],
'text-halo-color': 'rgba(0,0,0,0.8)',
'text-halo-width': 1,
}} />
</Source>
)}
</>
);
};

파일 보기

@ -1,40 +1,41 @@
import React from 'react';
import { useRef, useEffect } from 'react';
import { FONT_MONO } from '../../styles/fonts';
import type { HistoryFrame } from './fleetClusterTypes';
import { TIMELINE_DURATION_MS } from './fleetClusterTypes';
import { useGearReplayStore } from '../../stores/gearReplayStore';
interface HistoryReplayControllerProps {
historyData: HistoryFrame[];
effectiveSnapIdx: number;
isPlaying: boolean;
snapshotRanges: number[];
progressBarRef: React.RefObject<HTMLInputElement | null>;
progressIndicatorRef: React.RefObject<HTMLDivElement | null>;
timeDisplayRef: React.RefObject<HTMLSpanElement | null>;
historyStartRef: React.RefObject<number>;
timelinePosRef: React.MutableRefObject<number>;
frameTimesRef: React.RefObject<number[]>;
onTogglePlay: () => void;
onFrameChange: (idx: number) => void;
onClose: () => void;
}
const HistoryReplayController = ({
historyData,
effectiveSnapIdx,
isPlaying,
snapshotRanges,
progressBarRef,
progressIndicatorRef,
timeDisplayRef,
historyStartRef,
timelinePosRef,
frameTimesRef,
onTogglePlay,
onFrameChange,
onClose,
}: HistoryReplayControllerProps) => {
const hasSnap = effectiveSnapIdx >= 0;
const HistoryReplayController = ({ onClose }: HistoryReplayControllerProps) => {
// React selectors (infrequent changes)
const isPlaying = useGearReplayStore(s => s.isPlaying);
const snapshotRanges = useGearReplayStore(s => s.snapshotRanges);
const frameCount = useGearReplayStore(s => s.historyFrames.length);
// DOM refs for imperative updates
const progressBarRef = useRef<HTMLInputElement>(null);
const progressIndicatorRef = useRef<HTMLDivElement>(null);
const timeDisplayRef = useRef<HTMLSpanElement>(null);
// Subscribe to currentTime for DOM updates (no React re-render)
useEffect(() => {
const unsub = useGearReplayStore.subscribe(
s => s.currentTime,
(currentTime) => {
const { startTime, endTime } = useGearReplayStore.getState();
if (endTime <= startTime) return;
const progress = (currentTime - startTime) / (endTime - startTime);
if (progressBarRef.current) progressBarRef.current.value = String(Math.round(progress * 1000));
if (progressIndicatorRef.current) progressIndicatorRef.current.style.left = `${progress * 100}%`;
if (timeDisplayRef.current) {
timeDisplayRef.current.textContent = new Date(currentTime).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' });
}
},
);
return unsub;
}, []);
const store = useGearReplayStore;
return (
<div style={{
@ -82,7 +83,7 @@ const HistoryReplayController = ({
top: -1,
width: 3,
height: 10,
background: hasSnap ? '#fbbf24' : '#ef4444',
background: '#fbbf24',
borderRadius: 1,
transform: 'translateX(-50%)',
}} />
@ -92,7 +93,7 @@ const HistoryReplayController = ({
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
type="button"
onClick={onTogglePlay}
onClick={() => { if (isPlaying) store.getState().pause(); else store.getState().play(); }}
style={{
background: 'none',
border: '1px solid rgba(99,179,237,0.3)',
@ -109,7 +110,7 @@ const HistoryReplayController = ({
<span
ref={timeDisplayRef}
style={{ color: hasSnap ? '#fbbf24' : '#ef4444', minWidth: 40, textAlign: 'center' }}
style={{ color: '#fbbf24', minWidth: 40, textAlign: 'center' }}
>
--:--
</span>
@ -121,22 +122,11 @@ const HistoryReplayController = ({
max={1000}
defaultValue={0}
onChange={e => {
timelinePosRef.current = Number(e.target.value) / 1000;
// 수동 드래그 시 즉시 프레임 계산
const t = historyStartRef.current + timelinePosRef.current * TIMELINE_DURATION_MS;
const ft = frameTimesRef.current ?? [];
let best = 0, bestDiff = Infinity;
for (let i = 0; i < ft.length; i++) {
const d = Math.abs(ft[i] - t);
if (d < bestDiff) { bestDiff = d; best = i; }
}
onFrameChange(bestDiff < 1_800_000 ? best : -1);
if (timeDisplayRef.current) {
timeDisplayRef.current.textContent = new Date(t).toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit',
});
}
const { startTime, endTime } = store.getState();
const progress = Number(e.target.value) / 1000;
const seekTime = startTime + progress * (endTime - startTime);
store.getState().pause();
store.getState().seek(seekTime);
}}
style={{ flex: 1, cursor: 'pointer', accentColor: '#fbbf24' }}
title="히스토리 타임라인"
@ -144,7 +134,7 @@ const HistoryReplayController = ({
/>
<span style={{ color: '#64748b', fontSize: 9 }}>
{historyData.length}
{frameCount}
</span>
<button

파일 보기

@ -5,9 +5,12 @@ import type { MapRef } from 'react-map-gl/maplibre';
import { ScatterplotLayer, TextLayer } from '@deck.gl/layers';
import { useFontScale } from '../../hooks/useFontScale';
import { FONT_MONO } from '../../styles/fonts';
import type { MapboxOverlay } from '@deck.gl/mapbox';
import type { Layer as DeckLayer } from '@deck.gl/core';
import { DeckGLOverlay } from '../layers/DeckGLOverlay';
import { useAnalysisDeckLayers } from '../../hooks/useAnalysisDeckLayers';
import { useStaticDeckLayers } from '../../hooks/useStaticDeckLayers';
import { useGearReplayLayers } from '../../hooks/useGearReplayLayers';
import type { StaticPickInfo } from '../../hooks/useStaticDeckLayers';
import fishingZonesData from '../../data/zones/fishing-zones-wgs84.json';
import { ShipLayer } from '../layers/ShipLayer';
@ -210,6 +213,8 @@ const DebugTools = import.meta.env.DEV
export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis, groupPolygons, hiddenShipCategories, hiddenNationalities, externalFlyTo, onExternalFlyToDone, opsRoute }: Props) {
const { t } = useTranslation();
const mapRef = useRef<MapRef>(null);
const overlayRef = useRef<MapboxOverlay | null>(null);
const replayLayerRef = useRef<DeckLayer[]>([]);
const [infra, setInfra] = useState<PowerFacility[]>([]);
const [flyToTarget, setFlyToTarget] = useState<{ lng: number; lat: number; zoom: number } | null>(null);
const [selectedAnalysisMmsi, setSelectedAnalysisMmsi] = useState<string | null>(null);
@ -231,6 +236,16 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
const [analysisPanelOpen, setAnalysisPanelOpen] = useLocalStorage('analysisPanelOpen', false);
const [activeBadgeFilter, setActiveBadgeFilter] = useState<string | null>(null);
// ── deck.gl 리플레이 레이어 (Zustand → imperative setProps, React 렌더 우회) ──
const reactLayersRef = useRef<DeckLayer[]>([]);
const requestRender = useCallback(() => {
if (!overlayRef.current) return;
overlayRef.current.setProps({
layers: [...reactLayersRef.current, ...replayLayerRef.current],
});
}, []);
useGearReplayLayers(replayLayerRef, requestRender);
useEffect(() => {
fetchKoreaInfra().then(setInfra).catch(() => {});
}, []);
@ -803,16 +818,23 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
</Source>
)}
{/* deck.gl GPU 오버레이 — 불법어선 강조 + 분석 위험도 마커 */}
<DeckGLOverlay layers={[
...staticDeckLayers,
illegalFishingLayer,
illegalFishingLabelLayer,
zoneLabelsLayer,
...selectedGearLayers,
...selectedFleetLayers,
...(analysisPanelOpen ? analysisDeckLayers : []),
].filter(Boolean)} />
{/* deck.gl GPU 오버레이 — 정적 + 리플레이 레이어 합성 */}
<DeckGLOverlay
overlayRef={overlayRef}
layers={(() => {
const base = [
...staticDeckLayers,
illegalFishingLayer,
illegalFishingLabelLayer,
zoneLabelsLayer,
...selectedGearLayers,
...selectedFleetLayers,
...(analysisPanelOpen ? analysisDeckLayers : []),
].filter(Boolean);
reactLayersRef.current = base;
return [...base, ...replayLayerRef.current];
})()}
/>
{/* 정적 마커 클릭 Popup — 통합 리치 디자인 */}
{staticPickInfo && (
<StaticFacilityPopup pickInfo={staticPickInfo} onClose={() => setStaticPickInfo(null)} />

파일 보기

@ -3,9 +3,8 @@ import type { GeoJSON } from 'geojson';
import type { Ship, VesselAnalysisDto } from '../../types';
import type { GearCorrelationItem, CorrelationVesselTrack } from '../../services/vesselAnalysis';
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
import type { HistoryFrame, FleetListItem } from './fleetClusterTypes';
import { TIMELINE_DURATION_MS } from './fleetClusterTypes';
import { interpolateTrackPosition, buildInterpPolygon } from './fleetClusterUtils';
import type { FleetListItem } from './fleetClusterTypes';
import { buildInterpPolygon } from './fleetClusterUtils';
import { MODEL_ORDER, MODEL_COLORS } from './fleetClusterConstants';
export interface UseFleetClusterGeoJsonParams {
@ -16,14 +15,12 @@ export interface UseFleetClusterGeoJsonParams {
hoveredFleetId: number | null;
selectedGearGroup: string | null;
pickerHoveredGroup: string | null;
historyData: HistoryFrame[] | null;
effectiveSnapIdx: number;
historyActive: boolean;
correlationData: GearCorrelationItem[];
correlationTracks: CorrelationVesselTrack[];
enabledModels: Set<string>;
enabledVessels: Set<string>;
hoveredMmsi: string | null;
historyStartMs: number;
}
export interface FleetClusterGeoJsonResult {
@ -35,12 +32,6 @@ export interface FleetClusterGeoJsonResult {
memberMarkersGeoJson: GeoJSON;
pickerHighlightGeoJson: GeoJSON;
selectedGearHighlightGeoJson: GeoJSON.FeatureCollection | null;
// history animation GeoJSON
memberTrailsGeoJson: GeoJSON;
centerTrailGeoJson: GeoJSON;
currentCenterGeoJson: GeoJSON;
animPolygonGeoJson: GeoJSON;
animMembersGeoJson: GeoJSON;
// correlation GeoJSON
correlationVesselGeoJson: GeoJSON;
correlationTrailGeoJson: GeoJSON;
@ -51,10 +42,6 @@ export interface FleetClusterGeoJsonResult {
operationalPolygons: { modelName: string; color: string; geojson: GeoJSON.FeatureCollection }[];
// derived values
fleetList: FleetListItem[];
currentFrame: HistoryFrame | null;
showGray: boolean;
isStale: boolean;
snapshotRanges: number[];
correlationByModel: Map<string, GearCorrelationItem[]>;
availableModels: { name: string; count: number; isDefault: boolean }[];
}
@ -69,14 +56,12 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
hoveredFleetId,
selectedGearGroup,
pickerHoveredGroup,
historyData,
effectiveSnapIdx,
historyActive,
correlationData,
correlationTracks,
enabledModels,
enabledVessels,
hoveredMmsi,
historyStartMs,
} = params;
// ── 선단 폴리곤 GeoJSON (서버 제공) ──
@ -135,96 +120,8 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
return models;
}, [correlationByModel]);
// 현재 프레임 및 파생 상태
const currentFrame = historyData && effectiveSnapIdx >= 0 ? historyData[effectiveSnapIdx] : null;
const showGray = !!currentFrame?._longGap || !!currentFrame?._interp;
const isStale = showGray;
// 스냅샷 존재 구간 맵 (프로그레스 바 갭 표시용)
const snapshotRanges = useMemo(() => {
if (!historyData) return [];
return historyData
.filter(h => !h._interp)
.map(h => {
const t = new Date(h.snapshotTime).getTime();
return (t - historyStartMs) / TIMELINE_DURATION_MS;
});
}, [historyData, historyStartMs]);
// ── 사전계산: 각 프레임별 연관 대상 보간 위치 ──
const correlationPosMap = useMemo(() => {
if (!historyData || correlationTracks.length === 0) return null;
const trackMap = new Map(correlationTracks.map(v => [v.mmsi, v.track]));
return historyData.map(snap => {
const t = new Date(snap.snapshotTime).getTime();
const m = new Map<string, { lon: number; lat: number; cog: number }>();
for (const [mmsi, track] of trackMap) {
const p = interpolateTrackPosition(track, t);
if (p) m.set(mmsi, p);
}
return m;
});
}, [historyData, correlationTracks]);
// 사전계산: 각 프레임별 트레일 클립 인덱스
const trailClipMap = useMemo(() => {
if (!historyData || correlationTracks.length === 0) return null;
return historyData.map(snap => {
const t = new Date(snap.snapshotTime).getTime();
const m = new Map<string, number>();
for (const vt of correlationTracks) {
let idx = vt.track.length;
for (let i = 0; i < vt.track.length; i++) {
if (vt.track[i].ts > t) { idx = i; break; }
}
m.set(vt.mmsi, idx);
}
return m;
});
}, [historyData, correlationTracks]);
// 사전계산: 각 프레임별 오퍼레이셔널 폴리곤
const operationalPolygonsByFrame = useMemo(() => {
if (!historyData || !selectedGearGroup || !groupPolygons) return null;
const allGroups = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
const group = allGroups.find(g => g.groupKey === selectedGearGroup);
if (!group) return null;
return historyData.map((snap, fi) => {
const basePts: [number, number][] = snap.members.map(m => [m.lon, m.lat]);
const positions = correlationPosMap?.[fi];
const result: { modelName: string; color: string; geojson: GeoJSON.FeatureCollection }[] = [];
for (const [mn, items] of correlationByModel) {
if (!enabledModels.has(mn)) continue;
const extra: [number, number][] = [];
for (const c of items) {
if (c.score < 0.7) continue;
const p = positions?.get(c.targetMmsi);
if (p) extra.push([p.lon, p.lat]);
}
if (extra.length === 0) continue;
const polygon = buildInterpPolygon([...basePts, ...extra]);
if (!polygon) continue;
const color = MODEL_COLORS[mn] ?? '#94a3b8';
result.push({
modelName: mn,
color,
geojson: {
type: 'FeatureCollection',
features: [{ type: 'Feature', properties: { modelName: mn, color }, geometry: polygon }],
},
});
}
return result;
});
}, [historyData, selectedGearGroup, groupPolygons, correlationByModel, enabledModels, correlationPosMap]);
// 재생 시 O(1) 룩업, 비재생 시 기존 로직
// 오퍼레이셔널 폴리곤 (비재생 정적 연산)
const operationalPolygons = useMemo(() => {
if (operationalPolygonsByFrame && effectiveSnapIdx >= 0) {
return operationalPolygonsByFrame[effectiveSnapIdx] ?? [];
}
if (!selectedGearGroup || !groupPolygons) return [];
const allGroups = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
const group = allGroups.find(g => g.groupKey === selectedGearGroup);
@ -253,7 +150,7 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
});
}
return result;
}, [operationalPolygonsByFrame, effectiveSnapIdx, selectedGearGroup, groupPolygons, correlationByModel, enabledModels, ships]);
}, [selectedGearGroup, groupPolygons, correlationByModel, enabledModels, ships]);
// 어구 클러스터 GeoJSON (서버 제공)
const gearClusterGeoJson = useMemo((): GeoJSON => {
@ -328,9 +225,9 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: g.polygon }] };
}, [pickerHoveredGroup, groupPolygons]);
// 선택된 어구 그룹 하이라이트 폴리곤 (JSX IIFE → useMemo)
// 선택된 어구 그룹 하이라이트 폴리곤
const selectedGearHighlightGeoJson = useMemo((): GeoJSON.FeatureCollection | null => {
if (!selectedGearGroup || !enabledModels.has('identity') || historyData) return null;
if (!selectedGearGroup || !enabledModels.has('identity') || historyActive) return null;
const allGroups = groupPolygons
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
: [];
@ -344,106 +241,11 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
geometry: group.polygon,
}],
};
}, [selectedGearGroup, enabledModels, historyData, groupPolygons]);
}, [selectedGearGroup, enabledModels, historyActive, groupPolygons]);
// ── 히스토리 애니메이션 GeoJSON ──
const memberTrailsGeoJson = useMemo((): GeoJSON => {
if (!historyData) return EMPTY_FC;
const tracks = new Map<string, [number, number][]>();
for (const snap of historyData) {
for (const m of snap.members) {
const arr = tracks.get(m.mmsi) ?? [];
arr.push([m.lon, m.lat]);
tracks.set(m.mmsi, arr);
}
}
const features: GeoJSON.Feature[] = [];
for (const [, coords] of tracks) {
if (coords.length < 2) continue;
features.push({ type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } });
}
return { type: 'FeatureCollection', features };
}, [historyData]);
// center trail: historyData에 이미 보간 프레임 포함 → 전체 좌표 연결
const centerTrailGeoJson = useMemo((): GeoJSON => {
if (!historyData || historyData.length === 0) return EMPTY_FC;
const features: GeoJSON.Feature[] = [];
let segStart = 0;
for (let i = 1; i <= historyData.length; i++) {
const curInterp = i < historyData.length && !!historyData[i]._longGap;
const startInterp = !!historyData[segStart]._longGap;
if (i < historyData.length && curInterp === startInterp) continue;
const from = segStart > 0 ? segStart - 1 : segStart;
const seg = historyData.slice(from, i);
if (seg.length >= 2) {
features.push({
type: 'Feature',
properties: { interpolated: startInterp ? 1 : 0 },
geometry: { type: 'LineString', coordinates: seg.map(s => [s.centerLon, s.centerLat]) },
});
}
segStart = i;
}
for (const h of historyData) {
if (h.color === '#94a3b8') continue;
features.push({
type: 'Feature', properties: { interpolated: 0 },
geometry: { type: 'Point', coordinates: [h.centerLon, h.centerLat] },
});
}
return { type: 'FeatureCollection', features };
}, [historyData]);
// 현재 재생 위치 포인트
const currentCenterGeoJson = useMemo((): GeoJSON => {
if (!historyData || effectiveSnapIdx < 0) return EMPTY_FC;
const snap = historyData[effectiveSnapIdx];
if (!snap) return EMPTY_FC;
return {
type: 'FeatureCollection',
features: [{
type: 'Feature',
properties: { interpolated: showGray ? 1 : 0 },
geometry: { type: 'Point', coordinates: [snap.centerLon, snap.centerLat] },
}],
};
}, [historyData, effectiveSnapIdx, showGray]);
const animPolygonGeoJson = useMemo((): GeoJSON => {
if (!historyData || effectiveSnapIdx < 0) return EMPTY_FC;
const snap = historyData[effectiveSnapIdx];
if (!snap?.polygon) return EMPTY_FC;
return {
type: 'FeatureCollection',
features: [{ type: 'Feature', properties: { stale: isStale ? 1 : 0, interpolated: showGray ? 1 : 0 }, geometry: snap.polygon }],
};
}, [historyData, effectiveSnapIdx, isStale, showGray]);
// 현재 프레임의 멤버 위치 (가상 아이콘)
const animMembersGeoJson = useMemo((): GeoJSON => {
if (!historyData || effectiveSnapIdx < 0) return EMPTY_FC;
const snap = historyData[effectiveSnapIdx];
if (!snap) return EMPTY_FC;
return {
type: 'FeatureCollection',
features: snap.members.map(m => ({
type: 'Feature' as const,
properties: { mmsi: m.mmsi, name: m.name, cog: m.cog ?? 0, role: m.role, isGear: m.role === 'GEAR' ? 1 : 0, stale: isStale ? 1 : 0, interpolated: showGray ? 1 : 0 },
geometry: { type: 'Point' as const, coordinates: [m.lon, m.lat] },
})),
};
}, [historyData, effectiveSnapIdx, isStale, showGray]);
// ── 연관 대상 마커 (사전계산 룩업 or ships fallback) ──
// ── 연관 대상 마커 (ships[] fallback) ──
const correlationVesselGeoJson = useMemo((): GeoJSON => {
if (!selectedGearGroup || correlationByModel.size === 0) return EMPTY_FC;
const positions = correlationPosMap && effectiveSnapIdx >= 0 ? correlationPosMap[effectiveSnapIdx] : null;
const features: GeoJSON.Feature[] = [];
const seen = new Set<string>();
@ -452,29 +254,29 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
const color = MODEL_COLORS[mn] ?? '#94a3b8';
for (const c of items) {
if (seen.has(c.targetMmsi)) continue;
let lon: number | undefined, lat: number | undefined, cog = 0;
const cached = positions?.get(c.targetMmsi);
if (cached) { lon = cached.lon; lat = cached.lat; cog = cached.cog; }
if (lon === undefined) {
const s = ships.find(x => x.mmsi === c.targetMmsi);
if (s) { lon = s.lng; lat = s.lat; cog = s.course ?? 0; }
}
if (lon === undefined || lat === undefined) continue;
const s = ships.find(x => x.mmsi === c.targetMmsi);
if (!s) continue;
seen.add(c.targetMmsi);
features.push({
type: 'Feature',
properties: { mmsi: c.targetMmsi, name: c.targetName || c.targetMmsi, score: c.score, cog, color, isVessel: c.targetType === 'VESSEL' ? 1 : 0 },
geometry: { type: 'Point', coordinates: [lon, lat] },
properties: {
mmsi: c.targetMmsi,
name: c.targetName || c.targetMmsi,
score: c.score,
cog: s.course ?? 0,
color,
isVessel: c.targetType === 'VESSEL' ? 1 : 0,
},
geometry: { type: 'Point', coordinates: [s.lng, s.lat] },
});
}
}
return { type: 'FeatureCollection', features };
}, [selectedGearGroup, correlationByModel, enabledModels, correlationPosMap, effectiveSnapIdx, ships]);
}, [selectedGearGroup, correlationByModel, enabledModels, ships]);
// 연관 대상 트레일 (사전계산 클립 인덱스 룩업)
// 연관 대상 트레일 (전체 항적)
const correlationTrailGeoJson = useMemo((): GeoJSON => {
if (correlationTracks.length === 0) return EMPTY_FC;
const clips = trailClipMap && effectiveSnapIdx >= 0 ? trailClipMap[effectiveSnapIdx] : null;
const features: GeoJSON.Feature[] = [];
const vesselColor = new Map<string, string>();
for (const [mn, items] of correlationByModel) {
@ -486,29 +288,22 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
for (const vt of correlationTracks) {
if (!enabledVessels.has(vt.mmsi)) continue;
const color = vesselColor.get(vt.mmsi) ?? '#60a5fa';
const clipIdx = clips?.get(vt.mmsi) ?? vt.track.length;
const coords: [number, number][] = vt.track.slice(0, clipIdx).map(pt => [pt.lon, pt.lat]);
const coords: [number, number][] = vt.track.map(pt => [pt.lon, pt.lat]);
if (coords.length >= 2) {
features.push({ type: 'Feature', properties: { mmsi: vt.mmsi, color }, geometry: { type: 'LineString', coordinates: coords } });
}
}
return { type: 'FeatureCollection', features };
}, [correlationTracks, enabledVessels, correlationByModel, enabledModels, trailClipMap, effectiveSnapIdx]);
}, [correlationTracks, enabledVessels, correlationByModel, enabledModels]);
// 모델 배지 GeoJSON (사전계산 위치 룩업)
// 모델 배지 GeoJSON (groupPolygons 기반)
const modelBadgesGeoJson = useMemo((): GeoJSON => {
if (!selectedGearGroup) return EMPTY_FC;
const positions = correlationPosMap && effectiveSnapIdx >= 0 ? correlationPosMap[effectiveSnapIdx] : null;
const targets = new Map<string, { lon: number; lat: number; models: Set<string> }>();
if (enabledModels.has('identity')) {
const members = (historyData && effectiveSnapIdx >= 0)
? historyData[effectiveSnapIdx].members
: (() => {
if (!groupPolygons) return [];
const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
return all.find(g => g.groupKey === selectedGearGroup)?.members ?? [];
})();
if (enabledModels.has('identity') && groupPolygons) {
const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
const members = all.find(g => g.groupKey === selectedGearGroup)?.members ?? [];
for (const m of members) {
const e = targets.get(m.mmsi) ?? { lon: m.lon, lat: m.lat, models: new Set<string>() };
e.lon = m.lon; e.lat = m.lat; e.models.add('identity');
@ -519,15 +314,11 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
if (!enabledModels.has(mn)) continue;
for (const c of items) {
if (c.score < 0.3) continue;
let lon: number | undefined, lat: number | undefined;
const cached = positions?.get(c.targetMmsi);
if (cached) { lon = cached.lon; lat = cached.lat; }
if (lon === undefined) { const s = ships.find(x => x.mmsi === c.targetMmsi); if (s) { lon = s.lng; lat = s.lat; } }
if (lon !== undefined && lat !== undefined) {
const e = targets.get(c.targetMmsi) ?? { lon, lat, models: new Set<string>() };
e.lon = lon; e.lat = lat; e.models.add(mn);
targets.set(c.targetMmsi, e);
}
const s = ships.find(x => x.mmsi === c.targetMmsi);
if (!s) continue;
const e = targets.get(c.targetMmsi) ?? { lon: s.lng, lat: s.lat, models: new Set<string>() };
e.lon = s.lng; e.lat = s.lat; e.models.add(mn);
targets.set(c.targetMmsi, e);
}
}
const features: GeoJSON.Feature[] = [];
@ -538,19 +329,11 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
features.push({ type: 'Feature', properties: props, geometry: { type: 'Point', coordinates: [t.lon, t.lat] } });
}
return { type: 'FeatureCollection', features };
}, [selectedGearGroup, enabledModels, historyData, effectiveSnapIdx, groupPolygons,
correlationByModel, correlationPosMap, ships]);
}, [selectedGearGroup, enabledModels, groupPolygons, correlationByModel, ships]);
// 호버 하이라이트 — 대상 위치 (사전계산 룩업)
// 호버 하이라이트 — 대상 위치
const hoverHighlightGeoJson = useMemo((): GeoJSON => {
if (!hoveredMmsi || !selectedGearGroup) return EMPTY_FC;
const positions = correlationPosMap && effectiveSnapIdx >= 0 ? correlationPosMap[effectiveSnapIdx] : null;
if (historyData && effectiveSnapIdx >= 0) {
const m = historyData[effectiveSnapIdx].members.find(x => x.mmsi === hoveredMmsi);
if (m) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [m.lon, m.lat] } }] };
}
const cached = positions?.get(hoveredMmsi);
if (cached) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [cached.lon, cached.lat] } }] };
if (groupPolygons) {
const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
const m = all.find(g => g.groupKey === selectedGearGroup)?.members.find(x => x.mmsi === hoveredMmsi);
@ -559,20 +342,17 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
const s = ships.find(x => x.mmsi === hoveredMmsi);
if (s) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [s.lng, s.lat] } }] };
return EMPTY_FC;
}, [hoveredMmsi, selectedGearGroup, historyData, effectiveSnapIdx, correlationPosMap, groupPolygons, ships]);
}, [hoveredMmsi, selectedGearGroup, groupPolygons, ships]);
// 호버 하이라이트 — 대상 항적 (사전계산 클립 룩업)
// 호버 하이라이트 — 대상 항적
const hoverHighlightTrailGeoJson = useMemo((): GeoJSON => {
if (!hoveredMmsi) return EMPTY_FC;
const vt = correlationTracks.find(v => v.mmsi === hoveredMmsi);
if (!vt) return EMPTY_FC;
const clipIdx = trailClipMap && effectiveSnapIdx >= 0
? (trailClipMap[effectiveSnapIdx]?.get(hoveredMmsi) ?? vt.track.length)
: vt.track.length;
const coords: [number, number][] = vt.track.slice(0, clipIdx).map(pt => [pt.lon, pt.lat]);
const coords: [number, number][] = vt.track.map(pt => [pt.lon, pt.lat]);
if (coords.length < 2) return EMPTY_FC;
return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } }] };
}, [hoveredMmsi, correlationTracks, trailClipMap, effectiveSnapIdx]);
}, [hoveredMmsi, correlationTracks]);
// 선단 목록 (멤버 수 내림차순)
const fleetList = useMemo((): FleetListItem[] => {
@ -596,11 +376,6 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
memberMarkersGeoJson,
pickerHighlightGeoJson,
selectedGearHighlightGeoJson,
memberTrailsGeoJson,
centerTrailGeoJson,
currentCenterGeoJson,
animPolygonGeoJson,
animMembersGeoJson,
correlationVesselGeoJson,
correlationTrailGeoJson,
modelBadgesGeoJson,
@ -608,10 +383,6 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
hoverHighlightTrailGeoJson,
operationalPolygons,
fleetList,
currentFrame,
showGray,
isStale,
snapshotRanges,
correlationByModel,
availableModels,
};

파일 보기

@ -1,22 +1,26 @@
import type { MutableRefObject } from 'react';
import { useControl } from 'react-map-gl/maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
import type { Layer } from '@deck.gl/core';
interface Props {
layers: Layer[];
overlayRef?: MutableRefObject<MapboxOverlay | null>;
}
/**
* MapLibre Map deck.gl GPU .
* interleaved 모드: MapLibre deck.gl z-order로 .
* overlayRef: 외부에서 imperative setProps .
*/
export function DeckGLOverlay({ layers }: Props) {
export function DeckGLOverlay({ layers, overlayRef }: Props) {
const overlay = useControl<MapboxOverlay>(
() => new MapboxOverlay({
interleaved: true,
getCursor: ({ isHovering }) => isHovering ? 'pointer' : '',
}),
);
if (overlayRef) overlayRef.current = overlay;
overlay.setProps({ layers });
return null;
}