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 { 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,
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user