/** * useGearReplayLayers — 어구 궤적 리플레이 레이어 빌더 훅 * * 검증된 리플레이 렌더링 패턴 (단일 렌더링 경로): * * 1. animationStore rAF 루프 → set({ currentTime }) 매 프레임 (gearReplayStore) * 2. zustand.subscribe(currentTime) → renderFrame() * - 재생 중: ~10fps 쓰로틀 + pendingRafId로 다음 프레임 보장 (프레임 드롭 방지) * - seek/정지: 즉시 렌더 * 3. renderFrame() → 레이어 빌드 → overlay.setProps({ layers }) 직접 호출 * * 레이어 구성: * - PathLayer: 중심 궤적 (gold) * - TripsLayer: 멤버 궤적 fade trail * - IconLayer: 멤버 현재 위치 (보간) * - 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 = ``; return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; })(); const GEAR_URI = (() => { const s = ICON_SIZE; const 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; // ~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, buildBaseLayers: () => Layer[], ) { const frameCursorRef = useRef(0); // positionCursors — 멤버별 커서 (재생 시 O(1) 위치 탐색) const memberCursorsRef = useRef(new Map()); // buildBaseLayers를 최신 참조로 유지 const baseLayersRef = useRef(buildBaseLayers); baseLayersRef.current = buildBaseLayers; /** * renderFrame — 보간 + 레이어 빌드 + overlay.setProps 직접 호출: * 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; // 멤버 보간 — 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 // TripsLayer가 자체적으로 부드러운 궤적 애니메이션 처리 if (memberTripsData.length > 0) { replayLayers.push(createTripsLayer( 'replay-member-trails', memberTripsData, relativeTime, 3_600_000, // 1시간 fade trail )); } // 4. 멤버 현재 위치 IconLayer 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({ 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({ 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({ 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({ 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, })); } } // 핵심: baseLayers + replayLayers 합쳐서 overlay.setProps() 직접 호출 const baseLayers = baseLayersRef.current(); overlay.setProps({ layers: [...baseLayers, ...replayLayers] }); }, [overlayRef]); /** * currentTime 구독 — 재생 시 쓰로틀 + seek 시 즉시 렌더 * * 핵심: 재생 중 쓰로틀에 걸려도 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/정지: 즉시 렌더 if (!isPlaying) { renderFrame(); return; } // 재생 중: 쓰로틀 + pending rAF 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]); }