kcg-ai-monitoring/frontend/src/hooks/useGearReplayLayers.ts
htlee 2ee8a0e7ff feat(detection): DAR-03 어구 탐지 워크플로우 + 모선 검토 UI + 24h 리플레이 통합
- prediction: G-01/G-04/G-05/G-06 위반 분류 + 쌍끌이 공조 탐지 추가
- backend: 모선 확정/제외 API + signal-batch 항적 프록시 + ParentResolution 점수 근거 필드 확장
- frontend: 어구 탐지 그리드 다중필터/지도 flyTo, 후보 검토 패널(점수 근거+확정/제외), 24h convex hull 리플레이 + TripsLayer 애니메이션
- gitignore: 루트 .venv/ 추가
2026-04-15 13:26:15 +09:00

403 lines
14 KiB
TypeScript

/**
* useGearReplayLayers — 어구 궤적 리플레이 레이어 빌더 훅
*
* iran 프로젝트 useReplayLayer.ts 패턴 그대로 적용:
*
* 1. animationStore rAF 루프 → set({ currentTime }) 매 프레임 (gearReplayStore)
* 2. zustand.subscribe(currentTime) → renderFrame()
* - 재생 중: ~10fps 쓰로틀 + pendingRafId로 다음 프레임 보장 (프레임 드롭 방지)
* - seek/정지: 즉시 렌더
* 3. renderFrame() → 레이어 빌드 → overlay.setProps({ layers }) 직접 호출
*
* 레이어 구성 (iran 대비 KCG 적용):
* - PathLayer: 중심 궤적 (gold) — iran의 정적 PathLayer에 대응
* - TripsLayer: 멤버 궤적 fade trail — iran과 동일
* - IconLayer: 멤버 현재 위치 (보간) — iran의 가상 선박 레이어에 대응
* - PolygonLayer: 현재 폴리곤 (보간으로 확장/축소 애니메이션)
* - TextLayer: MMSI 라벨
*
* 제거된 것: 멤버 배경 PathLayer (TripsLayer와 중복), 멤버-중심 연결선 (불필요)
*/
import { useEffect, useRef, useCallback } from 'react';
import type { Layer } from 'deck.gl';
import { ScatterplotLayer, IconLayer, PolygonLayer, TextLayer } from 'deck.gl';
import type { MapboxOverlay } from '@deck.gl/mapbox';
import { useGearReplayStore } from '@stores/gearReplayStore';
import {
findFrameAtTime,
interpolateFromTripsData,
computeConvexHull,
type MemberPosition,
} from '@stores/gearReplayPreprocess';
import { createTripsLayer } from '@lib/map/layers/trips';
// ── SVG Data URI ──
const ICON_SIZE = 64;
const SHIP_URI = (() => {
const s = ICON_SIZE;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 ${s} ${s}"><polygon points="${s / 2},2 ${s * 0.12},${s - 2} ${s / 2},${s * 0.62} ${s * 0.88},${s - 2}" fill="white"/></svg>`;
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
})();
const GEAR_URI = (() => {
const s = ICON_SIZE;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 ${s} ${s}"><polygon points="${s / 2},4 ${s - 4},${s / 2} ${s / 2},${s - 4} 4,${s / 2}" fill="white"/></svg>`;
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
})();
// ── 색상 ──
const CYAN: [number, number, number, number] = [6, 182, 212, 220];
const AMBER: [number, number, number, number] = [245, 158, 11, 200];
const SLATE: [number, number, number, number] = [148, 163, 184, 120];
const POLYGON_FILL: [number, number, number, number] = [245, 158, 11, 30];
const POLYGON_STROKE: [number, number, number, number] = [245, 158, 11, 120];
const RENDER_INTERVAL_MS = 100; // iran과 동일: ~10fps 쓰로틀
function memberIconColor(m: MemberPosition): [number, number, number, number] {
if (m.stale) return SLATE;
if (m.isParent) return CYAN;
if (m.isGear) return AMBER;
return [148, 163, 184, 200];
}
// ── 훅 ──
export function useGearReplayLayers(
overlayRef: React.RefObject<MapboxOverlay | null>,
buildBaseLayers: () => Layer[],
) {
const frameCursorRef = useRef(0);
// iran의 positionCursors — 멤버별 커서 (재생 시 O(1) 위치 탐색)
const memberCursorsRef = useRef(new Map<string, number>());
// buildBaseLayers를 최신 참조로 유지
const baseLayersRef = useRef(buildBaseLayers);
baseLayersRef.current = buildBaseLayers;
/**
* renderFrame — iran의 renderFrame과 동일 구조:
* 1. 현재 위치 계산 (보간)
* 2. 레이어 빌드
* 3. overlay.setProps({ layers }) 직접 호출
*/
const renderFrame = useCallback(() => {
const overlay = overlayRef.current;
if (!overlay) return;
const state = useGearReplayStore.getState();
const {
historyFrames, frameTimes, memberTripsData, memberMetadata,
currentTime, startTime, correlationItems,
candidateTripsData, candidateMetadata,
} = state;
if (historyFrames.length === 0) {
overlay.setProps({ layers: baseLayersRef.current() });
return;
}
// 현재 프레임 찾기 (폴리곤 보간용)
const { index: frameIdx, cursor: newCursor } = findFrameAtTime(
frameTimes, currentTime, frameCursorRef.current,
);
frameCursorRef.current = newCursor;
// 멤버 보간 — iran의 getCurrentVesselPositions 패턴:
// 프레임 기반이 아닌 멤버별 개별 타임라인에서 보간 → 빈 구간도 연속 보간
const relativeTime = currentTime - startTime;
const members = interpolateFromTripsData(
memberTripsData, memberMetadata, relativeTime, memberCursorsRef.current,
);
// 멤버 위치 기반 convex hull 폴리곤 (프레임 보간이 아닌 실시간 생성)
const hullRing = computeConvexHull(members);
const currentFrame = frameIdx >= 0 ? historyFrames[frameIdx] : null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const replayLayers: any[] = [];
// 1. TripsLayer — 멤버 궤적 fade trail (iran과 동일 패턴)
// TripsLayer가 자체적으로 부드러운 궤적 애니메이션 처리
if (memberTripsData.length > 0) {
replayLayers.push(createTripsLayer(
'replay-member-trails',
memberTripsData,
relativeTime,
3_600_000, // 1시간 fade trail
));
}
// 4. 멤버 현재 위치 IconLayer (iran의 createVirtualShipLayers에 대응)
const ships = members.filter(m => m.isParent);
const gears = members.filter(m => m.isGear);
const others = members.filter(m => !m.isParent && !m.isGear);
if (ships.length > 0) {
replayLayers.push(new IconLayer<MemberPosition>({
id: 'replay-member-ships',
data: ships,
pickable: true,
iconAtlas: SHIP_URI,
iconMapping: {
ship: { x: 0, y: 0, width: ICON_SIZE, height: ICON_SIZE, anchorY: ICON_SIZE * 0.62, mask: true },
},
getIcon: () => 'ship',
getPosition: d => [d.lon, d.lat],
getSize: 24,
getAngle: d => -(d.cog ?? 0),
getColor: d => memberIconColor(d),
sizeUnits: 'pixels',
sizeMinPixels: 10,
billboard: false,
}));
}
if (gears.length > 0) {
replayLayers.push(new IconLayer<MemberPosition>({
id: 'replay-member-gears',
data: gears,
pickable: true,
iconAtlas: GEAR_URI,
iconMapping: {
gear: { x: 0, y: 0, width: ICON_SIZE, height: ICON_SIZE, anchorY: ICON_SIZE / 2, mask: true },
},
getIcon: () => 'gear',
getPosition: d => [d.lon, d.lat],
getSize: 16,
getAngle: 0,
getColor: d => memberIconColor(d),
sizeUnits: 'pixels',
sizeMinPixels: 6,
billboard: false,
}));
}
if (others.length > 0) {
replayLayers.push(new IconLayer<MemberPosition>({
id: 'replay-member-others',
data: others,
pickable: true,
iconAtlas: SHIP_URI,
iconMapping: {
ship: { x: 0, y: 0, width: ICON_SIZE, height: ICON_SIZE, anchorY: ICON_SIZE * 0.62, mask: true },
},
getIcon: () => 'ship',
getPosition: d => [d.lon, d.lat],
getSize: 18,
getAngle: d => -(d.cog ?? 0),
getColor: d => memberIconColor(d),
sizeUnits: 'pixels',
sizeMinPixels: 8,
billboard: false,
}));
}
// 5. MMSI 라벨
if (members.length > 0) {
replayLayers.push(new TextLayer<MemberPosition>({
id: 'replay-member-labels',
data: members,
getPosition: d => [d.lon, d.lat],
getText: d => d.mmsi,
getColor: [255, 255, 255, 200],
getSize: 10,
getPixelOffset: [0, -18],
fontFamily: 'monospace',
fontWeight: 'bold',
outlineWidth: 2,
outlineColor: [0, 0, 0, 200],
sizeUnits: 'pixels',
sizeMinPixels: 8,
sizeMaxPixels: 12,
billboard: true,
}));
}
// 6. 멤버 위치 기반 convex hull 폴리곤
if (hullRing) {
replayLayers.push(new PolygonLayer({
id: 'replay-polygon',
data: [{ ring: hullRing }],
getPolygon: d => d.ring,
getFillColor: POLYGON_FILL,
getLineColor: POLYGON_STROKE,
getLineWidth: 2,
lineWidthUnits: 'pixels',
lineWidthMinPixels: 1,
filled: true,
stroked: true,
}));
}
// 7. 추론 후보 위치 (correlation)
if (correlationItems.length > 0 && currentFrame) {
const memberMap = new Map(currentFrame.members.map(m => [m.mmsi, m]));
const corrPositions = correlationItems
.filter(c => {
const m = memberMap.get(c.targetMmsi);
return m && m.lat != null && m.lon != null;
})
.map(c => {
const m = memberMap.get(c.targetMmsi)!;
return { ...c, lon: m.lon, lat: m.lat };
});
if (corrPositions.length > 0) {
replayLayers.push(new ScatterplotLayer({
id: 'replay-corr-positions',
data: corrPositions,
getPosition: d => [d.lon, d.lat],
getFillColor: d => {
const s = d.score;
if (s >= 0.72) return [16, 185, 129, 180] as [number, number, number, number];
if (s >= 0.5) return [245, 158, 11, 180] as [number, number, number, number];
return [100, 116, 139, 140] as [number, number, number, number];
},
getRadius: d => 5 + d.score * 8,
radiusUnits: 'pixels',
radiusMinPixels: 4,
lineWidthMinPixels: 1,
stroked: true,
getLineColor: [255, 255, 255, 120],
getLineWidth: 1,
pickable: true,
}));
}
}
// 8. 후보 선박 항적 TripsLayer + 현재 위치 IconLayer
if (candidateTripsData.length > 0) {
// 후보 선박 궤적 fade trail (emerald)
replayLayers.push(createTripsLayer(
'replay-candidate-trails',
candidateTripsData,
relativeTime,
3_600_000,
));
// 후보 선박 현재 위치 (개별 타임라인 보간)
const candPositions = interpolateFromTripsData(
candidateTripsData,
new Map([...candidateMetadata].map(([k, v]) => [k, { name: v.name, role: 'CANDIDATE', isParent: false }])),
relativeTime,
memberCursorsRef.current,
);
if (candPositions.length > 0) {
replayLayers.push(new IconLayer({
id: 'replay-candidate-ships',
data: candPositions,
pickable: true,
iconAtlas: SHIP_URI,
iconMapping: {
ship: { x: 0, y: 0, width: ICON_SIZE, height: ICON_SIZE, anchorY: ICON_SIZE * 0.62, mask: true },
},
getIcon: () => 'ship',
getPosition: (d: MemberPosition) => [d.lon, d.lat],
getSize: 22,
getAngle: (d: MemberPosition) => -(d.cog ?? 0),
getColor: [16, 185, 129, 220] as [number, number, number, number], // emerald
sizeUnits: 'pixels' as const,
sizeMinPixels: 10,
billboard: false,
}));
// 후보 선박 라벨
replayLayers.push(new TextLayer({
id: 'replay-candidate-labels',
data: candPositions,
getPosition: (d: MemberPosition) => [d.lon, d.lat],
getText: (d: MemberPosition) => {
const meta = candidateMetadata.get(d.mmsi);
return meta?.name || d.mmsi;
},
getColor: [16, 185, 129, 220],
getSize: 10,
getPixelOffset: [0, -18],
fontFamily: 'monospace',
fontWeight: 'bold' as const,
outlineWidth: 2,
outlineColor: [0, 0, 0, 200],
sizeUnits: 'pixels' as const,
sizeMinPixels: 8,
sizeMaxPixels: 12,
billboard: true,
}));
}
}
// iran 핵심: baseLayers + replayLayers 합쳐서 overlay.setProps() 직접 호출
const baseLayers = baseLayersRef.current();
overlay.setProps({ layers: [...baseLayers, ...replayLayers] });
}, [overlayRef]);
/**
* currentTime 구독 — iran useReplayLayer.ts:425~458 그대로 적용
*
* 핵심: 재생 중 쓰로틀에 걸려도 pendingRafId로 다음 rAF에 반드시 렌더 예약
* → 프레임 드롭 없이 부드러운 애니메이션
*
* 가드 없이 항상 구독 — renderFrame 내부에서 historyFrames.length===0이면 baseLayers만 표시
*/
useEffect(() => {
let lastRenderTime = 0;
let pendingRafId: number | null = null;
const unsub = useGearReplayStore.subscribe(
(s) => s.currentTime,
() => {
// 데이터 없으면 무시
if (!useGearReplayStore.getState().groupKey) return;
const isPlaying = useGearReplayStore.getState().isPlaying;
// seek/정지: 즉시 렌더 (iran:437~439)
if (!isPlaying) {
renderFrame();
return;
}
// 재생 중: 쓰로틀 + pending rAF (iran:441~451)
const now = performance.now();
if (now - lastRenderTime >= RENDER_INTERVAL_MS) {
lastRenderTime = now;
renderFrame();
} else if (!pendingRafId) {
pendingRafId = requestAnimationFrame(() => {
pendingRafId = null;
lastRenderTime = performance.now();
renderFrame();
});
}
},
);
return () => {
unsub();
if (pendingRafId) cancelAnimationFrame(pendingRafId);
};
}, [renderFrame]);
// groupKey 변경 구독
useEffect(() => {
const unsub = useGearReplayStore.subscribe(
s => s.groupKey,
(groupKey) => {
if (groupKey) {
frameCursorRef.current = 0;
memberCursorsRef.current.clear();
renderFrame();
} else {
// 리플레이 종료 → 기본 레이어 복원
const overlay = overlayRef.current;
if (overlay) {
overlay.setProps({ layers: baseLayersRef.current() });
}
}
},
);
return unsub;
}, [renderFrame, overlayRef]);
}