import { useRef, useEffect, useState, useCallback, useMemo } from 'react'; import { FONT_MONO } from '../../styles/fonts'; import { useGearReplayStore } from '../../stores/gearReplayStore'; import { useLocalStorage } from '../../hooks/useLocalStorage'; import { MODEL_COLORS } from './fleetClusterConstants'; import type { HistoryFrame } from './fleetClusterTypes'; import type { GearCorrelationItem } from '../../services/vesselAnalysis'; import { useReplayCenterPanelLayout } from './useReplayCenterPanelLayout'; interface HistoryReplayControllerProps { onClose: () => void; hasRightReviewPanel?: boolean; } const MIN_AB_GAP_MS = 2 * 3600_000; const BASE_PLAYBACK_SPEED = 0.5; const SPEED_MULTIPLIERS = [1, 2, 5, 10] as const; interface ReplayUiPrefs { showTrails: boolean; showLabels: boolean; focusMode: boolean; show1hPolygon: boolean; show6hPolygon: boolean; abLoop: boolean; speedMultiplier: 1 | 2 | 5 | 10; } const DEFAULT_REPLAY_UI_PREFS: ReplayUiPrefs = { showTrails: true, showLabels: true, focusMode: false, show1hPolygon: true, show6hPolygon: false, abLoop: false, speedMultiplier: 1, }; // 멤버 정보 + 소속 모델 매핑 interface TooltipMember { mmsi: string; name: string; isGear: boolean; isParent: boolean; sources: { label: string; color: string }[]; // 소속 (1h, 6h, 모델명) } function buildTooltipMembers( frame1h: HistoryFrame | null, frame6h: HistoryFrame | null, correlationByModel: Map, enabledModels: Set, enabledVessels: Set, ): TooltipMember[] { const map = new Map(); const addSource = (mmsi: string, name: string, isGear: boolean, isParent: boolean, label: string, color: string) => { const existing = map.get(mmsi); if (existing) { existing.sources.push({ label, color }); } else { map.set(mmsi, { mmsi, name, isGear, isParent, sources: [{ label, color }] }); } }; // 1h 멤버 if (frame1h) { for (const m of frame1h.members) { const isGear = m.role === 'GEAR'; addSource(m.mmsi, m.name || m.mmsi, isGear, m.isParent, '1h', '#fbbf24'); } } // 6h 멤버 if (frame6h) { for (const m of frame6h.members) { const isGear = m.role === 'GEAR'; addSource(m.mmsi, m.name || m.mmsi, isGear, m.isParent, '6h', '#93c5fd'); } } // 활성 모델의 일치율 대상 for (const [modelName, items] of correlationByModel) { if (modelName === 'identity') continue; if (!enabledModels.has(modelName)) continue; const color = MODEL_COLORS[modelName] ?? '#94a3b8'; for (const c of items) { if (!enabledVessels.has(c.targetMmsi)) continue; const isGear = c.targetType === 'GEAR_BUOY'; addSource(c.targetMmsi, c.targetName || c.targetMmsi, isGear, false, modelName, color); } } return [...map.values()]; } const HistoryReplayController = ({ onClose, hasRightReviewPanel = false }: HistoryReplayControllerProps) => { const isPlaying = useGearReplayStore(s => s.isPlaying); const snapshotRanges = useGearReplayStore(s => s.snapshotRanges); const snapshotRanges6h = useGearReplayStore(s => s.snapshotRanges6h); const historyFrames = useGearReplayStore(s => s.historyFrames); const historyFrames6h = useGearReplayStore(s => s.historyFrames6h); const frameCount = historyFrames.length; const frameCount6h = historyFrames6h.length; const dataStartTime = useGearReplayStore(s => s.dataStartTime); const dataEndTime = useGearReplayStore(s => s.dataEndTime); const playbackSpeed = useGearReplayStore(s => s.playbackSpeed); const showTrails = useGearReplayStore(s => s.showTrails); const showLabels = useGearReplayStore(s => s.showLabels); const focusMode = useGearReplayStore(s => s.focusMode); const show1hPolygon = useGearReplayStore(s => s.show1hPolygon); const show6hPolygon = useGearReplayStore(s => s.show6hPolygon); const abLoop = useGearReplayStore(s => s.abLoop); const abA = useGearReplayStore(s => s.abA); const abB = useGearReplayStore(s => s.abB); const correlationByModel = useGearReplayStore(s => s.correlationByModel); const enabledModels = useGearReplayStore(s => s.enabledModels); const enabledVessels = useGearReplayStore(s => s.enabledVessels); const hoveredMmsi = useGearReplayStore(s => s.hoveredMmsi); const has6hData = frameCount6h > 0; const [hoveredTooltip, setHoveredTooltip] = useState<{ pos: number; time: number; frame1h: HistoryFrame | null; frame6h: HistoryFrame | null } | null>(null); const [pinnedTooltip, setPinnedTooltip] = useState<{ pos: number; time: number; frame1h: HistoryFrame | null; frame6h: HistoryFrame | null } | null>(null); const [dragging, setDragging] = useState<'A' | 'B' | null>(null); const [replayUiPrefs, setReplayUiPrefs] = useLocalStorage('gearReplayUiPrefs', DEFAULT_REPLAY_UI_PREFS); const trackRef = useRef(null); const progressIndicatorRef = useRef(null); const timeDisplayRef = useRef(null); const store = useGearReplayStore; const speedMultiplier = SPEED_MULTIPLIERS.includes(replayUiPrefs.speedMultiplier) ? replayUiPrefs.speedMultiplier : 1; // currentTime → 진행 인디케이터 useEffect(() => { const unsub = store.subscribe( s => s.currentTime, (currentTime) => { const { startTime, endTime } = store.getState(); if (endTime <= startTime) return; const progress = (currentTime - startTime) / (endTime - startTime); 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; }, [store]); // 재생 시작 시 고정 툴팁 해제 useEffect(() => { if (isPlaying) setPinnedTooltip(null); }, [isPlaying]); useEffect(() => { const replayStore = store.getState(); replayStore.setShowTrails(replayUiPrefs.showTrails); replayStore.setShowLabels(replayUiPrefs.showLabels); replayStore.setFocusMode(replayUiPrefs.focusMode); replayStore.setShow1hPolygon(replayUiPrefs.show1hPolygon); replayStore.setShow6hPolygon(has6hData ? replayUiPrefs.show6hPolygon : false); }, [ has6hData, replayUiPrefs.focusMode, replayUiPrefs.show1hPolygon, replayUiPrefs.show6hPolygon, replayUiPrefs.showLabels, replayUiPrefs.showTrails, store, ]); useEffect(() => { store.getState().setAbLoop(replayUiPrefs.abLoop); }, [dataEndTime, dataStartTime, replayUiPrefs.abLoop, store]); useEffect(() => { const nextSpeed = BASE_PLAYBACK_SPEED * speedMultiplier; if (Math.abs(playbackSpeed - nextSpeed) > 1e-9) { store.getState().setPlaybackSpeed(nextSpeed); } }, [playbackSpeed, speedMultiplier, store]); const posToProgress = useCallback((clientX: number) => { const rect = trackRef.current?.getBoundingClientRect(); if (!rect) return 0; return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); }, []); const progressToTime = useCallback((p: number) => { const { startTime, endTime } = store.getState(); return startTime + p * (endTime - startTime); }, [store]); // 특정 시간에 가장 가까운 1h/6h 프레임 찾기 const findClosestFrames = useCallback((t: number) => { const { startTime, endTime } = store.getState(); const threshold = (endTime - startTime) * 0.01; let f1h: HistoryFrame | null = null; let f6h: HistoryFrame | null = null; let minD1h = Infinity; let minD6h = Infinity; for (const f of historyFrames) { const d = Math.abs(new Date(f.snapshotTime).getTime() - t); if (d < minD1h && d < threshold) { minD1h = d; f1h = f; } } for (const f of historyFrames6h) { const d = Math.abs(new Date(f.snapshotTime).getTime() - t); if (d < minD6h && d < threshold) { minD6h = d; f6h = f; } } return { f1h, f6h }; }, [store, historyFrames, historyFrames6h]); // 트랙 클릭 → seek + 일시정지 + 툴팁 고정/갱신 const handleTrackClick = useCallback((e: React.MouseEvent) => { if (dragging) return; const progress = posToProgress(e.clientX); const t = progressToTime(progress); store.getState().pause(); store.getState().seek(t); // 가까운 프레임이 있으면 툴팁 고정 const { f1h, f6h } = findClosestFrames(t); if (f1h || f6h) { setPinnedTooltip({ pos: progress, time: t, frame1h: f1h, frame6h: f6h }); const mmsis = new Set(); if (f1h) f1h.members.forEach(m => mmsis.add(m.mmsi)); if (f6h) f6h.members.forEach(m => mmsis.add(m.mmsi)); for (const [mn, items] of correlationByModel) { if (mn === 'identity' || !enabledModels.has(mn)) continue; for (const c of items) { if (enabledVessels.has(c.targetMmsi)) mmsis.add(c.targetMmsi); } } store.getState().setPinnedMmsis(mmsis); } else { setPinnedTooltip(null); store.getState().setPinnedMmsis(new Set()); } }, [store, posToProgress, progressToTime, findClosestFrames, dragging, correlationByModel, enabledModels, enabledVessels]); // 호버 → 1h+6h 프레임 동시 검색 const handleTrackHover = useCallback((e: React.MouseEvent) => { if (dragging || pinnedTooltip) return; const progress = posToProgress(e.clientX); const t = progressToTime(progress); const { f1h, f6h } = findClosestFrames(t); if (f1h || f6h) { setHoveredTooltip({ pos: progress, time: t, frame1h: f1h, frame6h: f6h }); } else { setHoveredTooltip(null); } }, [posToProgress, progressToTime, findClosestFrames, dragging, pinnedTooltip]); // A-B 드래그 const handleAbDown = useCallback((marker: 'A' | 'B') => (e: React.MouseEvent) => { if (isPlaying) return; e.stopPropagation(); setDragging(marker); }, [isPlaying]); useEffect(() => { if (!dragging) return; const handleMove = (e: MouseEvent) => { const t = progressToTime(posToProgress(e.clientX)); const { startTime, endTime } = store.getState(); const s = store.getState(); if (dragging === 'A') { store.getState().setAbA(Math.max(startTime, Math.min(s.abB - MIN_AB_GAP_MS, t))); } else { store.getState().setAbB(Math.min(endTime, Math.max(s.abA + MIN_AB_GAP_MS, t))); } }; const handleUp = () => setDragging(null); window.addEventListener('mousemove', handleMove); window.addEventListener('mouseup', handleUp); return () => { window.removeEventListener('mousemove', handleMove); window.removeEventListener('mouseup', handleUp); }; }, [dragging, store, posToProgress, progressToTime]); const abAPos = useMemo(() => { if (!abLoop || abA <= 0) return -1; const { startTime, endTime } = store.getState(); return endTime > startTime ? (abA - startTime) / (endTime - startTime) : -1; }, [abLoop, abA, store]); const abBPos = useMemo(() => { if (!abLoop || abB <= 0) return -1; const { startTime, endTime } = store.getState(); return endTime > startTime ? (abB - startTime) / (endTime - startTime) : -1; }, [abLoop, abB, store]); // 고정 툴팁 멤버 빌드 const pinnedMembers = useMemo(() => { if (!pinnedTooltip) return []; return buildTooltipMembers(pinnedTooltip.frame1h, pinnedTooltip.frame6h, correlationByModel, enabledModels, enabledVessels); }, [pinnedTooltip, correlationByModel, enabledModels, enabledVessels]); // 호버 리치 멤버 목록 (고정 툴팁과 동일 형식) const hoveredMembers = useMemo(() => { if (!hoveredTooltip) return []; return buildTooltipMembers(hoveredTooltip.frame1h, hoveredTooltip.frame6h, correlationByModel, enabledModels, enabledVessels); }, [hoveredTooltip, correlationByModel, enabledModels, enabledVessels]); // 닫기 핸들러 (고정 해제 포함) const handleClose = useCallback(() => { setPinnedTooltip(null); store.getState().setPinnedMmsis(new Set()); onClose(); }, [store, onClose]); const btnStyle: React.CSSProperties = { background: 'none', border: '1px solid rgba(99,179,237,0.3)', borderRadius: 4, color: '#e2e8f0', cursor: 'pointer', padding: '2px 6px', fontSize: 10, fontFamily: FONT_MONO, }; const btnActiveStyle: React.CSSProperties = { ...btnStyle, background: 'rgba(99,179,237,0.15)', color: '#93c5fd', }; const layout = useReplayCenterPanelLayout({ minWidth: 266, maxWidth: 966, hasRightReviewPanel, }); return (
{/* 프로그레스 트랙 */}
{ if (!pinnedTooltip) setHoveredTooltip(null); }} >
{/* A-B 구간 */} {abLoop && abAPos >= 0 && abBPos >= 0 && (
)} {snapshotRanges6h.map((pos, i) => (
))} {snapshotRanges.map((pos, i) => (
))} {/* A-B 마커 */} {abLoop && abAPos >= 0 && (
A
)} {abLoop && abBPos >= 0 && (
B
)} {/* 호버 하이라이트 */} {hoveredTooltip && !pinnedTooltip && (
)} {/* 고정 마커 */} {pinnedTooltip && (
)} {/* 진행 인디케이터 */}
{/* 호버 리치 툴팁 (고정 아닌 상태) */} {hoveredTooltip && !pinnedTooltip && hoveredMembers.length > 0 && (
{new Date(hoveredTooltip.time).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
{hoveredMembers.map(m => (
{m.isGear ? '◇' : '△'}{m.isParent ? '★' : ''} {m.name}
{m.sources.map((s, si) => ( {(s.label === '1h' || s.label === '6h') ? s.label : ''} ))}
))}
)} {/* 고정 리치 툴팁 */} {pinnedTooltip && pinnedMembers.length > 0 && (
e.stopPropagation()} style={{ position: 'absolute', left: `${Math.min(pinnedTooltip.pos * 100, 85)}%`, top: -8, transform: 'translateY(-100%)', background: 'rgba(10,20,32,0.97)', border: '1px solid rgba(99,179,237,0.4)', borderRadius: 6, padding: '6px 8px', maxWidth: 320, maxHeight: 200, overflowY: 'auto', fontSize: 9, zIndex: 40, pointerEvents: 'auto', }}> {/* 헤더 */}
{new Date(pinnedTooltip.time).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
{/* 멤버 목록 (호버 → 지도 강조) */} {pinnedMembers.map(m => (
store.getState().setHoveredMmsi(m.mmsi)} onMouseLeave={() => store.getState().setHoveredMmsi(null)} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '2px 3px', borderBottom: '1px solid rgba(255,255,255,0.04)', cursor: 'pointer', borderRadius: 2, background: hoveredMmsi === m.mmsi ? 'rgba(255,255,255,0.08)' : 'transparent', }} > {m.isGear ? '◇' : '△'}{m.isParent ? '★' : ''} {m.name}
{m.sources.map((s, si) => ( {(s.label === '1h' || s.label === '6h') ? s.label : ''} ))}
))}
)}
{/* 컨트롤 행 */}
--:-- | | | |
{SPEED_MULTIPLIERS.map(multiplier => { const active = speedMultiplier === multiplier; return ( ); })}
{frameCount} {has6hData && <> / {frameCount6h}} 건
); }; export default HistoryReplayController;