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:
부모
7dd46f2078
커밋
8362bc5b6c
@ -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,
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user