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 { FONT_MONO } from '../../styles/fonts';
import { MODEL_ORDER, MODEL_COLORS, MODEL_DESC } from './fleetClusterConstants'; import { MODEL_ORDER, MODEL_COLORS, MODEL_DESC } from './fleetClusterConstants';
import { useGearReplayStore } from '../../stores/gearReplayStore'; import { useGearReplayStore } from '../../stores/gearReplayStore';
import { useTranslation } from 'react-i18next';
import { useReplayCenterPanelLayout } from './useReplayCenterPanelLayout';
interface CorrelationPanelProps { interface CorrelationPanelProps {
selectedGearGroup: string; selectedGearGroup: string;
@ -17,6 +19,8 @@ interface CorrelationPanelProps {
enabledVessels: Set<string>; enabledVessels: Set<string>;
correlationLoading: boolean; correlationLoading: boolean;
hoveredTarget: { mmsi: string; model: string } | null; hoveredTarget: { mmsi: string; model: string } | null;
hasRightReviewPanel?: boolean;
reviewDriven?: boolean;
onEnabledModelsChange: (updater: (prev: Set<string>) => Set<string>) => void; onEnabledModelsChange: (updater: (prev: Set<string>) => Set<string>) => void;
onEnabledVesselsChange: (updater: (prev: Set<string>) => Set<string>) => void; onEnabledVesselsChange: (updater: (prev: Set<string>) => Set<string>) => void;
onHoveredTargetChange: (target: { mmsi: string; model: string } | null) => void; onHoveredTargetChange: (target: { mmsi: string; model: string } | null) => void;
@ -35,11 +39,19 @@ const CorrelationPanel = ({
enabledVessels, enabledVessels,
correlationLoading, correlationLoading,
hoveredTarget, hoveredTarget,
hasRightReviewPanel = false,
reviewDriven = false,
onEnabledModelsChange, onEnabledModelsChange,
onEnabledVesselsChange, onEnabledVesselsChange,
onHoveredTargetChange, onHoveredTargetChange,
}: CorrelationPanelProps) => { }: CorrelationPanelProps) => {
const { t } = useTranslation();
const historyActive = useGearReplayStore(s => s.historyFrames.length > 0); const historyActive = useGearReplayStore(s => s.historyFrames.length > 0);
const layout = useReplayCenterPanelLayout({
minWidth: 252,
maxWidth: 966,
hasRightReviewPanel,
});
// Local tooltip state // Local tooltip state
const [hoveredModelTip, setHoveredModelTip] = useState<string | null>(null); const [hoveredModelTip, setHoveredModelTip] = useState<string | null>(null);
@ -193,16 +205,30 @@ const CorrelationPanel = ({
key={`${modelName}-${c.targetMmsi}`} key={`${modelName}-${c.targetMmsi}`}
style={{ style={{
fontSize: 9, marginBottom: 1, display: 'flex', alignItems: 'center', gap: 3, 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', background: isHovered ? `${color}22` : 'transparent',
opacity: isEnabled ? 1 : 0.5, opacity: reviewDriven ? 1 : isEnabled ? 1 : 0.5,
}} }}
onClick={() => toggleVessel(c.targetMmsi)} onClick={reviewDriven ? undefined : () => toggleVessel(c.targetMmsi)}
onMouseEnter={() => onHoveredTargetChange({ mmsi: c.targetMmsi, model: modelName })} onMouseEnter={reviewDriven ? undefined : () => onHoveredTargetChange({ mmsi: c.targetMmsi, model: modelName })}
onMouseLeave={() => onHoveredTargetChange(null)} onMouseLeave={reviewDriven ? undefined : () => onHoveredTargetChange(null)}
> >
<input type="checkbox" checked={isEnabled} readOnly title="맵 표시" {reviewDriven ? (
style={{ accentColor: color, width: 9, height: 9, flexShrink: 0, pointerEvents: 'none' }} /> <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 }}> <span style={{ color: isVessel ? '#60a5fa' : '#f97316', width: 10, textAlign: 'center', flexShrink: 0 }}>
{isVessel ? '⛴' : '◆'} {isVessel ? '⛴' : '◆'}
</span> </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) // Member row renderer (identity model — no score, independent hover)
const renderMemberRow = (m: { mmsi: string; name: string }, icon: string, iconColor: string, keyPrefix = 'id') => { const renderMemberRow = (m: { mmsi: string; name: string }, icon: string, iconColor: string, keyPrefix = 'id') => {
const isHovered = hoveredTarget?.mmsi === m.mmsi && hoveredTarget?.model === 'identity'; const isHovered = hoveredTarget?.mmsi === m.mmsi && hoveredTarget?.model === 'identity';
@ -251,10 +286,8 @@ const CorrelationPanel = ({
<div style={{ <div style={{
position: 'absolute', position: 'absolute',
bottom: historyActive ? 120 : 20, bottom: historyActive ? 120 : 20,
left: 'calc(50% + 100px)', left: `${layout.left}px`,
transform: 'translateX(-50%)', width: `${layout.width}px`,
width: 'calc(100vw - 880px)',
maxWidth: 1320,
display: 'flex', display: 'flex',
gap: 6, gap: 6,
alignItems: 'flex-end', alignItems: 'flex-end',
@ -270,6 +303,7 @@ const CorrelationPanel = ({
border: '1px solid rgba(249,115,22,0.3)', border: '1px solid rgba(249,115,22,0.3)',
borderRadius: 8, borderRadius: 8,
padding: '8px 10px', padding: '8px 10px',
width: 165,
minWidth: 165, minWidth: 165,
flexShrink: 0, flexShrink: 0,
boxShadow: '0 4px 16px rgba(0,0,0,0.5)', 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={{ fontWeight: 700, color: '#f97316', fontSize: 11 }}>{selectedGearGroup}</span>
<span style={{ color: '#64748b', fontSize: 9 }}>{memberCount}</span> <span style={{ color: '#64748b', fontSize: 9 }}>{memberCount}</span>
</div> </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> <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 }}> <label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, cursor: 'pointer', marginBottom: 3 }}>
<input <input
@ -300,15 +350,19 @@ const CorrelationPanel = ({
const gc = modelItems.filter(c => c.targetType !== 'VESSEL').length; const gc = modelItems.filter(c => c.targetType !== 'VESSEL').length;
const am = availableModels.find(m => m.name === mn); const am = availableModels.find(m => m.name === mn);
return ( 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 }}>
<input type="checkbox" checked={enabledModels.has(mn)} {reviewDriven ? (
disabled={!hasData} <span style={{ width: 11, height: 11, borderRadius: 999, background: hasData ? color : 'rgba(148,163,184,0.2)', flexShrink: 0 }} />
onChange={() => onEnabledModelsChange(prev => { ) : (
const next = new Set(prev); <input type="checkbox" checked={enabledModels.has(mn)}
if (next.has(mn)) next.delete(mn); else next.add(mn); disabled={!hasData}
return next; onChange={() => onEnabledModelsChange(prev => {
})} const next = new Set(prev);
style={{ accentColor: color, width: 11, height: 11 }} title={mn} /> if (next.has(mn)) next.delete(mn); else next.add(mn);
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={{ 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: hasData ? '#e2e8f0' : '#64748b', flex: 1 }}>{mn}{am?.isDefault ? '*' : ''}</span>
<span style={{ color: '#64748b', fontSize: 8 }}>{hasData ? `${vc}${gc}` : '—'}</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 ref={(el) => setCardRef('identity', el)} style={{ ...cardStyle, borderColor: 'rgba(249,115,22,0.25)', position: 'relative' }}>
<div style={getCardBodyStyle('identity')}> <div style={getCardBodyStyle('identity')}>
{identityVessels.length > 0 && ( {identityVessels.length > 0 && (
@ -335,7 +389,9 @@ const CorrelationPanel = ({
)} )}
{identityGear.length > 0 && ( {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'))} {identityGear.map(m => renderMemberRow(m, '◆', '#f97316'))}
</> </>
)} )}
@ -355,7 +411,9 @@ const CorrelationPanel = ({
)} )}
{/* 각 Correlation 모델 카드 (체크 시 우측에 추가) */} {/* 각 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 color = MODEL_COLORS[m.name] ?? '#94a3b8';
const items = correlationByModel.get(m.name) ?? []; const items = correlationByModel.get(m.name) ?? [];
const vessels = items.filter(c => c.targetType === 'VESSEL'); const vessels = items.filter(c => c.targetType === 'VESSEL');
@ -372,7 +430,9 @@ const CorrelationPanel = ({
)} )}
{gears.length > 0 && ( {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))} {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 type { FleetListItem } from './fleetClusterTypes';
import { panelStyle, headerStyle, toggleButtonStyle } from './fleetClusterConstants'; import { panelStyle, headerStyle, toggleButtonStyle } from './fleetClusterConstants';
import GearGroupSection from './GearGroupSection'; import GearGroupSection from './GearGroupSection';
import { useTranslation } from 'react-i18next';
interface FleetGearListPanelProps { interface FleetGearListPanelProps {
fleetList: FleetListItem[]; fleetList: FleetListItem[];
@ -42,14 +43,15 @@ const FleetGearListPanel = ({
onExpandGearGroup, onExpandGearGroup,
onShipSelect, onShipSelect,
}: FleetGearListPanelProps) => { }: FleetGearListPanelProps) => {
const { t } = useTranslation();
return ( return (
<div style={panelStyle}> <div style={panelStyle}>
{/* ── 선단 현황 섹션 ── */} {/* ── 선단 현황 섹션 ── */}
<div style={{ ...headerStyle, cursor: 'pointer' }} onClick={() => onToggleSection('fleet')}> <div style={{ ...headerStyle, cursor: 'pointer' }} onClick={() => onToggleSection('fleet')}>
<span style={{ fontWeight: 700, color: '#63b3ed', letterSpacing: 0.5 }}> <span style={{ fontWeight: 700, color: '#63b3ed', letterSpacing: 0.5 }}>
({fleetList.length}) {t('fleetGear.fleetSection', { count: fleetList.length })}
</span> </span>
<button type="button" style={toggleButtonStyle} aria-label="선단 현황 접기/펴기"> <button type="button" style={toggleButtonStyle} aria-label={t('fleetGear.toggleFleetSection')}>
{activeSection === 'fleet' ? '▲' : '▼'} {activeSection === 'fleet' ? '▲' : '▼'}
</button> </button>
</div> </div>
@ -57,12 +59,12 @@ const FleetGearListPanel = ({
<div style={{ padding: '4px 0', overflowY: 'auto', flex: 1 }}> <div style={{ padding: '4px 0', overflowY: 'auto', flex: 1 }}>
{fleetList.length === 0 ? ( {fleetList.length === 0 ? (
<div style={{ color: '#64748b', textAlign: 'center', padding: '8px 10px' }}> <div style={{ color: '#64748b', textAlign: 'center', padding: '8px 10px' }}>
{t('fleetGear.emptyFleet')}
</div> </div>
) : ( ) : (
fleetList.map(({ id, mmsiList, label, color, members }) => { fleetList.map(({ id, mmsiList, label, color, members }) => {
const company = companies.get(id); 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 isOpen = expandedFleet === id;
const isHovered = hoveredFleetId === id; const isHovered = hoveredFleetId === id;
@ -95,17 +97,19 @@ const FleetGearListPanel = ({
title={company ? `${company.nameCn} / ${company.nameEn}` : companyName}> title={company ? `${company.nameCn} / ${company.nameEn}` : companyName}>
{companyName} {companyName}
</span> </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); }} <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 }} 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="이 선단으로 지도 이동"> title={t('fleetGear.moveToFleet')}>
zoom {t('fleetGear.zoom')}
</button> </button>
</div> </div>
{isOpen && ( {isOpen && (
<div style={{ paddingLeft: 22, paddingRight: 10, paddingBottom: 6, fontSize: 10, color: '#94a3b8', borderLeft: `2px solid ${color}33`, marginLeft: 10 }}> <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 => { {displayMembers.map(m => {
const dto = analysisMap.get(m.mmsi); const dto = analysisMap.get(m.mmsi);
const role = dto?.algorithms.fleetRole.role ?? m.role; const role = dto?.algorithms.fleetRole.role ?? m.role;
@ -116,11 +120,11 @@ const FleetGearListPanel = ({
{displayName} {displayName}
</span> </span>
<span style={{ color: role === 'LEADER' ? '#fbbf24' : '#64748b', fontSize: 9, flexShrink: 0 }}> <span style={{ color: role === 'LEADER' ? '#fbbf24' : '#64748b', fontSize: 9, flexShrink: 0 }}>
({role === 'LEADER' ? 'MAIN' : 'SUB'}) ({role === 'LEADER' ? t('fleetGear.roleMain') : t('fleetGear.roleSub')})
</span> </span>
<button type="button" onClick={() => onShipSelect(m.mmsi)} <button type="button" onClick={() => onShipSelect(m.mmsi)}
style={{ background: 'none', border: 'none', color: '#63b3ed', fontSize: 10, cursor: 'pointer', padding: '0 2px', flexShrink: 0 }} 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> </button>
</div> </div>
@ -139,7 +143,7 @@ const FleetGearListPanel = ({
<GearGroupSection <GearGroupSection
groups={inZoneGearGroups} groups={inZoneGearGroups}
sectionKey="inZone" sectionKey="inZone"
sectionLabel={`조업구역내 어구 (${inZoneGearGroups.length}개)`} sectionLabel={t('fleetGear.inZoneSection', { count: inZoneGearGroups.length })}
accentColor="#dc2626" accentColor="#dc2626"
hoverBgColor="rgba(220,38,38,0.06)" hoverBgColor="rgba(220,38,38,0.06)"
isActive={activeSection === 'inZone'} isActive={activeSection === 'inZone'}
@ -154,7 +158,7 @@ const FleetGearListPanel = ({
<GearGroupSection <GearGroupSection
groups={outZoneGearGroups} groups={outZoneGearGroups}
sectionKey="outZone" sectionKey="outZone"
sectionLabel={`비허가 어구 (${outZoneGearGroups.length}개)`} sectionLabel={t('fleetGear.outZoneSection', { count: outZoneGearGroups.length })}
accentColor="#f97316" accentColor="#f97316"
hoverBgColor="rgba(255,255,255,0.04)" hoverBgColor="rgba(255,255,255,0.04)"
isActive={activeSection === 'outZone'} isActive={activeSection === 'outZone'}

파일 보기

@ -1,6 +1,7 @@
import type { GroupPolygonDto } from '../../services/vesselAnalysis'; import type { GroupPolygonDto } from '../../services/vesselAnalysis';
import { FONT_MONO } from '../../styles/fonts'; import { FONT_MONO } from '../../styles/fonts';
import { headerStyle, toggleButtonStyle } from './fleetClusterConstants'; import { headerStyle, toggleButtonStyle } from './fleetClusterConstants';
import { useTranslation } from 'react-i18next';
interface GearGroupSectionProps { interface GearGroupSectionProps {
groups: GroupPolygonDto[]; groups: GroupPolygonDto[];
@ -29,8 +30,47 @@ const GearGroupSection = ({
onGroupZoom, onGroupZoom,
onShipSelect, onShipSelect,
}: GearGroupSectionProps) => { }: GearGroupSectionProps) => {
const { t } = useTranslation();
const isInZoneSection = sectionKey === 'inZone'; 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 ( return (
<> <>
<div <div
@ -42,7 +82,7 @@ const GearGroupSection = ({
onClick={onToggleSection} onClick={onToggleSection}
> >
<span style={{ fontWeight: 700, color: accentColor, letterSpacing: 0.3, fontFamily: FONT_MONO }}> <span style={{ fontWeight: 700, color: accentColor, letterSpacing: 0.3, fontFamily: FONT_MONO }}>
{sectionLabel} ({groups.length}) {sectionLabel}
</span> </span>
<button <button
type="button" type="button"
@ -61,6 +101,8 @@ const GearGroupSection = ({
const parentMember = g.members.find(m => m.isParent); const parentMember = g.members.find(m => m.isParent);
const gearMembers = g.members.filter(m => !m.isParent); const gearMembers = g.members.filter(m => !m.isParent);
const zoneName = g.zoneName ?? ''; const zoneName = g.zoneName ?? '';
const inference = g.parentInference ?? null;
const badge = getInferenceBadge(inference?.status);
return ( return (
<div key={name} id={`gear-row-${name}`}> <div key={name} id={`gear-row-${name}`}>
@ -117,6 +159,25 @@ const GearGroupSection = ({
</span> </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 && ( {isInZoneSection && zoneName && (
<span style={{ color: '#ef4444', fontSize: 9, flexShrink: 0 }}>{zoneName}</span> <span style={{ color: '#ef4444', fontSize: 9, flexShrink: 0 }}>{zoneName}</span>
)} )}
@ -139,9 +200,9 @@ const GearGroupSection = ({
padding: '1px 4px', padding: '1px 4px',
flexShrink: 0, flexShrink: 0,
}} }}
title="이 어구 그룹으로 지도 이동" title={t('fleetGear.moveToGroup')}
> >
zoom {t('fleetGear.zoom')}
</button> </button>
</div> </div>
@ -158,10 +219,17 @@ const GearGroupSection = ({
}}> }}>
{parentMember && ( {parentMember && (
<div style={{ color: '#fbbf24', marginBottom: 2 }}> <div style={{ color: '#fbbf24', marginBottom: 2 }}>
: {parentMember.name || parentMember.mmsi} {t('parentInference.summary.recommendedParent')}: {parentMember.name || parentMember.mmsi}
</div> </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 => ( {gearMembers.map(m => (
<div key={m.mmsi} style={{ <div key={m.mmsi} style={{
display: 'flex', display: 'flex',
@ -190,8 +258,8 @@ const GearGroupSection = ({
padding: '0 2px', padding: '0 2px',
flexShrink: 0, flexShrink: 0,
}} }}
title="어구 위치로 이동" title={t('fleetGear.moveToGear')}
aria-label={`${m.name || m.mmsi} 위치로 이동`} aria-label={t('fleetGear.moveToGearItem', { name: m.name || m.mmsi })}
> >
</button> </button>

파일 보기

@ -1,16 +1,40 @@
import { useRef, useEffect, useState, useCallback, useMemo } from 'react'; import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
import { FONT_MONO } from '../../styles/fonts'; import { FONT_MONO } from '../../styles/fonts';
import { useGearReplayStore } from '../../stores/gearReplayStore'; import { useGearReplayStore } from '../../stores/gearReplayStore';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import { MODEL_COLORS } from './fleetClusterConstants'; import { MODEL_COLORS } from './fleetClusterConstants';
import type { HistoryFrame } from './fleetClusterTypes'; import type { HistoryFrame } from './fleetClusterTypes';
import type { GearCorrelationItem } from '../../services/vesselAnalysis'; import type { GearCorrelationItem } from '../../services/vesselAnalysis';
import { useReplayCenterPanelLayout } from './useReplayCenterPanelLayout';
interface HistoryReplayControllerProps { interface HistoryReplayControllerProps {
onClose: () => void; onClose: () => void;
onFilterByScore: (minPct: number | null) => void; hasRightReviewPanel?: boolean;
} }
const MIN_AB_GAP_MS = 2 * 3600_000; 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 { interface TooltipMember {
@ -70,7 +94,7 @@ function buildTooltipMembers(
return [...map.values()]; return [...map.values()];
} }
const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayControllerProps) => { const HistoryReplayController = ({ onClose, hasRightReviewPanel = false }: HistoryReplayControllerProps) => {
const isPlaying = useGearReplayStore(s => s.isPlaying); const isPlaying = useGearReplayStore(s => s.isPlaying);
const snapshotRanges = useGearReplayStore(s => s.snapshotRanges); const snapshotRanges = useGearReplayStore(s => s.snapshotRanges);
const snapshotRanges6h = useGearReplayStore(s => s.snapshotRanges6h); const snapshotRanges6h = useGearReplayStore(s => s.snapshotRanges6h);
@ -78,6 +102,9 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
const historyFrames6h = useGearReplayStore(s => s.historyFrames6h); const historyFrames6h = useGearReplayStore(s => s.historyFrames6h);
const frameCount = historyFrames.length; const frameCount = historyFrames.length;
const frameCount6h = historyFrames6h.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 showTrails = useGearReplayStore(s => s.showTrails);
const showLabels = useGearReplayStore(s => s.showLabels); const showLabels = useGearReplayStore(s => s.showLabels);
const focusMode = useGearReplayStore(s => s.focusMode); 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 [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 [pinnedTooltip, setPinnedTooltip] = useState<{ pos: number; time: number; frame1h: HistoryFrame | null; frame6h: HistoryFrame | null } | null>(null);
const [dragging, setDragging] = useState<'A' | 'B' | 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 trackRef = useRef<HTMLDivElement>(null);
const progressIndicatorRef = useRef<HTMLDivElement>(null); const progressIndicatorRef = useRef<HTMLDivElement>(null);
const timeDisplayRef = useRef<HTMLSpanElement>(null); const timeDisplayRef = useRef<HTMLSpanElement>(null);
const store = useGearReplayStore; const store = useGearReplayStore;
const speedMultiplier = SPEED_MULTIPLIERS.includes(replayUiPrefs.speedMultiplier)
? replayUiPrefs.speedMultiplier
: 1;
// currentTime → 진행 인디케이터 // currentTime → 진행 인디케이터
useEffect(() => { useEffect(() => {
@ -123,6 +154,34 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
if (isPlaying) setPinnedTooltip(null); if (isPlaying) setPinnedTooltip(null);
}, [isPlaying]); }, [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 posToProgress = useCallback((clientX: number) => {
const rect = trackRef.current?.getBoundingClientRect(); const rect = trackRef.current?.getBoundingClientRect();
if (!rect) return 0; if (!rect) return 0;
@ -258,13 +317,17 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
const btnActiveStyle: React.CSSProperties = { const btnActiveStyle: React.CSSProperties = {
...btnStyle, background: 'rgba(99,179,237,0.15)', color: '#93c5fd', ...btnStyle, background: 'rgba(99,179,237,0.15)', color: '#93c5fd',
}; };
const layout = useReplayCenterPanelLayout({
minWidth: 266,
maxWidth: 966,
hasRightReviewPanel,
});
return ( return (
<div style={{ <div style={{
position: 'absolute', bottom: 20, position: 'absolute', bottom: 20,
left: 'calc(50% + 100px)', transform: 'translateX(-50%)', left: `${layout.left}px`,
width: 'calc(100vw - 880px)', width: `${layout.width}px`,
minWidth: 380, maxWidth: 1320,
background: 'rgba(12,24,37,0.95)', border: '1px solid rgba(99,179,237,0.25)', 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, borderRadius: 8, padding: '8px 14px', display: 'flex', flexDirection: 'column', gap: 4,
zIndex: 50, fontFamily: FONT_MONO, fontSize: 10, color: '#e2e8f0', zIndex: 50, fontFamily: FONT_MONO, fontSize: 10, color: '#e2e8f0',
@ -452,38 +515,44 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
style={{ ...btnStyle, fontSize: 12 }}>{isPlaying ? '⏸' : '▶'}</button> style={{ ...btnStyle, fontSize: 12 }}>{isPlaying ? '⏸' : '▶'}</button>
<span ref={timeDisplayRef} style={{ color: '#fbbf24', minWidth: 36, textAlign: 'center' }}>--:--</span> <span ref={timeDisplayRef} style={{ color: '#fbbf24', minWidth: 36, textAlign: 'center' }}>--:--</span>
<span style={{ color: '#475569' }}>|</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> 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> 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} style={focusMode ? { ...btnStyle, background: 'rgba(239,68,68,0.15)', color: '#f87171' } : btnStyle}
title="집중 모드"></button> title="집중 모드"></button>
<span style={{ color: '#475569' }}>|</span> <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} 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> 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' } 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} : 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> disabled={!has6hData} title="6h 폴리곤">6h</button>
<span style={{ color: '#475569' }}>|</span> <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} 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> title="A-B 구간 반복">A-B</button>
<span style={{ color: '#475569' }}>|</span> <span style={{ color: '#475569' }}>|</span>
<span style={{ color: '#64748b', fontSize: 9 }}></span> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<select defaultValue="70" {SPEED_MULTIPLIERS.map(multiplier => {
onChange={e => { onFilterByScore(e.target.value === '' ? null : Number(e.target.value)); }} const active = speedMultiplier === multiplier;
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' }} return (
title="일치율 필터" aria-label="일치율 필터"> <button
<option value=""> (30%+)</option> key={multiplier}
<option value="50">50%+</option> type="button"
<option value="60">60%+</option> onClick={() => setReplayUiPrefs(prev => ({ ...prev, speedMultiplier: multiplier }))}
<option value="70">70%+</option> style={active
<option value="80">80%+</option> ? { ...btnActiveStyle, background: 'rgba(250,204,21,0.16)', color: '#fde68a', border: '1px solid rgba(250,204,21,0.32)' }
<option value="90">90%+</option> : btnStyle}
</select> title={`재생 속도 x${multiplier}`}
>
x{multiplier}
</button>
);
})}
</div>
<span style={{ flex: 1 }} /> <span style={{ flex: 1 }} />
<span style={{ color: '#64748b', fontSize: 9 }}> <span style={{ color: '#64748b', fontSize: 9 }}>
<span style={{ color: '#fbbf24' }}>{frameCount}</span> <span style={{ color: '#fbbf24' }}>{frameCount}</span>

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -6,6 +6,7 @@ import { useGearReplayStore } from '../stores/gearReplayStore';
import { findFrameAtTime, interpolateMemberPositions, interpolateSubFrameMembers } from '../stores/gearReplayPreprocess'; import { findFrameAtTime, interpolateMemberPositions, interpolateSubFrameMembers } from '../stores/gearReplayPreprocess';
import type { MemberPosition } from '../stores/gearReplayPreprocess'; import type { MemberPosition } from '../stores/gearReplayPreprocess';
import { MODEL_ORDER, MODEL_COLORS } from '../components/korea/fleetClusterConstants'; import { MODEL_ORDER, MODEL_COLORS } from '../components/korea/fleetClusterConstants';
import { getParentReviewCandidateColor } from '../components/korea/parentReviewCandidateColors';
import { buildInterpPolygon } from '../components/korea/fleetClusterUtils'; import { buildInterpPolygon } from '../components/korea/fleetClusterUtils';
import type { GearCorrelationItem } from '../services/vesselAnalysis'; import type { GearCorrelationItem } from '../services/vesselAnalysis';
import { SHIP_ICON_MAPPING } from '../utils/shipIconSvg'; import { SHIP_ICON_MAPPING } from '../utils/shipIconSvg';
@ -41,6 +42,80 @@ interface CorrPosition {
isVessel: boolean; 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 ────────────────────────────────────────────────────────────────────── // ── Hook ──────────────────────────────────────────────────────────────────────
/** /**
@ -67,8 +142,8 @@ export function useGearReplayLayers(
const enabledModels = useGearReplayStore(s => s.enabledModels); const enabledModels = useGearReplayStore(s => s.enabledModels);
const enabledVessels = useGearReplayStore(s => s.enabledVessels); const enabledVessels = useGearReplayStore(s => s.enabledVessels);
const hoveredMmsi = useGearReplayStore(s => s.hoveredMmsi); const hoveredMmsi = useGearReplayStore(s => s.hoveredMmsi);
const reviewCandidates = useGearReplayStore(s => s.reviewCandidates);
const correlationByModel = useGearReplayStore(s => s.correlationByModel); const correlationByModel = useGearReplayStore(s => s.correlationByModel);
const modelCenterTrails = useGearReplayStore(s => s.modelCenterTrails);
const showTrails = useGearReplayStore(s => s.showTrails); const showTrails = useGearReplayStore(s => s.showTrails);
const showLabels = useGearReplayStore(s => s.showLabels); const showLabels = useGearReplayStore(s => s.showLabels);
const show1hPolygon = useGearReplayStore(s => s.show1hPolygon); const show1hPolygon = useGearReplayStore(s => s.show1hPolygon);
@ -217,6 +292,11 @@ export function useGearReplayLayers(
// Member positions (interpolated) — 항상 계산 (배지/오퍼레이셔널에서 사용) // Member positions (interpolated) — 항상 계산 (배지/오퍼레이셔널에서 사용)
const members = interpolateMemberPositions(state.historyFrames, frameIdx, ct); const members = interpolateMemberPositions(state.historyFrames, frameIdx, ct);
const memberPts: [number, number][] = members.map(m => [m.lon, m.lat]); 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 폴리곤에서 공유) // 서브클러스터 프레임 (identity 폴리곤 + operational 폴리곤에서 공유)
const subFrames = frame.subFrames ?? [{ subClusterId: 0, centerLon: frame.centerLon, centerLat: frame.centerLat, members: frame.members, memberCount: frame.memberCount }]; 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) // 멤버 전체 항적 (identity — 항상 ON)
if (memberTripsData.length > 0) { if (memberTripsData.length > 0) {
for (const trip of memberTripsData) { 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({ layers.push(new PathLayer({
id: `replay-member-path-${trip.id}`, id: `replay-member-path-${trip.id}`,
data: [{ path: trip.path }], data: [{ path: trip.path }],
@ -236,6 +316,7 @@ export function useGearReplayLayers(
})); }));
} }
} }
// 연관 선박 전체 항적 (correlation) // 연관 선박 전체 항적 (correlation)
if (correlationTripsData.length > 0) { if (correlationTripsData.length > 0) {
const activeMmsis = new Set<string>(); const activeMmsis = new Set<string>();
@ -246,7 +327,7 @@ export function useGearReplayLayers(
} }
} }
for (const trip of correlationTripsData) { 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({ layers.push(new PathLayer({
id: `replay-corr-path-${trip.id}`, id: `replay-corr-path-${trip.id}`,
data: [{ path: trip.path }], data: [{ path: trip.path }],
@ -256,31 +337,29 @@ export function useGearReplayLayers(
})); }));
} }
} }
}
// 1. Correlation TripsLayer (GPU animated, 항상 ON, 고채도) if (reviewCandidates.length > 0) {
if (correlationTripsData.length > 0) { for (const candidate of reviewCandidates) {
const activeMmsis = new Set<string>(); const trip = corrTrackMap.get(candidate.mmsi);
for (const [mn, items] of correlationByModel) { if (!trip || trip.path.length < 2) continue;
if (!enabledModels.has(mn)) continue; const [r, g, b] = hexToRgb(getParentReviewCandidateColor(candidate.rank));
for (const c of items as GearCorrelationItem[]) { const hovered = hoveredMmsi === candidate.mmsi;
if (enabledVessels.has(c.targetMmsi)) activeMmsis.add(c.targetMmsi); 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,
}));
} }
} }
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,
}));
}
} }
// (identity 레이어는 최하단 — 최상위 z-index로 이동됨) // (identity 레이어는 최하단 — 최상위 z-index로 이동됨)
@ -329,11 +408,10 @@ export function useGearReplayLayers(
} }
} }
// 6. Correlation vessel positions (트랙 보간 → 끝점 clamp → live fallback) // 6. Correlation vessel positions (현재 리플레이 시점에 실제로 보이는 대상만)
const corrPositions: CorrPosition[] = []; const corrPositions: CorrPosition[] = [];
const corrTrackMap = new Map(correlationTripsData.map(d => [d.id, d])); const reviewPositions: CorrPosition[] = [];
const liveShips = shipsRef.current; void shipsRef;
const relTime = ct - st;
for (const [mn, items] of correlationByModel) { for (const [mn, items] of correlationByModel) {
if (!enabledModels.has(mn)) continue; if (!enabledModels.has(mn)) continue;
@ -342,80 +420,44 @@ export function useGearReplayLayers(
for (const c of items as GearCorrelationItem[]) { for (const c of items as GearCorrelationItem[]) {
if (!enabledVessels.has(c.targetMmsi)) continue; // OFF → 아이콘+트레일+폴리곤 모두 제외 if (!enabledVessels.has(c.targetMmsi)) continue; // OFF → 아이콘+트레일+폴리곤 모두 제외
if (reviewCandidateSet.has(c.targetMmsi)) continue;
if (corrPositions.some(p => p.mmsi === 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); const tripData = corrTrackMap.get(c.targetMmsi);
if (tripData && tripData.path.length > 0) { const position = tripData ? interpolateTripPosition(tripData, relTime) : null;
const ts = tripData.timestamps; if (!position) continue;
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;
corrPositions.push({ corrPositions.push({
mmsi: c.targetMmsi, mmsi: c.targetMmsi,
name: c.targetName || c.targetMmsi, name: c.targetName || c.targetMmsi,
lon, lon: position.lon,
lat, lat: position.lat,
cog, cog: position.cog,
color: [r, g, b, 230], color: [r, g, b, 230],
isVessel: c.targetType === 'VESSEL', 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) { if (shouldLog) {
const trackHit = corrPositions.filter(p => corrTrackMap.has(p.mmsi)).length; const trackHit = corrPositions.filter(p => corrTrackMap.has(p.mmsi)).length;
const liveHit = corrPositions.length - trackHit;
const sampleTrip = memberTripsData[0]; const sampleTrip = memberTripsData[0];
console.log('[GearReplay] renderFrame:', { console.log('[GearReplay] renderFrame:', {
historyFrames: state.historyFrames.length, historyFrames: state.historyFrames.length,
@ -427,7 +469,8 @@ export function useGearReplayLayers(
currentTime: Math.round((ct - st) / 60000) + 'min (rel)', currentTime: Math.round((ct - st) / 60000) + 'min (rel)',
members: members.length, members: members.length,
corrPositions: corrPositions.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', 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) { if (corrPositions.length > 0) {
layers.push(new IconLayer<CorrPosition>({ layers.push(new IconLayer<CorrPosition>({
id: 'replay-corr-vessels', 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 // 7. Hover highlight
if (hoveredMmsi) { if (hoveredMmsi) {
const hoveredMember = members.find(m => m.mmsi === 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 const hoveredPos: [number, number] | null = hoveredMember
? [hoveredMember.lon, hoveredMember.lat] ? [hoveredMember.lon, hoveredMember.lat]
: hoveredCorr : hoveredCorr
@ -506,16 +663,8 @@ export function useGearReplayLayers(
// Hover trail (from correlation track) // Hover trail (from correlation track)
const hoveredTrack = correlationTripsData.find(d => d.id === hoveredMmsi); const hoveredTrack = correlationTripsData.find(d => d.id === hoveredMmsi);
if (hoveredTrack) { if (hoveredTrack && !reviewCandidateSet.has(hoveredMmsi) && (visibleCorrMmsis.has(hoveredMmsi) || visibleMemberMmsis.has(hoveredMmsi))) {
const relTime = ct - st; const clippedPath = clipTripPathToTime(hoveredTrack, relTime);
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 (clippedPath.length >= 2) { if (clippedPath.length >= 2) {
layers.push(new PathLayer({ layers.push(new PathLayer({
id: 'replay-hover-trail', id: 'replay-hover-trail',
@ -537,6 +686,9 @@ export function useGearReplayLayers(
for (const c of corrPositions) { for (const c of corrPositions) {
if (state.pinnedMmsis.has(c.mmsi)) pinnedPositions.push({ position: [c.lon, c.lat] }); 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) { if (pinnedPositions.length > 0) {
// glow // glow
layers.push(new ScatterplotLayer({ layers.push(new ScatterplotLayer({
@ -566,12 +718,8 @@ export function useGearReplayLayers(
// pinned trails (correlation tracks) // pinned trails (correlation tracks)
const relTime = ct - st; const relTime = ct - st;
for (const trip of correlationTripsData) { for (const trip of correlationTripsData) {
if (!state.pinnedMmsis.has(trip.id)) continue; if (!state.pinnedMmsis.has(trip.id) || !visibleCorrMmsis.has(trip.id)) continue;
let clipIdx = trip.timestamps.length; const clippedPath = clipTripPathToTime(trip, relTime);
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 (clippedPath.length >= 2) { if (clippedPath.length >= 2) {
layers.push(new PathLayer({ layers.push(new PathLayer({
id: `replay-pinned-trail-${trip.id}`, 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) // pinned member trails (identity tracks)
for (const trip of memberTripsData) { for (const trip of memberTripsData) {
if (!state.pinnedMmsis.has(trip.id)) continue; if (!state.pinnedMmsis.has(trip.id) || !visibleMemberMmsis.has(trip.id)) continue;
let clipIdx = trip.timestamps.length; const clippedPath = clipTripPathToTime(trip, relTime);
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 (clippedPath.length >= 2) { if (clippedPath.length >= 2) {
layers.push(new PathLayer({ layers.push(new PathLayer({
id: `replay-pinned-mtrail-${trip.id}`, 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) // 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> }>(); const badgeTargets = new Map<string, { lon: number; lat: number; models: Set<string> }>();
@ -797,10 +899,10 @@ export function useGearReplayLayers(
} }
// TripsLayer (멤버 트레일) // TripsLayer (멤버 트레일)
if (memberTripsData.length > 0) { if (visibleMemberTrips.length > 0) {
layers.push(new TripsLayer({ layers.push(new TripsLayer({
id: 'replay-identity-trails', id: 'replay-identity-trails',
data: memberTripsData, data: visibleMemberTrips,
getPath: d => d.path, getPath: d => d.path,
getTimestamps: d => d.timestamps, getTimestamps: d => d.timestamps,
getColor: [255, 200, 60, 220], getColor: [255, 200, 60, 220],
@ -848,6 +950,8 @@ export function useGearReplayLayers(
const frame6h = state.historyFrames6h[frameIdx6h]; const frame6h = state.historyFrames6h[frameIdx6h];
const subFrames6h = frame6h.subFrames ?? [{ subClusterId: 0, centerLon: frame6h.centerLon, centerLat: frame6h.centerLat, members: frame6h.members, memberCount: frame6h.memberCount }]; 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 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 폴리곤 // 6h 폴리곤
for (const sf of subFrames6h) { for (const sf of subFrames6h) {
@ -908,10 +1012,10 @@ export function useGearReplayLayers(
} }
// 6h TripsLayer (항적 애니메이션) // 6h TripsLayer (항적 애니메이션)
if (memberTripsData6h.length > 0) { if (visibleMemberTrips6h.length > 0) {
layers.push(new TripsLayer({ layers.push(new TripsLayer({
id: 'replay-6h-identity-trails', id: 'replay-6h-identity-trails',
data: memberTripsData6h, data: visibleMemberTrips6h,
getPath: d => d.path, getPath: d => d.path,
getTimestamps: d => d.timestamps, getTimestamps: d => d.timestamps,
getColor: [147, 197, 253, 180] as [number, number, number, number], getColor: [147, 197, 253, 180] as [number, number, number, number],
@ -957,7 +1061,7 @@ export function useGearReplayLayers(
centerTrailSegments, centerDotsPositions, centerTrailSegments, centerDotsPositions,
centerTrailSegments6h, centerDotsPositions6h, subClusterCenters6h, centerTrailSegments6h, centerDotsPositions6h, subClusterCenters6h,
enabledModels, enabledVessels, hoveredMmsi, correlationByModel, enabledModels, enabledVessels, hoveredMmsi, correlationByModel,
modelCenterTrails, subClusterCenters, showTrails, showLabels, reviewCandidates, subClusterCenters, showTrails, showLabels,
show1hPolygon, show6hPolygon, pinnedMmsis, fs, zoomLevel, show1hPolygon, show6hPolygon, pinnedMmsis, fs, zoomLevel,
replayLayerRef, requestRender, replayLayerRef, requestRender,
]); ]);

파일 보기

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