ParentReviewPanel 마운트 + 관련 상태 관리를 FleetClusterLayer에 통합. 리플레이 컨트롤러, 어구 그룹 섹션, 일치율 패널 등 11개 컴포넌트 codex Lab 환경에서 검증된 버전으로 교체. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
571 lines
26 KiB
TypeScript
571 lines
26 KiB
TypeScript
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<string, GearCorrelationItem[]>,
|
|
enabledModels: Set<string>,
|
|
enabledVessels: Set<string>,
|
|
): TooltipMember[] {
|
|
const map = new Map<string, TooltipMember>();
|
|
|
|
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<ReplayUiPrefs>('gearReplayUiPrefs', DEFAULT_REPLAY_UI_PREFS);
|
|
const trackRef = useRef<HTMLDivElement>(null);
|
|
const progressIndicatorRef = useRef<HTMLDivElement>(null);
|
|
const timeDisplayRef = useRef<HTMLSpanElement>(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<HTMLDivElement>) => {
|
|
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<string>();
|
|
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<HTMLDivElement>) => {
|
|
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 (
|
|
<div style={{
|
|
position: 'absolute', bottom: 20,
|
|
left: `${layout.left}px`,
|
|
width: `${layout.width}px`,
|
|
background: 'rgba(12,24,37,0.95)', border: '1px solid rgba(99,179,237,0.25)',
|
|
borderRadius: 8, padding: '8px 14px', display: 'flex', flexDirection: 'column', gap: 4,
|
|
zIndex: 50, fontFamily: FONT_MONO, fontSize: 10, color: '#e2e8f0',
|
|
boxShadow: '0 4px 16px rgba(0,0,0,0.5)', pointerEvents: 'auto',
|
|
}}>
|
|
{/* 프로그레스 트랙 */}
|
|
<div
|
|
ref={trackRef}
|
|
style={{ position: 'relative', height: 18, cursor: 'pointer' }}
|
|
onClick={handleTrackClick}
|
|
onMouseMove={handleTrackHover}
|
|
onMouseLeave={() => { if (!pinnedTooltip) setHoveredTooltip(null); }}
|
|
>
|
|
<div style={{ position: 'absolute', left: 0, right: 0, top: 5, height: 8, background: 'rgba(255,255,255,0.05)', borderRadius: 4 }} />
|
|
|
|
{/* A-B 구간 */}
|
|
{abLoop && abAPos >= 0 && abBPos >= 0 && (
|
|
<div style={{
|
|
position: 'absolute', left: `${abAPos * 100}%`, top: 5,
|
|
width: `${(abBPos - abAPos) * 100}%`, height: 8,
|
|
background: 'rgba(34,197,94,0.12)', borderRadius: 4, pointerEvents: 'none',
|
|
}} />
|
|
)}
|
|
|
|
{snapshotRanges6h.map((pos, i) => (
|
|
<div key={`6h-${i}`} style={{ position: 'absolute', left: `${pos * 100}%`, top: 9, width: 2, height: 4, background: 'rgba(147,197,253,0.4)' }} />
|
|
))}
|
|
{snapshotRanges.map((pos, i) => (
|
|
<div key={`1h-${i}`} style={{ position: 'absolute', left: `${pos * 100}%`, top: 5, width: 2, height: 4, background: 'rgba(251,191,36,0.5)' }} />
|
|
))}
|
|
|
|
{/* A-B 마커 */}
|
|
{abLoop && abAPos >= 0 && (
|
|
<div onMouseDown={handleAbDown('A')} style={{
|
|
position: 'absolute', left: `${abAPos * 100}%`, top: 0, width: 8, height: 18,
|
|
transform: 'translateX(-50%)', cursor: isPlaying ? 'default' : 'ew-resize', zIndex: 5,
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
}}>
|
|
<div style={{ width: 2, height: 16, background: 'rgba(34,197,94,0.8)', borderRadius: 1 }} />
|
|
<span style={{ position: 'absolute', top: -14, fontSize: 10, color: '#22c55e', fontWeight: 700, pointerEvents: 'none', background: 'rgba(0,0,0,0.7)', borderRadius: 2, padding: '0 3px', lineHeight: '14px' }}>A</span>
|
|
</div>
|
|
)}
|
|
{abLoop && abBPos >= 0 && (
|
|
<div onMouseDown={handleAbDown('B')} style={{
|
|
position: 'absolute', left: `${abBPos * 100}%`, top: 0, width: 8, height: 18,
|
|
transform: 'translateX(-50%)', cursor: isPlaying ? 'default' : 'ew-resize', zIndex: 5,
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
}}>
|
|
<div style={{ width: 2, height: 16, background: 'rgba(34,197,94,0.8)', borderRadius: 1 }} />
|
|
<span style={{ position: 'absolute', top: -14, fontSize: 10, color: '#22c55e', fontWeight: 700, pointerEvents: 'none', background: 'rgba(0,0,0,0.7)', borderRadius: 2, padding: '0 3px', lineHeight: '14px' }}>B</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* 호버 하이라이트 */}
|
|
{hoveredTooltip && !pinnedTooltip && (
|
|
<div style={{
|
|
position: 'absolute', left: `${hoveredTooltip.pos * 100}%`, top: 3, width: 4, height: 12,
|
|
background: 'rgba(255,255,255,0.6)',
|
|
borderRadius: 1, transform: 'translateX(-50%)', pointerEvents: 'none',
|
|
}} />
|
|
)}
|
|
|
|
{/* 고정 마커 */}
|
|
{pinnedTooltip && (
|
|
<div style={{
|
|
position: 'absolute', left: `${pinnedTooltip.pos * 100}%`, top: 1, width: 5, height: 16,
|
|
background: 'rgba(255,255,255,0.9)', borderRadius: 1, transform: 'translateX(-50%)', pointerEvents: 'none',
|
|
}} />
|
|
)}
|
|
|
|
{/* 진행 인디케이터 */}
|
|
<div ref={progressIndicatorRef} style={{
|
|
position: 'absolute', left: '0%', top: 3, width: 3, height: 12,
|
|
background: '#fbbf24', borderRadius: 1, transform: 'translateX(-50%)', pointerEvents: 'none',
|
|
}} />
|
|
|
|
{/* 호버 리치 툴팁 (고정 아닌 상태) */}
|
|
{hoveredTooltip && !pinnedTooltip && hoveredMembers.length > 0 && (
|
|
<div style={{
|
|
position: 'absolute',
|
|
left: `${Math.min(hoveredTooltip.pos * 100, 85)}%`,
|
|
top: -8, transform: 'translateY(-100%)',
|
|
background: 'rgba(10,20,32,0.95)', border: '1px solid rgba(99,179,237,0.3)',
|
|
borderRadius: 6, padding: '5px 7px', maxWidth: 300, maxHeight: 160, overflowY: 'auto',
|
|
fontSize: 9, zIndex: 30, pointerEvents: 'none',
|
|
}}>
|
|
<div style={{ color: '#fbbf24', fontWeight: 600, marginBottom: 3 }}>
|
|
{new Date(hoveredTooltip.time).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
|
</div>
|
|
{hoveredMembers.map(m => (
|
|
<div key={m.mmsi} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '1px 0' }}>
|
|
<span style={{ color: m.isGear ? '#94a3b8' : '#fbbf24', fontSize: 8, width: 16, flexShrink: 0 }}>
|
|
{m.isGear ? '◇' : '△'}{m.isParent ? '★' : ''}
|
|
</span>
|
|
<span style={{ color: '#e2e8f0', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{m.name}
|
|
</span>
|
|
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
|
{m.sources.map((s, si) => (
|
|
<span key={si} style={{
|
|
display: 'inline-block', width: s.label === '1h' || s.label === '6h' ? 'auto' : 6,
|
|
height: 6, borderRadius: s.label === '1h' || s.label === '6h' ? 2 : '50%',
|
|
background: s.color, fontSize: 7, color: '#000', fontWeight: 700,
|
|
padding: s.label === '1h' || s.label === '6h' ? '0 2px' : 0,
|
|
lineHeight: '6px', textAlign: 'center',
|
|
}}>
|
|
{(s.label === '1h' || s.label === '6h') ? s.label : ''}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* 고정 리치 툴팁 */}
|
|
{pinnedTooltip && pinnedMembers.length > 0 && (
|
|
<div
|
|
onClick={e => 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',
|
|
}}>
|
|
{/* 헤더 */}
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
|
<span style={{ color: '#fbbf24', fontWeight: 600 }}>
|
|
{new Date(pinnedTooltip.time).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
|
</span>
|
|
<button type="button" onClick={(e) => { e.stopPropagation(); setPinnedTooltip(null); store.getState().setPinnedMmsis(new Set()); }}
|
|
style={{ background: 'none', border: 'none', color: '#64748b', cursor: 'pointer', fontSize: 10, padding: 0 }}>
|
|
✕
|
|
</button>
|
|
</div>
|
|
|
|
{/* 멤버 목록 (호버 → 지도 강조) */}
|
|
{pinnedMembers.map(m => (
|
|
<div
|
|
key={m.mmsi}
|
|
onMouseEnter={() => 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',
|
|
}}
|
|
>
|
|
<span style={{ color: m.isGear ? '#94a3b8' : '#fbbf24', fontSize: 8, width: 16, flexShrink: 0 }}>
|
|
{m.isGear ? '◇' : '△'}{m.isParent ? '★' : ''}
|
|
</span>
|
|
<span style={{
|
|
color: hoveredMmsi === m.mmsi ? '#ffffff' : '#e2e8f0',
|
|
fontWeight: hoveredMmsi === m.mmsi ? 600 : 400,
|
|
flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
|
}}>
|
|
{m.name}
|
|
</span>
|
|
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
|
{m.sources.map((s, si) => (
|
|
<span key={si} style={{
|
|
display: 'inline-block', width: s.label === '1h' || s.label === '6h' ? 'auto' : 6,
|
|
height: 6, borderRadius: s.label === '1h' || s.label === '6h' ? 2 : '50%',
|
|
background: s.color, fontSize: 7, color: '#000', fontWeight: 700,
|
|
padding: s.label === '1h' || s.label === '6h' ? '0 2px' : 0,
|
|
lineHeight: '6px', textAlign: 'center',
|
|
}}>
|
|
{(s.label === '1h' || s.label === '6h') ? s.label : ''}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 컨트롤 행 */}
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' }}>
|
|
<button type="button" onClick={() => { if (isPlaying) store.getState().pause(); else store.getState().play(); }}
|
|
style={{ ...btnStyle, fontSize: 12 }}>{isPlaying ? '⏸' : '▶'}</button>
|
|
<span ref={timeDisplayRef} style={{ color: '#fbbf24', minWidth: 36, textAlign: 'center' }}>--:--</span>
|
|
<span style={{ color: '#475569' }}>|</span>
|
|
<button type="button" onClick={() => setReplayUiPrefs(prev => ({ ...prev, showTrails: !prev.showTrails }))}
|
|
style={showTrails ? btnActiveStyle : btnStyle} title="항적">항적</button>
|
|
<button type="button" onClick={() => setReplayUiPrefs(prev => ({ ...prev, showLabels: !prev.showLabels }))}
|
|
style={showLabels ? btnActiveStyle : btnStyle} title="이름">이름</button>
|
|
<button type="button" onClick={() => setReplayUiPrefs(prev => ({ ...prev, focusMode: !prev.focusMode }))}
|
|
style={focusMode ? { ...btnStyle, background: 'rgba(239,68,68,0.15)', color: '#f87171' } : btnStyle}
|
|
title="집중 모드">집중</button>
|
|
<span style={{ color: '#475569' }}>|</span>
|
|
<button type="button" onClick={() => setReplayUiPrefs(prev => ({ ...prev, show1hPolygon: !prev.show1hPolygon }))}
|
|
style={show1hPolygon ? { ...btnActiveStyle, background: 'rgba(251,191,36,0.15)', color: '#fbbf24', border: '1px solid rgba(251,191,36,0.4)' } : btnStyle}
|
|
title="1h 폴리곤">1h</button>
|
|
<button type="button" onClick={() => has6hData && setReplayUiPrefs(prev => ({ ...prev, show6hPolygon: !prev.show6hPolygon }))}
|
|
style={!has6hData ? { ...btnStyle, opacity: 0.3, cursor: 'not-allowed' }
|
|
: show6hPolygon ? { ...btnActiveStyle, background: 'rgba(147,197,253,0.15)', color: '#93c5fd', border: '1px solid rgba(147,197,253,0.4)' } : btnStyle}
|
|
disabled={!has6hData} title="6h 폴리곤">6h</button>
|
|
<span style={{ color: '#475569' }}>|</span>
|
|
<button type="button" onClick={() => setReplayUiPrefs(prev => ({ ...prev, abLoop: !prev.abLoop }))}
|
|
style={abLoop ? { ...btnStyle, background: 'rgba(34,197,94,0.15)', color: '#22c55e', border: '1px solid rgba(34,197,94,0.4)' } : btnStyle}
|
|
title="A-B 구간 반복">A-B</button>
|
|
<span style={{ color: '#475569' }}>|</span>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
{SPEED_MULTIPLIERS.map(multiplier => {
|
|
const active = speedMultiplier === multiplier;
|
|
return (
|
|
<button
|
|
key={multiplier}
|
|
type="button"
|
|
onClick={() => setReplayUiPrefs(prev => ({ ...prev, speedMultiplier: multiplier }))}
|
|
style={active
|
|
? { ...btnActiveStyle, background: 'rgba(250,204,21,0.16)', color: '#fde68a', border: '1px solid rgba(250,204,21,0.32)' }
|
|
: btnStyle}
|
|
title={`재생 속도 x${multiplier}`}
|
|
>
|
|
x{multiplier}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<span style={{ flex: 1 }} />
|
|
<span style={{ color: '#64748b', fontSize: 9 }}>
|
|
<span style={{ color: '#fbbf24' }}>{frameCount}</span>
|
|
{has6hData && <> / <span style={{ color: '#93c5fd' }}>{frameCount6h}</span></>} 건
|
|
</span>
|
|
<button type="button" onClick={handleClose}
|
|
style={{ background: 'none', border: '1px solid rgba(239,68,68,0.3)', borderRadius: 4, color: '#ef4444', cursor: 'pointer', padding: '2px 6px', fontSize: 11, fontFamily: FONT_MONO }}>
|
|
✕
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default HistoryReplayController;
|