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:
부모
bbbc326e38
커밋
87d1b31ef3
@ -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;
|
||||
}
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user