feat: 어구 모선 추론 UI 통합 — FleetClusterLayer + 리플레이 컴포넌트 이식

ParentReviewPanel 마운트 + 관련 상태 관리를 FleetClusterLayer에 통합.
리플레이 컨트롤러, 어구 그룹 섹션, 일치율 패널 등 11개 컴포넌트
codex Lab 환경에서 검증된 버전으로 교체.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-04-04 00:48:48 +09:00
부모 7dd46f2078
커밋 8362bc5b6c
11개의 변경된 파일1729개의 추가작업 그리고 338개의 파일을 삭제

파일 보기

@ -6,6 +6,8 @@ import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
import { FONT_MONO } from '../../styles/fonts';
import { MODEL_ORDER, MODEL_COLORS, MODEL_DESC } from './fleetClusterConstants';
import { useGearReplayStore } from '../../stores/gearReplayStore';
import { useTranslation } from 'react-i18next';
import { useReplayCenterPanelLayout } from './useReplayCenterPanelLayout';
interface CorrelationPanelProps {
selectedGearGroup: string;
@ -17,6 +19,8 @@ interface CorrelationPanelProps {
enabledVessels: Set<string>;
correlationLoading: boolean;
hoveredTarget: { mmsi: string; model: string } | null;
hasRightReviewPanel?: boolean;
reviewDriven?: boolean;
onEnabledModelsChange: (updater: (prev: Set<string>) => Set<string>) => void;
onEnabledVesselsChange: (updater: (prev: Set<string>) => Set<string>) => void;
onHoveredTargetChange: (target: { mmsi: string; model: string } | null) => void;
@ -35,11 +39,19 @@ const CorrelationPanel = ({
enabledVessels,
correlationLoading,
hoveredTarget,
hasRightReviewPanel = false,
reviewDriven = false,
onEnabledModelsChange,
onEnabledVesselsChange,
onHoveredTargetChange,
}: CorrelationPanelProps) => {
const { t } = useTranslation();
const historyActive = useGearReplayStore(s => s.historyFrames.length > 0);
const layout = useReplayCenterPanelLayout({
minWidth: 252,
maxWidth: 966,
hasRightReviewPanel,
});
// Local tooltip state
const [hoveredModelTip, setHoveredModelTip] = useState<string | null>(null);
@ -193,16 +205,30 @@ const CorrelationPanel = ({
key={`${modelName}-${c.targetMmsi}`}
style={{
fontSize: 9, marginBottom: 1, display: 'flex', alignItems: 'center', gap: 3,
padding: '1px 2px', borderRadius: 2, cursor: 'pointer',
padding: '1px 2px', borderRadius: 2, cursor: reviewDriven ? 'default' : 'pointer',
background: isHovered ? `${color}22` : 'transparent',
opacity: isEnabled ? 1 : 0.5,
opacity: reviewDriven ? 1 : isEnabled ? 1 : 0.5,
}}
onClick={() => toggleVessel(c.targetMmsi)}
onMouseEnter={() => onHoveredTargetChange({ mmsi: c.targetMmsi, model: modelName })}
onMouseLeave={() => onHoveredTargetChange(null)}
onClick={reviewDriven ? undefined : () => toggleVessel(c.targetMmsi)}
onMouseEnter={reviewDriven ? undefined : () => onHoveredTargetChange({ mmsi: c.targetMmsi, model: modelName })}
onMouseLeave={reviewDriven ? undefined : () => onHoveredTargetChange(null)}
>
{reviewDriven ? (
<span
title={t('parentInference.reference.reviewDriven')}
style={{
width: 9,
height: 9,
borderRadius: 999,
background: color,
flexShrink: 0,
opacity: 0.9,
}}
/>
) : (
<input type="checkbox" checked={isEnabled} readOnly title="맵 표시"
style={{ accentColor: color, width: 9, height: 9, flexShrink: 0, pointerEvents: 'none' }} />
)}
<span style={{ color: isVessel ? '#60a5fa' : '#f97316', width: 10, textAlign: 'center', flexShrink: 0 }}>
{isVessel ? '⛴' : '◆'}
</span>
@ -219,6 +245,15 @@ const CorrelationPanel = ({
);
};
const visibleModelNames = useMemo(() => {
if (reviewDriven) {
return availableModels
.filter(model => (correlationByModel.get(model.name) ?? []).length > 0)
.map(model => model.name);
}
return availableModels.filter(model => enabledModels.has(model.name)).map(model => model.name);
}, [availableModels, correlationByModel, enabledModels, reviewDriven]);
// Member row renderer (identity model — no score, independent hover)
const renderMemberRow = (m: { mmsi: string; name: string }, icon: string, iconColor: string, keyPrefix = 'id') => {
const isHovered = hoveredTarget?.mmsi === m.mmsi && hoveredTarget?.model === 'identity';
@ -251,10 +286,8 @@ const CorrelationPanel = ({
<div style={{
position: 'absolute',
bottom: historyActive ? 120 : 20,
left: 'calc(50% + 100px)',
transform: 'translateX(-50%)',
width: 'calc(100vw - 880px)',
maxWidth: 1320,
left: `${layout.left}px`,
width: `${layout.width}px`,
display: 'flex',
gap: 6,
alignItems: 'flex-end',
@ -270,6 +303,7 @@ const CorrelationPanel = ({
border: '1px solid rgba(249,115,22,0.3)',
borderRadius: 8,
padding: '8px 10px',
width: 165,
minWidth: 165,
flexShrink: 0,
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
@ -278,6 +312,22 @@ const CorrelationPanel = ({
<span style={{ fontWeight: 700, color: '#f97316', fontSize: 11 }}>{selectedGearGroup}</span>
<span style={{ color: '#64748b', fontSize: 9 }}>{memberCount}</span>
</div>
<div style={{
marginBottom: 7,
padding: '6px 7px',
borderRadius: 6,
background: 'rgba(15,23,42,0.72)',
border: '1px solid rgba(249,115,22,0.14)',
color: '#cbd5e1',
fontSize: 8,
lineHeight: 1.45,
whiteSpace: 'normal',
wordBreak: 'keep-all',
}}>
{reviewDriven
? t('parentInference.reference.reviewDriven')
: t('parentInference.reference.shipOnly')}
</div>
<div style={{ fontSize: 9, fontWeight: 700, color: '#93c5fd', marginBottom: 4 }}> </div>
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, cursor: 'pointer', marginBottom: 3 }}>
<input
@ -300,7 +350,10 @@ const CorrelationPanel = ({
const gc = modelItems.filter(c => c.targetType !== 'VESSEL').length;
const am = availableModels.find(m => m.name === mn);
return (
<label key={mn} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, cursor: hasData ? 'pointer' : 'default', marginBottom: 3, opacity: hasData ? 1 : 0.4 }}>
<label key={mn} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, cursor: reviewDriven ? 'default' : hasData ? 'pointer' : 'default', marginBottom: 3, opacity: hasData ? 1 : 0.4 }}>
{reviewDriven ? (
<span style={{ width: 11, height: 11, borderRadius: 999, background: hasData ? color : 'rgba(148,163,184,0.2)', flexShrink: 0 }} />
) : (
<input type="checkbox" checked={enabledModels.has(mn)}
disabled={!hasData}
onChange={() => onEnabledModelsChange(prev => {
@ -309,6 +362,7 @@ const CorrelationPanel = ({
return next;
})}
style={{ accentColor: color, width: 11, height: 11 }} title={mn} />
)}
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, flexShrink: 0, opacity: hasData ? 1 : 0.3 }} />
<span style={{ color: hasData ? '#e2e8f0' : '#64748b', flex: 1 }}>{mn}{am?.isDefault ? '*' : ''}</span>
<span style={{ color: '#64748b', fontSize: 8 }}>{hasData ? `${vc}${gc}` : '—'}</span>
@ -324,7 +378,7 @@ const CorrelationPanel = ({
}}>
{/* 이름 기반 카드 (체크 시) */}
{enabledModels.has('identity') && (identityVessels.length > 0 || identityGear.length > 0) && (
{(reviewDriven || enabledModels.has('identity')) && (identityVessels.length > 0 || identityGear.length > 0) && (
<div ref={(el) => setCardRef('identity', el)} style={{ ...cardStyle, borderColor: 'rgba(249,115,22,0.25)', position: 'relative' }}>
<div style={getCardBodyStyle('identity')}>
{identityVessels.length > 0 && (
@ -335,7 +389,9 @@ const CorrelationPanel = ({
)}
{identityGear.length > 0 && (
<>
<div style={{ fontSize: 8, color: '#64748b', marginBottom: 2, marginTop: 3 }}> ({identityGear.length})</div>
<div style={{ fontSize: 8, color: '#94a3b8', marginBottom: 2, marginTop: 3 }}>
{t('parentInference.reference.referenceGear')} ({identityGear.length})
</div>
{identityGear.map(m => renderMemberRow(m, '◆', '#f97316'))}
</>
)}
@ -355,7 +411,9 @@ const CorrelationPanel = ({
)}
{/* 각 Correlation 모델 카드 (체크 시 우측에 추가) */}
{availableModels.filter(m => enabledModels.has(m.name)).map(m => {
{visibleModelNames.map(modelName => {
const m = availableModels.find(model => model.name === modelName);
if (!m) return null;
const color = MODEL_COLORS[m.name] ?? '#94a3b8';
const items = correlationByModel.get(m.name) ?? [];
const vessels = items.filter(c => c.targetType === 'VESSEL');
@ -372,7 +430,9 @@ const CorrelationPanel = ({
)}
{gears.length > 0 && (
<>
<div style={{ fontSize: 8, color: '#64748b', marginBottom: 2, marginTop: 3 }}> ({gears.length})</div>
<div style={{ fontSize: 8, color: '#94a3b8', marginBottom: 2, marginTop: 3 }}>
{t('parentInference.reference.referenceGear')} ({gears.length})
</div>
{gears.map(c => renderRow(c, color, m.name))}
</>
)}

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -4,6 +4,7 @@ import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
import type { FleetListItem } from './fleetClusterTypes';
import { panelStyle, headerStyle, toggleButtonStyle } from './fleetClusterConstants';
import GearGroupSection from './GearGroupSection';
import { useTranslation } from 'react-i18next';
interface FleetGearListPanelProps {
fleetList: FleetListItem[];
@ -42,14 +43,15 @@ const FleetGearListPanel = ({
onExpandGearGroup,
onShipSelect,
}: FleetGearListPanelProps) => {
const { t } = useTranslation();
return (
<div style={panelStyle}>
{/* ── 선단 현황 섹션 ── */}
<div style={{ ...headerStyle, cursor: 'pointer' }} onClick={() => onToggleSection('fleet')}>
<span style={{ fontWeight: 700, color: '#63b3ed', letterSpacing: 0.5 }}>
({fleetList.length})
{t('fleetGear.fleetSection', { count: fleetList.length })}
</span>
<button type="button" style={toggleButtonStyle} aria-label="선단 현황 접기/펴기">
<button type="button" style={toggleButtonStyle} aria-label={t('fleetGear.toggleFleetSection')}>
{activeSection === 'fleet' ? '▲' : '▼'}
</button>
</div>
@ -57,12 +59,12 @@ const FleetGearListPanel = ({
<div style={{ padding: '4px 0', overflowY: 'auto', flex: 1 }}>
{fleetList.length === 0 ? (
<div style={{ color: '#64748b', textAlign: 'center', padding: '8px 10px' }}>
{t('fleetGear.emptyFleet')}
</div>
) : (
fleetList.map(({ id, mmsiList, label, color, members }) => {
const company = companies.get(id);
const companyName = company?.nameCn ?? label ?? `선단 #${id}`;
const companyName = company?.nameCn ?? label ?? t('fleetGear.fleetFallback', { id });
const isOpen = expandedFleet === id;
const isHovered = hoveredFleetId === id;
@ -95,17 +97,19 @@ const FleetGearListPanel = ({
title={company ? `${company.nameCn} / ${company.nameEn}` : companyName}>
{companyName}
</span>
<span style={{ color: '#64748b', fontSize: 10, flexShrink: 0 }}>({mmsiList.length})</span>
<span style={{ color: '#64748b', fontSize: 10, flexShrink: 0 }}>
{t('fleetGear.vesselCountCompact', { count: mmsiList.length })}
</span>
<button type="button" onClick={e => { e.stopPropagation(); onFleetZoom(id); }}
style={{ background: 'none', border: '1px solid rgba(99,179,237,0.3)', borderRadius: 3, color: '#63b3ed', fontSize: 9, cursor: 'pointer', padding: '1px 4px', flexShrink: 0 }}
title="이 선단으로 지도 이동">
zoom
title={t('fleetGear.moveToFleet')}>
{t('fleetGear.zoom')}
</button>
</div>
{isOpen && (
<div style={{ paddingLeft: 22, paddingRight: 10, paddingBottom: 6, fontSize: 10, color: '#94a3b8', borderLeft: `2px solid ${color}33`, marginLeft: 10 }}>
<div style={{ color: '#64748b', fontSize: 9, marginBottom: 3 }}>:</div>
<div style={{ color: '#64748b', fontSize: 9, marginBottom: 3 }}>{t('fleetGear.shipList')}:</div>
{displayMembers.map(m => {
const dto = analysisMap.get(m.mmsi);
const role = dto?.algorithms.fleetRole.role ?? m.role;
@ -116,11 +120,11 @@ const FleetGearListPanel = ({
{displayName}
</span>
<span style={{ color: role === 'LEADER' ? '#fbbf24' : '#64748b', fontSize: 9, flexShrink: 0 }}>
({role === 'LEADER' ? 'MAIN' : 'SUB'})
({role === 'LEADER' ? t('fleetGear.roleMain') : t('fleetGear.roleSub')})
</span>
<button type="button" onClick={() => onShipSelect(m.mmsi)}
style={{ background: 'none', border: 'none', color: '#63b3ed', fontSize: 10, cursor: 'pointer', padding: '0 2px', flexShrink: 0 }}
title="선박으로 이동" aria-label={`${displayName} 선박으로 이동`}>
title={t('fleetGear.moveToShip')} aria-label={t('fleetGear.moveToShipItem', { name: displayName })}>
</button>
</div>
@ -139,7 +143,7 @@ const FleetGearListPanel = ({
<GearGroupSection
groups={inZoneGearGroups}
sectionKey="inZone"
sectionLabel={`조업구역내 어구 (${inZoneGearGroups.length}개)`}
sectionLabel={t('fleetGear.inZoneSection', { count: inZoneGearGroups.length })}
accentColor="#dc2626"
hoverBgColor="rgba(220,38,38,0.06)"
isActive={activeSection === 'inZone'}
@ -154,7 +158,7 @@ const FleetGearListPanel = ({
<GearGroupSection
groups={outZoneGearGroups}
sectionKey="outZone"
sectionLabel={`비허가 어구 (${outZoneGearGroups.length}개)`}
sectionLabel={t('fleetGear.outZoneSection', { count: outZoneGearGroups.length })}
accentColor="#f97316"
hoverBgColor="rgba(255,255,255,0.04)"
isActive={activeSection === 'outZone'}

파일 보기

@ -1,6 +1,7 @@
import type { GroupPolygonDto } from '../../services/vesselAnalysis';
import { FONT_MONO } from '../../styles/fonts';
import { headerStyle, toggleButtonStyle } from './fleetClusterConstants';
import { useTranslation } from 'react-i18next';
interface GearGroupSectionProps {
groups: GroupPolygonDto[];
@ -29,8 +30,47 @@ const GearGroupSection = ({
onGroupZoom,
onShipSelect,
}: GearGroupSectionProps) => {
const { t } = useTranslation();
const isInZoneSection = sectionKey === 'inZone';
const getInferenceBadge = (status: string | null | undefined) => {
switch (status) {
case 'AUTO_PROMOTED':
return { label: t('parentInference.badges.AUTO_PROMOTED'), color: '#22c55e' };
case 'MANUAL_CONFIRMED':
return { label: t('parentInference.badges.MANUAL_CONFIRMED'), color: '#38bdf8' };
case 'DIRECT_PARENT_MATCH':
return { label: t('parentInference.badges.DIRECT_PARENT_MATCH'), color: '#2dd4bf' };
case 'REVIEW_REQUIRED':
return { label: t('parentInference.badges.REVIEW_REQUIRED'), color: '#f59e0b' };
case 'SKIPPED_SHORT_NAME':
return { label: t('parentInference.badges.SKIPPED_SHORT_NAME'), color: '#94a3b8' };
case 'NO_CANDIDATE':
return { label: t('parentInference.badges.NO_CANDIDATE'), color: '#c084fc' };
case 'UNRESOLVED':
return { label: t('parentInference.badges.UNRESOLVED'), color: '#64748b' };
default:
return null;
}
};
const getInferenceStatusLabel = (status: string | null | undefined) => {
if (!status) return '';
return t(`parentInference.status.${status}`, { defaultValue: status });
};
const getInferenceReason = (inference: GroupPolygonDto['parentInference']) => {
if (!inference) return '';
switch (inference.status) {
case 'SKIPPED_SHORT_NAME':
return t('parentInference.reasons.shortName');
case 'NO_CANDIDATE':
return t('parentInference.reasons.noCandidate');
default:
return inference.statusReason || inference.skipReason || '';
}
};
return (
<>
<div
@ -42,7 +82,7 @@ const GearGroupSection = ({
onClick={onToggleSection}
>
<span style={{ fontWeight: 700, color: accentColor, letterSpacing: 0.3, fontFamily: FONT_MONO }}>
{sectionLabel} ({groups.length})
{sectionLabel}
</span>
<button
type="button"
@ -61,6 +101,8 @@ const GearGroupSection = ({
const parentMember = g.members.find(m => m.isParent);
const gearMembers = g.members.filter(m => !m.isParent);
const zoneName = g.zoneName ?? '';
const inference = g.parentInference ?? null;
const badge = getInferenceBadge(inference?.status);
return (
<div key={name} id={`gear-row-${name}`}>
@ -117,6 +159,25 @@ const GearGroupSection = ({
</span>
)}
{badge && (
<span
style={{
color: badge.color,
border: `1px solid ${badge.color}55`,
borderRadius: 3,
padding: '0 4px',
fontSize: 8,
flexShrink: 0,
}}
title={
inference?.selectedParentName
? `${getInferenceStatusLabel(inference.status)}: ${inference.selectedParentName}`
: getInferenceReason(inference) || getInferenceStatusLabel(inference?.status) || ''
}
>
{badge.label}
</span>
)}
{isInZoneSection && zoneName && (
<span style={{ color: '#ef4444', fontSize: 9, flexShrink: 0 }}>{zoneName}</span>
)}
@ -139,9 +200,9 @@ const GearGroupSection = ({
padding: '1px 4px',
flexShrink: 0,
}}
title="이 어구 그룹으로 지도 이동"
title={t('fleetGear.moveToGroup')}
>
zoom
{t('fleetGear.zoom')}
</button>
</div>
@ -158,10 +219,17 @@ const GearGroupSection = ({
}}>
{parentMember && (
<div style={{ color: '#fbbf24', marginBottom: 2 }}>
: {parentMember.name || parentMember.mmsi}
{t('parentInference.summary.recommendedParent')}: {parentMember.name || parentMember.mmsi}
</div>
)}
<div style={{ color: '#64748b', marginBottom: 2 }}> :</div>
{inference && (
<div style={{ marginBottom: 4, color: inference.status === 'AUTO_PROMOTED' ? '#22c55e' : '#94a3b8' }}>
{t('parentInference.summary.label')}: {getInferenceStatusLabel(inference.status)}
{inference.selectedParentName ? ` / ${inference.selectedParentName}` : ''}
{getInferenceReason(inference) ? ` / ${getInferenceReason(inference)}` : ''}
</div>
)}
<div style={{ color: '#64748b', marginBottom: 2 }}>{t('fleetGear.gearList')}:</div>
{gearMembers.map(m => (
<div key={m.mmsi} style={{
display: 'flex',
@ -190,8 +258,8 @@ const GearGroupSection = ({
padding: '0 2px',
flexShrink: 0,
}}
title="어구 위치로 이동"
aria-label={`${m.name || m.mmsi} 위치로 이동`}
title={t('fleetGear.moveToGear')}
aria-label={t('fleetGear.moveToGearItem', { name: m.name || m.mmsi })}
>
</button>

파일 보기

@ -1,16 +1,40 @@
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;
onFilterByScore: (minPct: number | null) => 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 {
@ -70,7 +94,7 @@ function buildTooltipMembers(
return [...map.values()];
}
const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayControllerProps) => {
const HistoryReplayController = ({ onClose, hasRightReviewPanel = false }: HistoryReplayControllerProps) => {
const isPlaying = useGearReplayStore(s => s.isPlaying);
const snapshotRanges = useGearReplayStore(s => s.snapshotRanges);
const snapshotRanges6h = useGearReplayStore(s => s.snapshotRanges6h);
@ -78,6 +102,9 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
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);
@ -95,11 +122,15 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
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(() => {
@ -123,6 +154,34 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
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;
@ -258,13 +317,17 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
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: 'calc(50% + 100px)', transform: 'translateX(-50%)',
width: 'calc(100vw - 880px)',
minWidth: 380, maxWidth: 1320,
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',
@ -452,38 +515,44 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
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={() => store.getState().setShowTrails(!showTrails)}
<button type="button" onClick={() => setReplayUiPrefs(prev => ({ ...prev, showTrails: !prev.showTrails }))}
style={showTrails ? btnActiveStyle : btnStyle} title="항적"></button>
<button type="button" onClick={() => store.getState().setShowLabels(!showLabels)}
<button type="button" onClick={() => setReplayUiPrefs(prev => ({ ...prev, showLabels: !prev.showLabels }))}
style={showLabels ? btnActiveStyle : btnStyle} title="이름"></button>
<button type="button" onClick={() => store.getState().setFocusMode(!focusMode)}
<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={() => store.getState().setShow1hPolygon(!show1hPolygon)}
<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={() => store.getState().setShow6hPolygon(!show6hPolygon)}
<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={() => store.getState().setAbLoop(!abLoop)}
<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>
<span style={{ color: '#64748b', fontSize: 9 }}></span>
<select defaultValue="70"
onChange={e => { onFilterByScore(e.target.value === '' ? null : Number(e.target.value)); }}
style={{ background: 'rgba(15,23,42,0.9)', border: '1px solid rgba(99,179,237,0.3)', borderRadius: 4, color: '#e2e8f0', fontSize: 9, fontFamily: FONT_MONO, padding: '1px 4px', cursor: 'pointer' }}
title="일치율 필터" aria-label="일치율 필터">
<option value=""> (30%+)</option>
<option value="50">50%+</option>
<option value="60">60%+</option>
<option value="70">70%+</option>
<option value="80">80%+</option>
<option value="90">90%+</option>
</select>
<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>

파일 보기

@ -241,11 +241,19 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
useEncMapSettings(maplibreRef, mapMode, encSettings, encSyncEpoch);
const replayLayerRef = useRef<DeckLayer[]>([]);
const fleetClusterLayerRef = useRef<DeckLayer[]>([]);
const fleetMapClickHandlerRef = useRef<((payload: { coordinate: [number, number]; screen: [number, number] }) => void) | null>(null);
const fleetMapMoveHandlerRef = useRef<((payload: { coordinate: [number, number]; screen: [number, number] }) => void) | null>(null);
const requestRenderRef = useRef<(() => void) | null>(null);
const handleFleetDeckLayers = useCallback((layers: DeckLayer[]) => {
fleetClusterLayerRef.current = layers;
requestRenderRef.current?.();
}, []);
const registerFleetMapClickHandler = useCallback((handler: ((payload: { coordinate: [number, number]; screen: [number, number] }) => void) | null) => {
fleetMapClickHandlerRef.current = handler;
}, []);
const registerFleetMapMoveHandler = useCallback((handler: ((payload: { coordinate: [number, number]; screen: [number, number] }) => void) | null) => {
fleetMapMoveHandlerRef.current = handler;
}, []);
const [infra, setInfra] = useState<PowerFacility[]>([]);
const [flyToTarget, setFlyToTarget] = useState<{ lng: number; lat: number; zoom: number } | null>(null);
const [selectedAnalysisMmsi, setSelectedAnalysisMmsi] = useState<string | null>(null);
@ -684,6 +692,24 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
mapStyle={activeMapStyle}
onZoom={handleZoom}
onLoad={handleMapLoad}
onClick={event => {
const handler = fleetMapClickHandlerRef.current;
if (handler) {
handler({
coordinate: [event.lngLat.lng, event.lngLat.lat],
screen: [event.point.x, event.point.y],
});
}
}}
onMouseMove={event => {
const handler = fleetMapMoveHandlerRef.current;
if (handler) {
handler({
coordinate: [event.lngLat.lng, event.lngLat.lat],
screen: [event.point.x, event.point.y],
});
}
}}
>
<NavigationControl position="top-right" />
@ -825,10 +851,13 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
groupPolygons={groupPolygons}
zoomScale={zoomScale}
onDeckLayersChange={handleFleetDeckLayers}
registerMapClickHandler={registerFleetMapClickHandler}
registerMapMoveHandler={registerFleetMapMoveHandler}
onShipSelect={handleAnalysisShipSelect}
onFleetZoom={handleFleetZoom}
onSelectedGearChange={setSelectedGearData}
onSelectedFleetChange={setSelectedFleetData}
autoOpenReviewPanel={koreaFilters.cnFishing}
/>
)}
{vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && !replayFocusMode && (

파일 보기

@ -36,6 +36,9 @@ export interface HoverTooltipState {
lat: number;
type: 'fleet' | 'gear';
id: number | string;
groupKey?: string;
subClusterId?: number;
compositeKey?: string;
}
export interface PickerCandidate {

파일 보기

@ -13,7 +13,10 @@ export interface UseFleetClusterGeoJsonParams {
groupPolygons: UseGroupPolygonsResult | undefined;
analysisMap: Map<string, VesselAnalysisDto>;
hoveredFleetId: number | null;
hoveredGearCompositeKey?: string | null;
visibleGearCompositeKeys?: Set<string> | null;
selectedGearGroup: string | null;
selectedGearCompositeKey?: string | null;
pickerHoveredGroup: string | null;
historyActive: boolean;
correlationData: GearCorrelationItem[];
@ -32,6 +35,7 @@ export interface FleetClusterGeoJsonResult {
memberMarkersGeoJson: GeoJSON;
pickerHighlightGeoJson: GeoJSON;
selectedGearHighlightGeoJson: GeoJSON.FeatureCollection | null;
hoveredGearHighlightGeoJson: GeoJSON.FeatureCollection | null;
// correlation GeoJSON
correlationVesselGeoJson: GeoJSON;
correlationTrailGeoJson: GeoJSON;
@ -74,7 +78,10 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
shipMap,
groupPolygons,
hoveredFleetId,
hoveredGearCompositeKey = null,
visibleGearCompositeKeys = null,
selectedGearGroup,
selectedGearCompositeKey = null,
pickerHoveredGroup,
historyActive,
correlationData,
@ -195,10 +202,15 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
if (!groupPolygons) return { type: 'FeatureCollection', features };
for (const g of groupPolygons.allGroups.filter(x => x.groupType !== 'FLEET')) {
if (!g.polygon) continue;
const compositeKey = `${g.groupKey}:${g.subClusterId ?? 0}`;
if (visibleGearCompositeKeys && !visibleGearCompositeKeys.has(compositeKey)) continue;
features.push({
type: 'Feature',
properties: {
name: g.groupKey,
groupKey: g.groupKey,
subClusterId: g.subClusterId ?? 0,
compositeKey,
gearCount: g.memberCount,
inZone: g.groupType === 'GEAR_IN_ZONE' ? 1 : 0,
},
@ -206,7 +218,7 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
});
}
return { type: 'FeatureCollection', features };
}, [groupPolygons]);
}, [groupPolygons, visibleGearCompositeKeys]);
// 가상 선박 마커 GeoJSON (API members + shipMap heading 보정)
const memberMarkersGeoJson = useMemo((): GeoJSON => {
@ -248,10 +260,12 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
}
for (const g of [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]) {
const color = g.groupType === 'GEAR_IN_ZONE' ? '#dc2626' : '#f97316';
const compositeKey = `${g.groupKey}:${g.subClusterId ?? 0}`;
if (visibleGearCompositeKeys && !visibleGearCompositeKeys.has(compositeKey)) continue;
for (const m of g.members) addMember(m, g.groupKey, g.groupType, color);
}
return { type: 'FeatureCollection', features };
}, [groupPolygons, shipMap]);
}, [groupPolygons, shipMap, visibleGearCompositeKeys]);
// picker 호버 하이라이트 (선단 + 어구 통합)
const pickerHighlightGeoJson = useMemo((): GeoJSON => {
@ -270,17 +284,47 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
const allGroups = groupPolygons
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
: [];
const matches = allGroups.filter(g => g.groupKey === selectedGearGroup && g.polygon);
const matches = allGroups.filter(g => {
if (!g.polygon || g.groupKey !== selectedGearGroup) return false;
const compositeKey = `${g.groupKey}:${g.subClusterId ?? 0}`;
if (selectedGearCompositeKey && compositeKey !== selectedGearCompositeKey) return false;
if (visibleGearCompositeKeys && !visibleGearCompositeKeys.has(compositeKey)) return false;
return true;
});
if (matches.length === 0) return null;
return {
type: 'FeatureCollection',
features: matches.map(g => ({
type: 'Feature' as const,
properties: { subClusterId: g.subClusterId },
properties: {
subClusterId: g.subClusterId,
compositeKey: `${g.groupKey}:${g.subClusterId ?? 0}`,
},
geometry: g.polygon!,
})),
};
}, [selectedGearGroup, enabledModels, historyActive, groupPolygons]);
}, [selectedGearGroup, selectedGearCompositeKey, enabledModels, historyActive, groupPolygons, visibleGearCompositeKeys]);
const hoveredGearHighlightGeoJson = useMemo((): GeoJSON.FeatureCollection | null => {
if (!hoveredGearCompositeKey || !groupPolygons) return null;
const group = groupPolygons.allGroups.find(
item => item.groupType !== 'FLEET' && `${item.groupKey}:${item.subClusterId ?? 0}` === hoveredGearCompositeKey && item.polygon,
);
if (!group?.polygon) return null;
return {
type: 'FeatureCollection',
features: [{
type: 'Feature',
properties: {
groupKey: group.groupKey,
subClusterId: group.subClusterId ?? 0,
compositeKey: hoveredGearCompositeKey,
inZone: group.groupType === 'GEAR_IN_ZONE' ? 1 : 0,
},
geometry: group.polygon,
}],
};
}, [groupPolygons, hoveredGearCompositeKey]);
// ── 연관 대상 마커 (ships[] fallback) ──
const correlationVesselGeoJson = useMemo((): GeoJSON => {
@ -416,6 +460,7 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
memberMarkersGeoJson,
pickerHighlightGeoJson,
selectedGearHighlightGeoJson,
hoveredGearHighlightGeoJson,
correlationVesselGeoJson,
correlationTrailGeoJson,
modelBadgesGeoJson,

파일 보기

@ -12,7 +12,7 @@ import { clusterLabels } from '../utils/labelCluster';
export interface FleetClusterDeckConfig {
selectedGearGroup: string | null;
hoveredMmsi: string | null;
hoveredGearGroup: string | null; // gear polygon hover highlight
hoveredGearCompositeKey: string | null;
enabledModels: Set<string>;
historyActive: boolean;
hasCorrelationTracks: boolean;
@ -21,13 +21,24 @@ export interface FleetClusterDeckConfig {
fontScale?: number; // fontScale.analysis (default 1)
focusMode?: boolean; // 집중 모드 — 라이브 폴리곤/마커 숨김
onPolygonClick?: (features: PickedPolygonFeature[], coordinate: [number, number]) => void;
onPolygonHover?: (info: { lng: number; lat: number; type: 'fleet' | 'gear'; id: string | number } | null) => void;
onPolygonHover?: (info: {
lng: number;
lat: number;
type: 'fleet' | 'gear';
id: string | number;
groupKey?: string;
subClusterId?: number;
compositeKey?: string;
} | null) => void;
}
export interface PickedPolygonFeature {
type: 'fleet' | 'gear';
clusterId?: number;
name?: string;
groupKey?: string;
subClusterId?: number;
compositeKey?: string;
gearCount?: number;
inZone?: boolean;
}
@ -112,6 +123,9 @@ function findPolygonsAtPoint(
results.push({
type: 'gear',
name: f.properties?.name,
groupKey: f.properties?.groupKey,
subClusterId: f.properties?.subClusterId,
compositeKey: f.properties?.compositeKey,
gearCount: f.properties?.gearCount,
inZone: f.properties?.inZone === 1,
});
@ -136,7 +150,7 @@ export function useFleetClusterDeckLayers(
const {
selectedGearGroup,
hoveredMmsi,
hoveredGearGroup,
hoveredGearCompositeKey,
enabledModels,
historyActive,
zoomScale,
@ -243,7 +257,15 @@ export function useFleetClusterDeckLayers(
const f = info.object as GeoJSON.Feature;
const name = f.properties?.name;
if (name) {
onPolygonHover?.({ lng: info.coordinate![0], lat: info.coordinate![1], type: 'gear', id: name });
onPolygonHover?.({
lng: info.coordinate![0],
lat: info.coordinate![1],
type: 'gear',
id: f.properties?.groupKey ?? name,
groupKey: f.properties?.groupKey ?? name,
subClusterId: f.properties?.subClusterId,
compositeKey: f.properties?.compositeKey,
});
}
} else {
onPolygonHover?.(null);
@ -258,16 +280,12 @@ export function useFleetClusterDeckLayers(
}
// ── 4b. Gear hover highlight ──────────────────────────────────────────
if (hoveredGearGroup && gearFc.features.length > 0) {
const hoveredGearFeatures = gearFc.features.filter(
f => f.properties?.name === hoveredGearGroup,
);
if (hoveredGearFeatures.length > 0) {
if (hoveredGearCompositeKey && geo.hoveredGearHighlightGeoJson && geo.hoveredGearHighlightGeoJson.features.length > 0) {
layers.push(new GeoJsonLayer({
id: 'gear-hover-highlight',
data: { type: 'FeatureCollection' as const, features: hoveredGearFeatures },
data: geo.hoveredGearHighlightGeoJson,
getFillColor: (f: GeoJSON.Feature) =>
f.properties?.inZone === 1 ? [220, 38, 38, 64] : [249, 115, 22, 64],
f.properties?.inZone === 1 ? [220, 38, 38, 72] : [249, 115, 22, 72],
getLineColor: (f: GeoJSON.Feature) =>
f.properties?.inZone === 1 ? [220, 38, 38, 255] : [249, 115, 22, 255],
getLineWidth: 2.5,
@ -277,7 +295,6 @@ export function useFleetClusterDeckLayers(
pickable: false,
}));
}
}
// ── 5. Selected gear highlight (selectedGearHighlightGeoJson) ────────────
if (geo.selectedGearHighlightGeoJson && geo.selectedGearHighlightGeoJson.features.length > 0) {
@ -539,7 +556,7 @@ export function useFleetClusterDeckLayers(
geo,
selectedGearGroup,
hoveredMmsi,
hoveredGearGroup,
hoveredGearCompositeKey,
enabledModels,
historyActive,
zoomScale,

파일 보기

@ -6,6 +6,7 @@ import { useGearReplayStore } from '../stores/gearReplayStore';
import { findFrameAtTime, interpolateMemberPositions, interpolateSubFrameMembers } from '../stores/gearReplayPreprocess';
import type { MemberPosition } from '../stores/gearReplayPreprocess';
import { MODEL_ORDER, MODEL_COLORS } from '../components/korea/fleetClusterConstants';
import { getParentReviewCandidateColor } from '../components/korea/parentReviewCandidateColors';
import { buildInterpPolygon } from '../components/korea/fleetClusterUtils';
import type { GearCorrelationItem } from '../services/vesselAnalysis';
import { SHIP_ICON_MAPPING } from '../utils/shipIconSvg';
@ -41,6 +42,80 @@ interface CorrPosition {
isVessel: boolean;
}
interface TripDatumLike {
id: string;
path: [number, number][];
timestamps: number[];
color: [number, number, number, number];
}
function interpolateTripPosition(
trip: TripDatumLike,
relTime: number,
): { lon: number; lat: number; cog: number } | null {
const ts = trip.timestamps;
const path = trip.path;
if (path.length === 0 || ts.length === 0) return null;
if (relTime < ts[0] || relTime > ts[ts.length - 1]) return null;
if (path.length === 1 || ts.length === 1) {
return { lon: path[0][0], lat: path[0][1], cog: 0 };
}
if (relTime <= ts[0]) {
const dx = path[1][0] - path[0][0];
const dy = path[1][1] - path[0][1];
return {
lon: path[0][0],
lat: path[0][1],
cog: (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360,
};
}
if (relTime >= ts[ts.length - 1]) {
const last = path.length - 1;
const dx = path[last][0] - path[last - 1][0];
const dy = path[last][1] - path[last - 1][1];
return {
lon: path[last][0],
lat: path[last][1],
cog: (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360,
};
}
let lo = 0;
let hi = ts.length - 1;
while (lo < hi - 1) {
const mid = (lo + hi) >> 1;
if (ts[mid] <= relTime) lo = mid;
else hi = mid;
}
const ratio = ts[hi] !== ts[lo] ? (relTime - ts[lo]) / (ts[hi] - ts[lo]) : 0;
const dx = path[hi][0] - path[lo][0];
const dy = path[hi][1] - path[lo][1];
return {
lon: path[lo][0] + dx * ratio,
lat: path[lo][1] + (path[hi][1] - path[lo][1]) * ratio,
cog: (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360,
};
}
function clipTripPathToTime(trip: TripDatumLike, relTime: number): [number, number][] {
const ts = trip.timestamps;
if (trip.path.length < 2 || ts.length < 2) return [];
if (relTime < ts[0]) return [];
if (relTime >= ts[ts.length - 1]) return trip.path;
let hi = ts.findIndex(value => value > relTime);
if (hi <= 0) hi = 1;
const clipped = trip.path.slice(0, hi);
const interpolated = interpolateTripPosition(trip, relTime);
if (interpolated) {
clipped.push([interpolated.lon, interpolated.lat]);
}
return clipped;
}
// ── Hook ──────────────────────────────────────────────────────────────────────
/**
@ -67,8 +142,8 @@ export function useGearReplayLayers(
const enabledModels = useGearReplayStore(s => s.enabledModels);
const enabledVessels = useGearReplayStore(s => s.enabledVessels);
const hoveredMmsi = useGearReplayStore(s => s.hoveredMmsi);
const reviewCandidates = useGearReplayStore(s => s.reviewCandidates);
const correlationByModel = useGearReplayStore(s => s.correlationByModel);
const modelCenterTrails = useGearReplayStore(s => s.modelCenterTrails);
const showTrails = useGearReplayStore(s => s.showTrails);
const showLabels = useGearReplayStore(s => s.showLabels);
const show1hPolygon = useGearReplayStore(s => s.show1hPolygon);
@ -217,6 +292,11 @@ export function useGearReplayLayers(
// Member positions (interpolated) — 항상 계산 (배지/오퍼레이셔널에서 사용)
const members = interpolateMemberPositions(state.historyFrames, frameIdx, ct);
const memberPts: [number, number][] = members.map(m => [m.lon, m.lat]);
const relTime = ct - st;
const visibleMemberMmsis = new Set(members.map(m => m.mmsi));
const reviewCandidateMap = new Map(reviewCandidates.map(candidate => [candidate.mmsi, candidate]));
const reviewCandidateSet = new Set(reviewCandidateMap.keys());
const corrTrackMap = new Map(correlationTripsData.map(d => [d.id, d]));
// 서브클러스터 프레임 (identity 폴리곤 + operational 폴리곤에서 공유)
const subFrames = frame.subFrames ?? [{ subClusterId: 0, centerLon: frame.centerLon, centerLat: frame.centerLat, members: frame.members, memberCount: frame.memberCount }];
@ -226,7 +306,7 @@ export function useGearReplayLayers(
// 멤버 전체 항적 (identity — 항상 ON)
if (memberTripsData.length > 0) {
for (const trip of memberTripsData) {
if (trip.path.length < 2) continue;
if (!visibleMemberMmsis.has(trip.id) || trip.path.length < 2) continue;
layers.push(new PathLayer({
id: `replay-member-path-${trip.id}`,
data: [{ path: trip.path }],
@ -236,6 +316,7 @@ export function useGearReplayLayers(
}));
}
}
// 연관 선박 전체 항적 (correlation)
if (correlationTripsData.length > 0) {
const activeMmsis = new Set<string>();
@ -246,7 +327,7 @@ export function useGearReplayLayers(
}
}
for (const trip of correlationTripsData) {
if (!activeMmsis.has(trip.id) || trip.path.length < 2) continue;
if (!activeMmsis.has(trip.id) || reviewCandidateSet.has(trip.id) || trip.path.length < 2) continue;
layers.push(new PathLayer({
id: `replay-corr-path-${trip.id}`,
data: [{ path: trip.path }],
@ -256,30 +337,28 @@ export function useGearReplayLayers(
}));
}
}
}
// 1. Correlation TripsLayer (GPU animated, 항상 ON, 고채도)
if (correlationTripsData.length > 0) {
const activeMmsis = new Set<string>();
for (const [mn, items] of correlationByModel) {
if (!enabledModels.has(mn)) continue;
for (const c of items as GearCorrelationItem[]) {
if (enabledVessels.has(c.targetMmsi)) activeMmsis.add(c.targetMmsi);
}
}
const enabledTrips = correlationTripsData.filter(d => activeMmsis.has(d.id));
if (enabledTrips.length > 0) {
layers.push(new TripsLayer({
id: 'replay-corr-trails',
data: enabledTrips,
getPath: d => d.path,
getTimestamps: d => d.timestamps,
getColor: [100, 180, 255, 220], // 고채도 파랑 (항적보다 밝게)
widthMinPixels: 2.5,
fadeTrail: true,
trailLength: TRAIL_LENGTH_MS,
currentTime: ct - st,
if (reviewCandidates.length > 0) {
for (const candidate of reviewCandidates) {
const trip = corrTrackMap.get(candidate.mmsi);
if (!trip || trip.path.length < 2) continue;
const [r, g, b] = hexToRgb(getParentReviewCandidateColor(candidate.rank));
const hovered = hoveredMmsi === candidate.mmsi;
layers.push(new PathLayer({
id: `replay-review-path-glow-${candidate.mmsi}`,
data: [{ path: trip.path }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: hovered ? [255, 255, 255, 110] : [255, 255, 255, 45],
widthMinPixels: hovered ? 7 : 4,
}));
layers.push(new PathLayer({
id: `replay-review-path-${candidate.mmsi}`,
data: [{ path: trip.path }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [r, g, b, hovered ? 230 : 160],
widthMinPixels: hovered ? 4.5 : 2.5,
}));
}
}
}
@ -329,11 +408,10 @@ export function useGearReplayLayers(
}
}
// 6. Correlation vessel positions (트랙 보간 → 끝점 clamp → live fallback)
// 6. Correlation vessel positions (현재 리플레이 시점에 실제로 보이는 대상만)
const corrPositions: CorrPosition[] = [];
const corrTrackMap = new Map(correlationTripsData.map(d => [d.id, d]));
const liveShips = shipsRef.current;
const relTime = ct - st;
const reviewPositions: CorrPosition[] = [];
void shipsRef;
for (const [mn, items] of correlationByModel) {
if (!enabledModels.has(mn)) continue;
@ -342,80 +420,44 @@ export function useGearReplayLayers(
for (const c of items as GearCorrelationItem[]) {
if (!enabledVessels.has(c.targetMmsi)) continue; // OFF → 아이콘+트레일+폴리곤 모두 제외
if (reviewCandidateSet.has(c.targetMmsi)) continue;
if (corrPositions.some(p => p.mmsi === c.targetMmsi)) continue;
let lon: number | undefined;
let lat: number | undefined;
let cog = 0;
// 방법 1: 트랙 데이터 (보간 + 범위 밖은 끝점 clamp)
const tripData = corrTrackMap.get(c.targetMmsi);
if (tripData && tripData.path.length > 0) {
const ts = tripData.timestamps;
const path = tripData.path;
if (relTime <= ts[0]) {
// 트랙 시작 전 → 첫 점 사용
lon = path[0][0]; lat = path[0][1];
if (path.length > 1) {
const dx = path[1][0] - path[0][0];
const dy = path[1][1] - path[0][1];
cog = (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360;
}
} else if (relTime >= ts[ts.length - 1]) {
// 트랙 종료 후 → 마지막 점 사용
const last = path.length - 1;
lon = path[last][0]; lat = path[last][1];
if (last > 0) {
const dx = path[last][0] - path[last - 1][0];
const dy = path[last][1] - path[last - 1][1];
cog = (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360;
}
} else {
// 범위 내 → 보간
let lo = 0;
let hi = ts.length - 1;
while (lo < hi - 1) {
const mid = (lo + hi) >> 1;
if (ts[mid] <= relTime) lo = mid; else hi = mid;
}
const ratio = ts[hi] !== ts[lo] ? (relTime - ts[lo]) / (ts[hi] - ts[lo]) : 0;
lon = path[lo][0] + (path[hi][0] - path[lo][0]) * ratio;
lat = path[lo][1] + (path[hi][1] - path[lo][1]) * ratio;
const dx = path[hi][0] - path[lo][0];
const dy = path[hi][1] - path[lo][1];
cog = (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360;
}
}
// 방법 2: live 선박 위치 fallback
if (lon === undefined) {
const ship = liveShips.get(c.targetMmsi);
if (ship) {
lon = ship.lng;
lat = ship.lat;
cog = ship.course ?? 0;
}
}
if (lon === undefined || lat === undefined) continue;
const position = tripData ? interpolateTripPosition(tripData, relTime) : null;
if (!position) continue;
corrPositions.push({
mmsi: c.targetMmsi,
name: c.targetName || c.targetMmsi,
lon,
lat,
cog,
lon: position.lon,
lat: position.lat,
cog: position.cog,
color: [r, g, b, 230],
isVessel: c.targetType === 'VESSEL',
});
}
}
for (const candidate of reviewCandidates) {
const tripData = corrTrackMap.get(candidate.mmsi);
const position = tripData ? interpolateTripPosition(tripData, relTime) : null;
if (!position) continue;
const [r, g, b] = hexToRgb(getParentReviewCandidateColor(candidate.rank));
reviewPositions.push({
mmsi: candidate.mmsi,
name: candidate.name,
lon: position.lon,
lat: position.lat,
cog: position.cog,
color: [r, g, b, hoveredMmsi === candidate.mmsi ? 255 : 235],
isVessel: true,
});
}
// 디버그: 첫 프레임에서 전체 상태 출력
if (shouldLog) {
const trackHit = corrPositions.filter(p => corrTrackMap.has(p.mmsi)).length;
const liveHit = corrPositions.length - trackHit;
const sampleTrip = memberTripsData[0];
console.log('[GearReplay] renderFrame:', {
historyFrames: state.historyFrames.length,
@ -427,7 +469,8 @@ export function useGearReplayLayers(
currentTime: Math.round((ct - st) / 60000) + 'min (rel)',
members: members.length,
corrPositions: corrPositions.length,
posSource: `track:${trackHit} live:${liveHit}`,
reviewPositions: reviewPositions.length,
posSource: `track:${trackHit}`,
memberTrip0: sampleTrip ? { id: sampleTrip.id, pts: sampleTrip.path.length, tsRange: `${Math.round(sampleTrip.timestamps[0]/60000)}~${Math.round(sampleTrip.timestamps[sampleTrip.timestamps.length-1]/60000)}min` } : 'none',
});
// 모델별 상세
@ -440,6 +483,73 @@ export function useGearReplayLayers(
}
}
const visibleCorrMmsis = new Set(corrPositions.map(position => position.mmsi));
const visibleReviewMmsis = new Set(reviewPositions.map(position => position.mmsi));
const visibleMemberTrips = memberTripsData.filter(d => visibleMemberMmsis.has(d.id));
const enabledCorrTrips = correlationTripsData.filter(d => visibleCorrMmsis.has(d.id) && !reviewCandidateSet.has(d.id));
const reviewVisibleTrips = correlationTripsData
.filter(d => visibleReviewMmsis.has(d.id))
.map(d => {
const candidate = reviewCandidateMap.get(d.id);
const [r, g, b] = hexToRgb(getParentReviewCandidateColor(candidate?.rank ?? 1));
return { ...d, color: [r, g, b, hoveredMmsi === d.id ? 255 : 230] as [number, number, number, number] };
});
const hoveredReviewTrips = reviewVisibleTrips.filter(d => d.id === hoveredMmsi);
const defaultReviewTrips = reviewVisibleTrips.filter(d => d.id !== hoveredMmsi);
if (enabledCorrTrips.length > 0) {
layers.push(new TripsLayer({
id: 'replay-corr-trails',
data: enabledCorrTrips,
getPath: d => d.path,
getTimestamps: d => d.timestamps,
getColor: [100, 180, 255, 220],
widthMinPixels: 2.5,
fadeTrail: true,
trailLength: TRAIL_LENGTH_MS,
currentTime: ct - st,
}));
}
if (defaultReviewTrips.length > 0) {
layers.push(new TripsLayer({
id: 'replay-review-trails',
data: defaultReviewTrips,
getPath: d => d.path,
getTimestamps: d => d.timestamps,
getColor: d => d.color,
widthMinPixels: 4,
fadeTrail: true,
trailLength: TRAIL_LENGTH_MS,
currentTime: ct - st,
}));
}
if (hoveredReviewTrips.length > 0) {
layers.push(new TripsLayer({
id: 'replay-review-hover-trails-glow',
data: hoveredReviewTrips,
getPath: d => d.path,
getTimestamps: d => d.timestamps,
getColor: [255, 255, 255, 190],
widthMinPixels: 7,
fadeTrail: true,
trailLength: TRAIL_LENGTH_MS,
currentTime: ct - st,
}));
layers.push(new TripsLayer({
id: 'replay-review-hover-trails',
data: hoveredReviewTrips,
getPath: d => d.path,
getTimestamps: d => d.timestamps,
getColor: d => d.color,
widthMinPixels: 5,
fadeTrail: true,
trailLength: TRAIL_LENGTH_MS,
currentTime: ct - st,
}));
}
if (corrPositions.length > 0) {
layers.push(new IconLayer<CorrPosition>({
id: 'replay-corr-vessels',
@ -470,10 +580,57 @@ export function useGearReplayLayers(
}
}
if (reviewPositions.length > 0) {
layers.push(new ScatterplotLayer({
id: 'replay-review-vessel-glow',
data: reviewPositions,
getPosition: d => [d.lon, d.lat],
getFillColor: d => {
const alpha = hoveredMmsi === d.mmsi ? 90 : 40;
return [255, 255, 255, alpha] as [number, number, number, number];
},
getRadius: d => hoveredMmsi === d.mmsi ? 420 : 260,
radiusUnits: 'meters',
radiusMinPixels: 10,
}));
layers.push(new IconLayer<CorrPosition>({
id: 'replay-review-vessels',
data: reviewPositions,
getPosition: d => [d.lon, d.lat],
getIcon: () => SHIP_ICON_MAPPING['ship-triangle'],
getSize: d => hoveredMmsi === d.mmsi ? 24 : 20,
getAngle: d => -(d.cog || 0),
getColor: d => d.color,
sizeUnits: 'pixels',
billboard: false,
}));
if (showLabels) {
const clusteredReview = clusterLabels(reviewPositions, d => [d.lon, d.lat], zoomLevel);
layers.push(new TextLayer<CorrPosition>({
id: 'replay-review-labels',
data: clusteredReview,
getPosition: d => [d.lon, d.lat],
getText: d => {
const candidate = reviewCandidateMap.get(d.mmsi);
return candidate ? `#${candidate.rank} ${d.name}` : d.name;
},
getColor: d => d.color,
getSize: d => hoveredMmsi === d.mmsi ? 10 * fs : 9 * fs,
getPixelOffset: [0, 17],
background: true,
getBackgroundColor: [0, 0, 0, 215],
backgroundPadding: [3, 2],
}));
}
}
// 7. Hover highlight
if (hoveredMmsi) {
const hoveredMember = members.find(m => m.mmsi === hoveredMmsi);
const hoveredCorr = corrPositions.find(c => c.mmsi === hoveredMmsi);
const hoveredCorr = corrPositions.find(c => c.mmsi === hoveredMmsi)
?? reviewPositions.find(c => c.mmsi === hoveredMmsi);
const hoveredPos: [number, number] | null = hoveredMember
? [hoveredMember.lon, hoveredMember.lat]
: hoveredCorr
@ -506,16 +663,8 @@ export function useGearReplayLayers(
// Hover trail (from correlation track)
const hoveredTrack = correlationTripsData.find(d => d.id === hoveredMmsi);
if (hoveredTrack) {
const relTime = ct - st;
let clipIdx = hoveredTrack.timestamps.length;
for (let i = 0; i < hoveredTrack.timestamps.length; i++) {
if (hoveredTrack.timestamps[i] > relTime) {
clipIdx = i;
break;
}
}
const clippedPath = hoveredTrack.path.slice(0, clipIdx);
if (hoveredTrack && !reviewCandidateSet.has(hoveredMmsi) && (visibleCorrMmsis.has(hoveredMmsi) || visibleMemberMmsis.has(hoveredMmsi))) {
const clippedPath = clipTripPathToTime(hoveredTrack, relTime);
if (clippedPath.length >= 2) {
layers.push(new PathLayer({
id: 'replay-hover-trail',
@ -537,6 +686,9 @@ export function useGearReplayLayers(
for (const c of corrPositions) {
if (state.pinnedMmsis.has(c.mmsi)) pinnedPositions.push({ position: [c.lon, c.lat] });
}
for (const c of reviewPositions) {
if (state.pinnedMmsis.has(c.mmsi)) pinnedPositions.push({ position: [c.lon, c.lat] });
}
if (pinnedPositions.length > 0) {
// glow
layers.push(new ScatterplotLayer({
@ -566,12 +718,8 @@ export function useGearReplayLayers(
// pinned trails (correlation tracks)
const relTime = ct - st;
for (const trip of correlationTripsData) {
if (!state.pinnedMmsis.has(trip.id)) continue;
let clipIdx = trip.timestamps.length;
for (let i = 0; i < trip.timestamps.length; i++) {
if (trip.timestamps[i] > relTime) { clipIdx = i; break; }
}
const clippedPath = trip.path.slice(0, clipIdx);
if (!state.pinnedMmsis.has(trip.id) || !visibleCorrMmsis.has(trip.id)) continue;
const clippedPath = clipTripPathToTime(trip, relTime);
if (clippedPath.length >= 2) {
layers.push(new PathLayer({
id: `replay-pinned-trail-${trip.id}`,
@ -583,14 +731,24 @@ export function useGearReplayLayers(
}
}
for (const trip of reviewVisibleTrips) {
if (!state.pinnedMmsis.has(trip.id)) continue;
const clippedPath = clipTripPathToTime(trip, relTime);
if (clippedPath.length >= 2) {
layers.push(new PathLayer({
id: `replay-pinned-review-trail-${trip.id}`,
data: [{ path: clippedPath }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: trip.color,
widthMinPixels: 3.5,
}));
}
}
// pinned member trails (identity tracks)
for (const trip of memberTripsData) {
if (!state.pinnedMmsis.has(trip.id)) continue;
let clipIdx = trip.timestamps.length;
for (let i = 0; i < trip.timestamps.length; i++) {
if (trip.timestamps[i] > relTime) { clipIdx = i; break; }
}
const clippedPath = trip.path.slice(0, clipIdx);
if (!state.pinnedMmsis.has(trip.id) || !visibleMemberMmsis.has(trip.id)) continue;
const clippedPath = clipTripPathToTime(trip, relTime);
if (clippedPath.length >= 2) {
layers.push(new PathLayer({
id: `replay-pinned-mtrail-${trip.id}`,
@ -642,62 +800,6 @@ export function useGearReplayLayers(
}
}
// 8.5. Model center trails + current center point (모델×서브클러스터별 중심 경로)
for (const trail of modelCenterTrails) {
if (!enabledModels.has(trail.modelName)) continue;
if (trail.path.length < 2) continue;
const color = MODEL_COLORS[trail.modelName] ?? '#94a3b8';
const [r, g, b] = hexToRgb(color);
// 중심 경로 (PathLayer, 연한 모델 색상)
layers.push(new PathLayer({
id: `replay-model-trail-${trail.modelName}-s${trail.subClusterId}`,
data: [{ path: trail.path }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [r, g, b, 100],
widthMinPixels: 1.5,
}));
// 현재 중심점 (보간)
const ts = trail.timestamps;
if (ts.length > 0 && relTime >= ts[0] && relTime <= ts[ts.length - 1]) {
let lo = 0, hi = ts.length - 1;
while (lo < hi - 1) { const mid = (lo + hi) >> 1; if (ts[mid] <= relTime) lo = mid; else hi = mid; }
const ratio = ts[hi] !== ts[lo] ? (relTime - ts[lo]) / (ts[hi] - ts[lo]) : 0;
const cx = trail.path[lo][0] + (trail.path[hi][0] - trail.path[lo][0]) * ratio;
const cy = trail.path[lo][1] + (trail.path[hi][1] - trail.path[lo][1]) * ratio;
const centerData = [{ position: [cx, cy] as [number, number] }];
layers.push(new ScatterplotLayer({
id: `replay-model-center-${trail.modelName}-s${trail.subClusterId}`,
data: centerData,
getPosition: (d: { position: [number, number] }) => d.position,
getFillColor: [r, g, b, 255],
getRadius: 150,
radiusUnits: 'meters',
radiusMinPixels: 5,
stroked: true,
getLineColor: [255, 255, 255, 200],
lineWidthMinPixels: 1.5,
}));
if (showLabels) {
layers.push(new TextLayer({
id: `replay-model-center-label-${trail.modelName}-s${trail.subClusterId}`,
data: centerData,
getPosition: (d: { position: [number, number] }) => d.position,
getText: () => trail.modelName,
getColor: [r, g, b, 255],
getSize: 9 * fs,
getPixelOffset: [0, -12],
background: true,
getBackgroundColor: [0, 0, 0, 200],
backgroundPadding: [3, 1],
fontFamily: '"Fira Code Variable", monospace',
}));
}
}
}
// 9. Model badges (small colored dots next to each vessel/gear per model)
{
const badgeTargets = new Map<string, { lon: number; lat: number; models: Set<string> }>();
@ -797,10 +899,10 @@ export function useGearReplayLayers(
}
// TripsLayer (멤버 트레일)
if (memberTripsData.length > 0) {
if (visibleMemberTrips.length > 0) {
layers.push(new TripsLayer({
id: 'replay-identity-trails',
data: memberTripsData,
data: visibleMemberTrips,
getPath: d => d.path,
getTimestamps: d => d.timestamps,
getColor: [255, 200, 60, 220],
@ -848,6 +950,8 @@ export function useGearReplayLayers(
const frame6h = state.historyFrames6h[frameIdx6h];
const subFrames6h = frame6h.subFrames ?? [{ subClusterId: 0, centerLon: frame6h.centerLon, centerLat: frame6h.centerLat, members: frame6h.members, memberCount: frame6h.memberCount }];
const members6h = interpolateMemberPositions(state.historyFrames6h, frameIdx6h, ct);
const visibleMemberMmsis6h = new Set(members6h.map(member => member.mmsi));
const visibleMemberTrips6h = memberTripsData6h.filter(trip => visibleMemberMmsis6h.has(trip.id));
// 6h 폴리곤
for (const sf of subFrames6h) {
@ -908,10 +1012,10 @@ export function useGearReplayLayers(
}
// 6h TripsLayer (항적 애니메이션)
if (memberTripsData6h.length > 0) {
if (visibleMemberTrips6h.length > 0) {
layers.push(new TripsLayer({
id: 'replay-6h-identity-trails',
data: memberTripsData6h,
data: visibleMemberTrips6h,
getPath: d => d.path,
getTimestamps: d => d.timestamps,
getColor: [147, 197, 253, 180] as [number, number, number, number],
@ -957,7 +1061,7 @@ export function useGearReplayLayers(
centerTrailSegments, centerDotsPositions,
centerTrailSegments6h, centerDotsPositions6h, subClusterCenters6h,
enabledModels, enabledVessels, hoveredMmsi, correlationByModel,
modelCenterTrails, subClusterCenters, showTrails, showLabels,
reviewCandidates, subClusterCenters, showTrails, showLabels,
show1hPolygon, show6hPolygon, pinnedMmsis, fs, zoomLevel,
replayLayerRef, requestRender,
]);

파일 보기

@ -37,8 +37,18 @@ export interface CenterTrailSegment {
isInterpolated: boolean;
}
export interface ReplayReviewCandidate {
mmsi: string;
name: string;
rank: number;
score: number | null;
trackAvailable: boolean;
subClusterId: number;
}
// ── Speed factor: 1x = 30 real seconds covers 12 timeline hours ──
const SPEED_FACTOR = (12 * 60 * 60 * 1000) / (30 * 1000); // 1440
const DEFAULT_AB_RANGE_MS = 2 * 60 * 60 * 1000;
// ── Module-level rAF state (outside React) ───────────────────────
let animationFrameId: number | null = null;
@ -52,6 +62,8 @@ interface GearReplayState {
currentTime: number;
startTime: number;
endTime: number;
dataStartTime: number;
dataEndTime: number;
playbackSpeed: number;
// Source data (1h = primary identity polygon)
@ -84,6 +96,7 @@ interface GearReplayState {
enabledModels: Set<string>;
enabledVessels: Set<string>;
hoveredMmsi: string | null;
reviewCandidates: ReplayReviewCandidate[];
correlationByModel: Map<string, GearCorrelationItem[]>;
showTrails: boolean;
showLabels: boolean;
@ -111,6 +124,7 @@ interface GearReplayState {
setEnabledModels: (models: Set<string>) => void;
setEnabledVessels: (vessels: Set<string>) => void;
setHoveredMmsi: (mmsi: string | null) => void;
setReviewCandidates: (candidates: ReplayReviewCandidate[]) => void;
setShowTrails: (show: boolean) => void;
setShowLabels: (show: boolean) => void;
setFocusMode: (focus: boolean) => void;
@ -169,6 +183,8 @@ export const useGearReplayStore = create<GearReplayState>()(
currentTime: 0,
startTime: 0,
endTime: 0,
dataStartTime: 0,
dataEndTime: 0,
playbackSpeed: 1,
// Source data
@ -198,6 +214,7 @@ export const useGearReplayStore = create<GearReplayState>()(
enabledModels: new Set<string>(),
enabledVessels: new Set<string>(),
hoveredMmsi: null,
reviewCandidates: [],
showTrails: true,
showLabels: true,
focusMode: false,
@ -216,6 +233,52 @@ export const useGearReplayStore = create<GearReplayState>()(
const endTime = Date.now();
const frameTimes = frames.map(f => new Date(f.snapshotTime).getTime());
const frameTimes6h = (frames6h ?? []).map(f => new Date(f.snapshotTime).getTime());
let primaryDataStartTime = Number.POSITIVE_INFINITY;
let primaryDataEndTime = 0;
let fallbackDataStartTime = Number.POSITIVE_INFINITY;
let fallbackDataEndTime = 0;
const pushPrimaryTime = (value: number) => {
if (!Number.isFinite(value) || value <= 0) return;
primaryDataStartTime = Math.min(primaryDataStartTime, value);
primaryDataEndTime = Math.max(primaryDataEndTime, value);
};
const pushFallbackTime = (value: number) => {
if (!Number.isFinite(value) || value <= 0) return;
fallbackDataStartTime = Math.min(fallbackDataStartTime, value);
fallbackDataEndTime = Math.max(fallbackDataEndTime, value);
};
frameTimes.forEach(pushPrimaryTime);
frameTimes6h.forEach(pushPrimaryTime);
for (const track of corrTracks) {
for (const point of track.track) {
pushFallbackTime(point.ts);
}
}
let dataStartTime = Number.isFinite(primaryDataStartTime)
? primaryDataStartTime
: fallbackDataStartTime;
let dataEndTime = Number.isFinite(primaryDataStartTime)
? primaryDataEndTime
: fallbackDataEndTime;
if (!Number.isFinite(dataStartTime) || dataStartTime <= 0) {
dataStartTime = startTime;
dataEndTime = endTime;
} else if (dataEndTime <= dataStartTime) {
const paddedStart = Math.max(startTime, dataStartTime - DEFAULT_AB_RANGE_MS / 2);
const paddedEnd = Math.min(endTime, dataStartTime + DEFAULT_AB_RANGE_MS / 2);
if (paddedEnd > paddedStart) {
dataStartTime = paddedStart;
dataEndTime = paddedEnd;
} else {
dataStartTime = startTime;
dataEndTime = endTime;
}
}
const memberTrips = buildMemberTripsData(frames, startTime);
const corrTrips = buildCorrelationTripsData(corrTracks, startTime);
@ -248,6 +311,8 @@ export const useGearReplayStore = create<GearReplayState>()(
snapshotRanges6h: ranges6h,
startTime,
endTime,
dataStartTime,
dataEndTime,
currentTime: startTime,
rawCorrelationTracks: corrTracks,
memberTripsData: memberTrips,
@ -305,17 +370,29 @@ export const useGearReplayStore = create<GearReplayState>()(
},
setHoveredMmsi: (mmsi) => set({ hoveredMmsi: mmsi }),
setReviewCandidates: (candidates) => set({ reviewCandidates: candidates }),
setShowTrails: (show) => set({ showTrails: show }),
setShowLabels: (show) => set({ showLabels: show }),
setFocusMode: (focus) => set({ focusMode: focus }),
setShow1hPolygon: (show) => set({ show1hPolygon: show }),
setShow6hPolygon: (show) => set({ show6hPolygon: show }),
setAbLoop: (on) => {
const { startTime, endTime } = get();
const { startTime, endTime, dataStartTime, dataEndTime } = get();
if (on && startTime > 0) {
// 기본 A-B: 전체 구간의 마지막 4시간
const dur = endTime - startTime;
set({ abLoop: true, abA: endTime - Math.min(dur, 4 * 3600_000), abB: endTime });
let rangeStart = dataStartTime > 0 ? Math.max(startTime, dataStartTime) : startTime;
let rangeEnd = dataEndTime > rangeStart ? Math.min(endTime, dataEndTime) : endTime;
if (rangeEnd <= rangeStart) {
const fallbackStart = Math.max(startTime, rangeStart - DEFAULT_AB_RANGE_MS / 2);
const fallbackEnd = Math.min(endTime, rangeStart + DEFAULT_AB_RANGE_MS / 2);
if (fallbackEnd > fallbackStart) {
rangeStart = fallbackStart;
rangeEnd = fallbackEnd;
} else {
rangeStart = startTime;
rangeEnd = endTime;
}
}
set({ abLoop: true, abA: rangeStart, abB: rangeEnd });
} else {
set({ abLoop: false, abA: 0, abB: 0 });
}
@ -358,6 +435,8 @@ export const useGearReplayStore = create<GearReplayState>()(
currentTime: 0,
startTime: 0,
endTime: 0,
dataStartTime: 0,
dataEndTime: 0,
playbackSpeed: 1,
historyFrames: [],
historyFrames6h: [],
@ -381,6 +460,7 @@ export const useGearReplayStore = create<GearReplayState>()(
enabledModels: new Set<string>(),
enabledVessels: new Set<string>(),
hoveredMmsi: null,
reviewCandidates: [],
showTrails: true,
showLabels: true,
focusMode: false,