refactor: FleetClusterLayer 10파일 분리 + deck.gl 리플레이 기반 구축
FleetClusterLayer.tsx 2357줄 → 10개 파일 분리: - fleetClusterTypes/Utils/Constants: 타입, 기하 함수, 모델 상수 - useFleetClusterGeoJson: 27개 useMemo GeoJSON 훅 - FleetClusterMapLayers: MapLibre Source/Layer JSX - CorrelationPanel/HistoryReplayController: 패널 서브컴포넌트 - GearGroupSection/FleetGearListPanel: 좌측 목록 (DRY) - FleetClusterLayer: 오케스트레이터 524줄 deck.gl + Zustand 리플레이 기반 (Phase 0~2): - zustand 5.0.12, @deck.gl/geo-layers 9.2.11 설치 - gearReplayStore: Zustand + rAF 애니메이션 루프 - gearReplayPreprocess: TripsLayer 전처리 + cursor O(1) 보간 - useGearReplayLayers: deck.gl 레이어 빌더 (10fps 스로틀) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
2fb0842523
커밋
bbbc326e38
914
frontend/package-lock.json
generated
914
frontend/package-lock.json
generated
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@deck.gl/core": "^9.2.11",
|
||||
"@deck.gl/geo-layers": "^9.2.11",
|
||||
"@deck.gl/layers": "^9.2.11",
|
||||
"@deck.gl/mapbox": "^9.2.11",
|
||||
"@fontsource-variable/fira-code": "^5.2.7",
|
||||
@ -32,7 +33,8 @@
|
||||
"react-map-gl": "^8.1.0",
|
||||
"recharts": "^3.8.0",
|
||||
"satellite.js": "^6.0.2",
|
||||
"tailwindcss": "^4.2.1"
|
||||
"tailwindcss": "^4.2.1",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
|
||||
366
frontend/src/components/korea/CorrelationPanel.tsx
Normal file
366
frontend/src/components/korea/CorrelationPanel.tsx
Normal file
@ -0,0 +1,366 @@
|
||||
import { useState } from 'react';
|
||||
import type { GearCorrelationItem, CorrelationVesselTrack } from '../../services/vesselAnalysis';
|
||||
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
|
||||
import type { HistoryFrame } from './fleetClusterTypes';
|
||||
import { FONT_MONO } from '../../styles/fonts';
|
||||
import { MODEL_ORDER, MODEL_COLORS, MODEL_DESC } from './fleetClusterConstants';
|
||||
|
||||
interface CorrelationPanelProps {
|
||||
selectedGearGroup: string;
|
||||
memberCount: number;
|
||||
groupPolygons: UseGroupPolygonsResult | undefined;
|
||||
correlationByModel: Map<string, GearCorrelationItem[]>;
|
||||
availableModels: { name: string; count: number; isDefault: boolean }[];
|
||||
correlationTracks: CorrelationVesselTrack[];
|
||||
enabledModels: Set<string>;
|
||||
enabledVessels: Set<string>;
|
||||
correlationLoading: boolean;
|
||||
historyData: HistoryFrame[] | null;
|
||||
effectiveSnapIdx: number;
|
||||
hoveredTarget: { mmsi: string; model: string } | null;
|
||||
onEnabledModelsChange: (updater: (prev: Set<string>) => Set<string>) => void;
|
||||
onEnabledVesselsChange: (updater: (prev: Set<string>) => Set<string>) => void;
|
||||
onHoveredTargetChange: (target: { mmsi: string; model: string } | null) => void;
|
||||
}
|
||||
|
||||
// Ensure MODEL_ORDER is treated as string array for Record lookups
|
||||
const _MODEL_ORDER: string[] = MODEL_ORDER as unknown as string[];
|
||||
|
||||
const CorrelationPanel = ({
|
||||
selectedGearGroup,
|
||||
memberCount,
|
||||
groupPolygons,
|
||||
correlationByModel,
|
||||
availableModels,
|
||||
correlationTracks,
|
||||
enabledModels,
|
||||
enabledVessels,
|
||||
correlationLoading,
|
||||
historyData,
|
||||
hoveredTarget,
|
||||
onEnabledModelsChange,
|
||||
onEnabledVesselsChange,
|
||||
onHoveredTargetChange,
|
||||
}: CorrelationPanelProps) => {
|
||||
// Local tooltip state
|
||||
const [hoveredModelTip, setHoveredModelTip] = useState<string | null>(null);
|
||||
const [pinnedModelTip, setPinnedModelTip] = useState<string | null>(null);
|
||||
const activeModelTip = pinnedModelTip ?? hoveredModelTip;
|
||||
|
||||
// Compute identity data from groupPolygons
|
||||
const allGroups = groupPolygons
|
||||
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
|
||||
: [];
|
||||
const group = allGroups.find(g => g.groupKey === selectedGearGroup);
|
||||
const identityVessels = group?.members.filter(m => m.isParent) ?? [];
|
||||
const identityGear = group?.members.filter(m => !m.isParent) ?? [];
|
||||
|
||||
// Suppress unused MODEL_ORDER warning — used for ordering checks
|
||||
void _MODEL_ORDER;
|
||||
|
||||
// Common card styles
|
||||
const cardStyle: React.CSSProperties = {
|
||||
background: 'rgba(12,24,37,0.95)',
|
||||
borderRadius: 6,
|
||||
minWidth: 160,
|
||||
maxWidth: 200,
|
||||
flexShrink: 0,
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
position: 'relative',
|
||||
};
|
||||
|
||||
const cardScrollStyle: React.CSSProperties = {
|
||||
padding: '6px 8px',
|
||||
maxHeight: 200,
|
||||
overflowY: 'auto',
|
||||
};
|
||||
|
||||
// Model title tooltip hover/click handlers
|
||||
const handleTipHover = (model: string) => {
|
||||
if (!pinnedModelTip) setHoveredModelTip(model);
|
||||
};
|
||||
const handleTipLeave = () => {
|
||||
if (!pinnedModelTip) setHoveredModelTip(null);
|
||||
};
|
||||
const handleTipClick = (model: string) => {
|
||||
setPinnedModelTip(prev => prev === model ? null : model);
|
||||
setHoveredModelTip(null);
|
||||
};
|
||||
|
||||
const renderModelTip = (model: string, color: string) => {
|
||||
if (activeModelTip !== model) return null;
|
||||
const desc = MODEL_DESC[model];
|
||||
if (!desc) return null;
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
left: 0,
|
||||
marginBottom: 4,
|
||||
padding: '6px 10px',
|
||||
background: 'rgba(15,23,42,0.97)',
|
||||
border: `1px solid ${color}66`,
|
||||
borderRadius: 5,
|
||||
fontSize: 9,
|
||||
color: '#e2e8f0',
|
||||
zIndex: 30,
|
||||
whiteSpace: 'nowrap',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.6)',
|
||||
pointerEvents: pinnedModelTip === model ? 'auto' : 'none',
|
||||
}}>
|
||||
<div style={{ fontWeight: 700, color, marginBottom: 4 }}>{desc.summary}</div>
|
||||
{desc.details.map((line, i) => (
|
||||
<div key={i} style={{ color: '#94a3b8', lineHeight: 1.5 }}>{line}</div>
|
||||
))}
|
||||
{pinnedModelTip === model && (
|
||||
<div style={{
|
||||
color: '#64748b',
|
||||
fontSize: 8,
|
||||
marginTop: 4,
|
||||
borderTop: '1px solid rgba(255,255,255,0.06)',
|
||||
paddingTop: 3,
|
||||
}}>
|
||||
클릭하여 닫기
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Common row renderer (correlation target — with score bar, model-independent hover)
|
||||
const renderRow = (c: GearCorrelationItem, color: string, modelName: string) => {
|
||||
const pct = (c.score * 100).toFixed(0);
|
||||
const barW = Math.max(2, c.score * 30);
|
||||
const barColor = c.score >= 0.7 ? '#22c55e' : c.score >= 0.5 ? '#eab308' : '#94a3b8';
|
||||
const isVessel = c.targetType === 'VESSEL';
|
||||
const hasTrack = correlationTracks.some(v => v.mmsi === c.targetMmsi);
|
||||
const isHovered = hoveredTarget?.mmsi === c.targetMmsi && hoveredTarget?.model === modelName;
|
||||
return (
|
||||
<div
|
||||
key={c.targetMmsi}
|
||||
style={{
|
||||
fontSize: 9,
|
||||
marginBottom: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 3,
|
||||
padding: '1px 2px',
|
||||
borderRadius: 2,
|
||||
cursor: 'default',
|
||||
background: isHovered ? `${color}22` : 'transparent',
|
||||
}}
|
||||
onMouseEnter={() => onHoveredTargetChange({ mmsi: c.targetMmsi, model: modelName })}
|
||||
onMouseLeave={() => onHoveredTargetChange(null)}
|
||||
>
|
||||
{hasTrack && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabledVessels.has(c.targetMmsi)}
|
||||
onChange={() => onEnabledVesselsChange(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(c.targetMmsi)) next.delete(c.targetMmsi);
|
||||
else next.add(c.targetMmsi);
|
||||
return next;
|
||||
})}
|
||||
style={{ accentColor: color, width: 9, height: 9, flexShrink: 0 }}
|
||||
title="맵 표시"
|
||||
/>
|
||||
)}
|
||||
<span style={{ color: isVessel ? '#60a5fa' : '#f97316', width: 10, textAlign: 'center', flexShrink: 0 }}>
|
||||
{isVessel ? '⛴' : '◆'}
|
||||
</span>
|
||||
<span style={{ color: '#cbd5e1', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{c.targetName || c.targetMmsi}
|
||||
</span>
|
||||
<div style={{ width: 50, display: 'flex', alignItems: 'center', gap: 2, flexShrink: 0 }}>
|
||||
<div style={{ width: 24, height: 3, background: 'rgba(255,255,255,0.08)', borderRadius: 2, overflow: 'hidden' }}>
|
||||
<div style={{ width: barW, height: '100%', background: barColor, borderRadius: 2 }} />
|
||||
</div>
|
||||
<span style={{ color: barColor, fontSize: 8, minWidth: 20, textAlign: 'right' }}>{pct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Member row renderer (identity model — no score, independent hover)
|
||||
const renderMemberRow = (m: { mmsi: string; name: string }, icon: string, iconColor: string) => {
|
||||
const isHovered = hoveredTarget?.mmsi === m.mmsi && hoveredTarget?.model === 'identity';
|
||||
return (
|
||||
<div
|
||||
key={m.mmsi}
|
||||
style={{
|
||||
fontSize: 9,
|
||||
marginBottom: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '1px 2px',
|
||||
borderRadius: 2,
|
||||
cursor: 'default',
|
||||
background: isHovered ? 'rgba(249,115,22,0.15)' : 'transparent',
|
||||
}}
|
||||
onMouseEnter={() => onHoveredTargetChange({ mmsi: m.mmsi, model: 'identity' })}
|
||||
onMouseLeave={() => onHoveredTargetChange(null)}
|
||||
>
|
||||
<span style={{ color: iconColor, width: 10, textAlign: 'center', flexShrink: 0 }}>{icon}</span>
|
||||
<span style={{ color: '#cbd5e1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{m.name || m.mmsi}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: historyData ? 80 : 20,
|
||||
left: 'calc(50% - 275px)',
|
||||
display: 'flex',
|
||||
gap: 6,
|
||||
alignItems: 'flex-start',
|
||||
zIndex: 21,
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: 10,
|
||||
color: '#e2e8f0',
|
||||
pointerEvents: 'auto',
|
||||
}}>
|
||||
{/* 고정: 토글 패널 */}
|
||||
<div style={{
|
||||
background: 'rgba(12,24,37,0.95)',
|
||||
border: '1px solid rgba(249,115,22,0.3)',
|
||||
borderRadius: 8,
|
||||
padding: '8px 10px',
|
||||
minWidth: 165,
|
||||
flexShrink: 0,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
|
||||
<span style={{ fontWeight: 700, color: '#f97316', fontSize: 11 }}>{selectedGearGroup}</span>
|
||||
<span style={{ color: '#64748b', fontSize: 9 }}>{memberCount}개</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 9, fontWeight: 700, color: '#93c5fd', marginBottom: 4 }}>폴리곤 오버레이</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, cursor: 'pointer', marginBottom: 3 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabledModels.has('identity')}
|
||||
onChange={() => onEnabledModelsChange(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has('identity')) next.delete('identity');
|
||||
else next.add('identity');
|
||||
return next;
|
||||
})}
|
||||
style={{ accentColor: '#f97316', width: 11, height: 11 }}
|
||||
title="이름 기반"
|
||||
/>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#f97316', flexShrink: 0 }} />
|
||||
<span style={{ color: '#e2e8f0' }}>이름 기반</span>
|
||||
<span style={{ color: '#64748b', marginLeft: 'auto' }}>{memberCount}</span>
|
||||
</label>
|
||||
{correlationLoading && <div style={{ fontSize: 8, color: '#64748b' }}>로딩...</div>}
|
||||
{availableModels.map(m => {
|
||||
const color = MODEL_COLORS[m.name] ?? '#94a3b8';
|
||||
const modelItems = correlationByModel.get(m.name) ?? [];
|
||||
const vc = modelItems.filter(c => c.targetType === 'VESSEL').length;
|
||||
const gc = modelItems.filter(c => c.targetType !== 'VESSEL').length;
|
||||
return (
|
||||
<label key={m.name} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, cursor: 'pointer', marginBottom: 3 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabledModels.has(m.name)}
|
||||
onChange={() => onEnabledModelsChange(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(m.name)) next.delete(m.name);
|
||||
else next.add(m.name);
|
||||
return next;
|
||||
})}
|
||||
style={{ accentColor: color, width: 11, height: 11 }}
|
||||
title={m.name}
|
||||
/>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, flexShrink: 0 }} />
|
||||
<span style={{ color: '#e2e8f0', flex: 1 }}>{m.name}{m.isDefault ? '*' : ''}</span>
|
||||
<span style={{ color: '#64748b', fontSize: 8 }}>{vc}⛴{gc}◆</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 이름 기반 카드 (체크 시) */}
|
||||
{enabledModels.has('identity') && (identityVessels.length > 0 || identityGear.length > 0) && (
|
||||
<div style={{ ...cardStyle, borderColor: 'rgba(249,115,22,0.25)' }}>
|
||||
{renderModelTip('identity', '#f97316')}
|
||||
<div style={cardScrollStyle}>
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4, cursor: 'help' }}
|
||||
onMouseEnter={() => handleTipHover('identity')}
|
||||
onMouseLeave={handleTipLeave}
|
||||
onClick={() => handleTipClick('identity')}
|
||||
>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#f97316', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 9, fontWeight: 700, color: '#f97316' }}>이름 기반</span>
|
||||
</div>
|
||||
{identityVessels.length > 0 && (
|
||||
<>
|
||||
<div style={{ fontSize: 8, color: '#64748b', marginBottom: 2 }}>연관 선박 ({identityVessels.length})</div>
|
||||
{identityVessels.map(m => renderMemberRow(m, '⛴', '#60a5fa'))}
|
||||
</>
|
||||
)}
|
||||
{identityGear.length > 0 && (
|
||||
<>
|
||||
<div style={{ fontSize: 8, color: '#64748b', marginBottom: 2, marginTop: 3 }}>연관 어구 ({identityGear.length})</div>
|
||||
{identityGear.slice(0, 12).map(m => renderMemberRow(m, '◆', '#f97316'))}
|
||||
{identityGear.length > 12 && (
|
||||
<div style={{ fontSize: 8, color: '#4a6b82' }}>+{identityGear.length - 12}개 더</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 각 Correlation 모델 카드 (체크 시 우측에 추가) */}
|
||||
{availableModels.filter(m => enabledModels.has(m.name)).map(m => {
|
||||
const color = MODEL_COLORS[m.name] ?? '#94a3b8';
|
||||
const items = correlationByModel.get(m.name) ?? [];
|
||||
const vessels = items.filter(c => c.targetType === 'VESSEL');
|
||||
const gears = items.filter(c => c.targetType !== 'VESSEL');
|
||||
if (vessels.length === 0 && gears.length === 0) return null;
|
||||
return (
|
||||
<div key={m.name} style={{ ...cardStyle, borderColor: `${color}40` }}>
|
||||
{renderModelTip(m.name, color)}
|
||||
<div style={cardScrollStyle}>
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4, cursor: 'help' }}
|
||||
onMouseEnter={() => handleTipHover(m.name)}
|
||||
onMouseLeave={handleTipLeave}
|
||||
onClick={() => handleTipClick(m.name)}
|
||||
>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 9, fontWeight: 700, color }}>{m.name}{m.isDefault ? '*' : ''}</span>
|
||||
</div>
|
||||
{vessels.length > 0 && (
|
||||
<>
|
||||
<div style={{ fontSize: 8, color: '#64748b', marginBottom: 2 }}>연관 선박 ({vessels.length})</div>
|
||||
{vessels.slice(0, 10).map(c => renderRow(c, color, m.name))}
|
||||
{vessels.length > 10 && (
|
||||
<div style={{ fontSize: 8, color: '#4a6b82' }}>+{vessels.length - 10}건 더</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{gears.length > 0 && (
|
||||
<>
|
||||
<div style={{ fontSize: 8, color: '#64748b', marginBottom: 2, marginTop: 3 }}>연관 어구 ({gears.length})</div>
|
||||
{gears.slice(0, 10).map(c => renderRow(c, color, m.name))}
|
||||
{gears.length > 10 && (
|
||||
<div style={{ fontSize: 8, color: '#4a6b82' }}>+{gears.length - 10}건 더</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CorrelationPanel;
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
492
frontend/src/components/korea/FleetClusterMapLayers.tsx
Normal file
492
frontend/src/components/korea/FleetClusterMapLayers.tsx
Normal file
@ -0,0 +1,492 @@
|
||||
import { Source, Layer, Popup } from 'react-map-gl/maplibre';
|
||||
import { FONT_MONO } from '../../styles/fonts';
|
||||
import type { FleetCompany } from '../../services/vesselAnalysis';
|
||||
import type { VesselAnalysisDto } from '../../types';
|
||||
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
|
||||
import type {
|
||||
HistoryFrame,
|
||||
HoverTooltipState,
|
||||
GearPickerPopupState,
|
||||
PickerCandidate,
|
||||
} from './fleetClusterTypes';
|
||||
import type { FleetClusterGeoJsonResult } from './useFleetClusterGeoJson';
|
||||
import { MODEL_ORDER, MODEL_COLORS } from './fleetClusterConstants';
|
||||
|
||||
interface FleetClusterMapLayersProps {
|
||||
geo: FleetClusterGeoJsonResult;
|
||||
selectedGearGroup: string | null;
|
||||
hoveredMmsi: string | null;
|
||||
enabledModels: Set<string>;
|
||||
expandedFleet: number | null;
|
||||
historyData: HistoryFrame[] | null;
|
||||
effectiveSnapIdx: number;
|
||||
// Popup/tooltip state
|
||||
hoverTooltip: HoverTooltipState | null;
|
||||
gearPickerPopup: GearPickerPopupState | null;
|
||||
pickerHoveredGroup: string | null;
|
||||
// Data for tooltip rendering
|
||||
groupPolygons: UseGroupPolygonsResult | undefined;
|
||||
companies: Map<number, FleetCompany>;
|
||||
analysisMap: Map<string, VesselAnalysisDto>;
|
||||
// Whether any correlation trails exist (drives conditional render)
|
||||
hasCorrelationTracks: boolean;
|
||||
// Callbacks
|
||||
onPickerHover: (group: string | null) => void;
|
||||
onPickerSelect: (candidate: PickerCandidate) => void;
|
||||
onPickerClose: () => void;
|
||||
}
|
||||
|
||||
const FleetClusterMapLayers = ({
|
||||
geo,
|
||||
selectedGearGroup,
|
||||
hoveredMmsi,
|
||||
enabledModels,
|
||||
expandedFleet,
|
||||
historyData,
|
||||
effectiveSnapIdx,
|
||||
hoverTooltip,
|
||||
gearPickerPopup,
|
||||
pickerHoveredGroup,
|
||||
groupPolygons,
|
||||
companies,
|
||||
analysisMap,
|
||||
hasCorrelationTracks,
|
||||
onPickerHover,
|
||||
onPickerSelect,
|
||||
onPickerClose,
|
||||
}: FleetClusterMapLayersProps) => {
|
||||
const {
|
||||
fleetPolygonGeoJSON,
|
||||
lineGeoJSON,
|
||||
hoveredGeoJSON,
|
||||
gearClusterGeoJson,
|
||||
memberMarkersGeoJson,
|
||||
pickerHighlightGeoJson,
|
||||
operationalPolygons,
|
||||
memberTrailsGeoJson,
|
||||
centerTrailGeoJson,
|
||||
currentCenterGeoJson,
|
||||
animPolygonGeoJson,
|
||||
animMembersGeoJson,
|
||||
correlationVesselGeoJson,
|
||||
correlationTrailGeoJson,
|
||||
modelBadgesGeoJson,
|
||||
hoverHighlightGeoJson,
|
||||
hoverHighlightTrailGeoJson,
|
||||
isStale,
|
||||
} = geo;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 선단 폴리곤 레이어 */}
|
||||
<Source id="fleet-cluster-fill" type="geojson" data={fleetPolygonGeoJSON}>
|
||||
<Layer
|
||||
id="fleet-cluster-fill-layer"
|
||||
type="fill"
|
||||
paint={{
|
||||
'fill-color': ['get', 'color'],
|
||||
'fill-opacity': 0.1,
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
id="fleet-cluster-line-layer"
|
||||
type="line"
|
||||
paint={{
|
||||
'line-color': ['get', 'color'],
|
||||
'line-opacity': 0.5,
|
||||
'line-width': 1.5,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* 2척 선단 라인 (서버측 Polygon 처리로 빈 컬렉션) */}
|
||||
<Source id="fleet-cluster-line" type="geojson" data={lineGeoJSON}>
|
||||
<Layer
|
||||
id="fleet-cluster-line-only"
|
||||
type="line"
|
||||
paint={{
|
||||
'line-color': ['get', 'color'],
|
||||
'line-opacity': 0.5,
|
||||
'line-width': 1.5,
|
||||
'line-dasharray': [4, 2],
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* 호버 하이라이트 (별도 Source) */}
|
||||
<Source id="fleet-cluster-hovered" type="geojson" data={hoveredGeoJSON}>
|
||||
<Layer
|
||||
id="fleet-cluster-hovered-fill"
|
||||
type="fill"
|
||||
paint={{
|
||||
'fill-color': ['get', 'color'],
|
||||
'fill-opacity': 0.25,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* 선택된 어구 그룹 — 이름 기반 하이라이트 폴리곤 */}
|
||||
{selectedGearGroup && enabledModels.has('identity') && !historyData && (() => {
|
||||
const allGroups = groupPolygons
|
||||
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
|
||||
: [];
|
||||
const group = allGroups.find(g => g.groupKey === selectedGearGroup);
|
||||
if (!group?.polygon) return null;
|
||||
const hlGeoJson: GeoJSON.FeatureCollection = {
|
||||
type: 'FeatureCollection',
|
||||
features: [{
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: group.polygon,
|
||||
}],
|
||||
};
|
||||
return (
|
||||
<Source id="gear-cluster-selected" type="geojson" data={hlGeoJson}>
|
||||
<Layer id="gear-selected-fill" type="fill" paint={{ 'fill-color': '#f97316', 'fill-opacity': 0.25 }} />
|
||||
<Layer id="gear-selected-line" type="line" paint={{ 'line-color': '#f97316', 'line-width': 3, 'line-opacity': 0.9 }} />
|
||||
</Source>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 선택된 어구 그룹 — 모델별 오퍼레이셔널 폴리곤 오버레이 */}
|
||||
{selectedGearGroup && operationalPolygons.map(op => (
|
||||
<Source key={`op-${op.modelName}`} id={`gear-op-${op.modelName}`} type="geojson" data={op.geojson}>
|
||||
<Layer id={`gear-op-fill-${op.modelName}`} type="fill" paint={{
|
||||
'fill-color': op.color, 'fill-opacity': 0.12,
|
||||
}} />
|
||||
<Layer id={`gear-op-line-${op.modelName}`} type="line" paint={{
|
||||
'line-color': op.color, 'line-width': 2.5, 'line-opacity': 0.8,
|
||||
'line-dasharray': [6, 3],
|
||||
}} />
|
||||
</Source>
|
||||
))}
|
||||
|
||||
{/* 비허가 어구 클러스터 폴리곤 */}
|
||||
<Source id="gear-clusters" type="geojson" data={gearClusterGeoJson}>
|
||||
<Layer
|
||||
id="gear-cluster-fill-layer"
|
||||
type="fill"
|
||||
paint={{
|
||||
'fill-color': ['case', ['==', ['get', 'inZone'], 1], 'rgba(220, 38, 38, 0.12)', 'rgba(249, 115, 22, 0.08)'],
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
id="gear-cluster-line-layer"
|
||||
type="line"
|
||||
paint={{
|
||||
'line-color': ['case', ['==', ['get', 'inZone'], 1], '#dc2626', '#f97316'],
|
||||
'line-opacity': 0.7,
|
||||
'line-width': ['case', ['==', ['get', 'inZone'], 1], 2, 1.5],
|
||||
'line-dasharray': [4, 2],
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* 가상 선박 마커 — 히스토리 재생 모드에서는 숨김 (애니메이션 아이콘으로 대체) */}
|
||||
<Source id="group-member-markers" type="geojson" data={historyData ? ({ type: 'FeatureCollection', features: [] } as GeoJSON) : memberMarkersGeoJson}>
|
||||
<Layer
|
||||
id="group-member-icon"
|
||||
type="symbol"
|
||||
layout={{
|
||||
'icon-image': ['case', ['==', ['get', 'isGear'], 1], 'gear-diamond', 'ship-triangle'],
|
||||
'icon-size': ['interpolate', ['linear'], ['zoom'],
|
||||
4, ['*', ['get', 'baseSize'], 0.9],
|
||||
6, ['*', ['get', 'baseSize'], 1.2],
|
||||
8, ['*', ['get', 'baseSize'], 1.8],
|
||||
10, ['*', ['get', 'baseSize'], 2.6],
|
||||
12, ['*', ['get', 'baseSize'], 3.2],
|
||||
13, ['*', ['get', 'baseSize'], 4.0],
|
||||
14, ['*', ['get', 'baseSize'], 4.8],
|
||||
],
|
||||
'icon-rotate': ['case', ['==', ['get', 'isGear'], 1], 0, ['get', 'cog']],
|
||||
'icon-rotation-alignment': 'map',
|
||||
'icon-allow-overlap': true,
|
||||
'icon-ignore-placement': true,
|
||||
'text-field': ['get', 'name'],
|
||||
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 6, 8, 8, 12, 10],
|
||||
'text-offset': [0, 1.4],
|
||||
'text-anchor': 'top',
|
||||
'text-allow-overlap': false,
|
||||
'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'],
|
||||
}}
|
||||
paint={{
|
||||
'icon-color': ['get', 'color'],
|
||||
'icon-halo-color': 'rgba(0,0,0,0.6)',
|
||||
'icon-halo-width': 0.5,
|
||||
'text-color': ['get', 'color'],
|
||||
'text-halo-color': '#000000',
|
||||
'text-halo-width': 1,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* 어구 picker 호버 하이라이트 */}
|
||||
<Source id="gear-picker-highlight" type="geojson" data={pickerHighlightGeoJson}>
|
||||
<Layer id="gear-picker-highlight-fill" type="fill"
|
||||
paint={{ 'fill-color': '#ffffff', 'fill-opacity': 0.25 }} />
|
||||
<Layer id="gear-picker-highlight-line" type="line"
|
||||
paint={{ 'line-color': '#ffffff', 'line-width': 2, 'line-dasharray': [3, 2] }} />
|
||||
</Source>
|
||||
|
||||
{/* 어구 다중 선택 팝업 */}
|
||||
{gearPickerPopup && (
|
||||
<Popup longitude={gearPickerPopup.lng} latitude={gearPickerPopup.lat}
|
||||
onClose={() => { onPickerClose(); }}
|
||||
closeOnClick={false} className="gl-popup" maxWidth="220px">
|
||||
<div style={{ fontSize: 10, fontFamily: FONT_MONO, padding: '4px 0' }}>
|
||||
<div style={{ fontWeight: 700, marginBottom: 4, color: '#e2e8f0', padding: '0 6px' }}>
|
||||
겹친 그룹 ({gearPickerPopup.candidates.length})
|
||||
</div>
|
||||
{gearPickerPopup.candidates.map(c => (
|
||||
<div key={c.isFleet ? `fleet-${c.clusterId}` : c.name}
|
||||
onMouseEnter={() => onPickerHover(c.isFleet ? String(c.clusterId) : c.name)}
|
||||
onMouseLeave={() => onPickerHover(null)}
|
||||
onClick={() => {
|
||||
onPickerSelect(c);
|
||||
onPickerClose();
|
||||
}}
|
||||
style={{
|
||||
cursor: 'pointer', padding: '3px 6px',
|
||||
borderLeft: `3px solid ${c.isFleet ? '#63b3ed' : c.inZone ? '#dc2626' : '#f97316'}`,
|
||||
marginBottom: 2, borderRadius: 2,
|
||||
backgroundColor: pickerHoveredGroup === (c.isFleet ? String(c.clusterId) : c.name) ? 'rgba(255,255,255,0.1)' : 'transparent',
|
||||
}}>
|
||||
<span style={{ color: c.isFleet ? '#63b3ed' : '#e2e8f0', fontSize: 9 }}>{c.isFleet ? '⚓ ' : ''}{c.name}</span>
|
||||
<span style={{ color: '#64748b', marginLeft: 4 }}>({c.count}{c.isFleet ? '척' : '개'})</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
|
||||
{/* 폴리곤 호버 툴팁 */}
|
||||
{hoverTooltip && (() => {
|
||||
if (hoverTooltip.type === 'fleet') {
|
||||
const cid = hoverTooltip.id as number;
|
||||
const group = groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === cid);
|
||||
const company = companies.get(cid);
|
||||
const memberCount = group?.memberCount ?? 0;
|
||||
return (
|
||||
<Popup longitude={hoverTooltip.lng} latitude={hoverTooltip.lat}
|
||||
closeButton={false} closeOnClick={false} anchor="bottom"
|
||||
className="gl-popup" maxWidth="220px"
|
||||
>
|
||||
<div style={{ fontFamily: FONT_MONO, fontSize: 10, padding: 4, color: '#e2e8f0' }}>
|
||||
<div style={{ fontWeight: 700, color: group?.color ?? '#63b3ed', marginBottom: 3 }}>
|
||||
{company?.nameCn || group?.groupLabel || `선단 #${cid}`}
|
||||
</div>
|
||||
<div style={{ color: '#94a3b8' }}>선박 {memberCount}척</div>
|
||||
{expandedFleet === cid && group?.members.slice(0, 5).map(m => {
|
||||
const dto = analysisMap.get(m.mmsi);
|
||||
const role = dto?.algorithms.fleetRole.role ?? m.role;
|
||||
return (
|
||||
<div key={m.mmsi} style={{ fontSize: 9, color: '#7ea8c4', marginTop: 2 }}>
|
||||
{role === 'LEADER' ? '★' : '·'} {m.name || m.mmsi} <span style={{ color: '#4a6b82' }}>{m.sog.toFixed(1)}kt</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div style={{ fontSize: 8, color: '#4a6b82', marginTop: 3 }}>클릭하여 상세 보기</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
if (hoverTooltip.type === 'gear') {
|
||||
const name = hoverTooltip.id as string;
|
||||
const allGroups = groupPolygons
|
||||
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
|
||||
: [];
|
||||
const group = allGroups.find(g => g.groupKey === name);
|
||||
if (!group) return null;
|
||||
const parentMember = group.members.find(m => m.isParent);
|
||||
const gearMembers = group.members.filter(m => !m.isParent);
|
||||
return (
|
||||
<Popup longitude={hoverTooltip.lng} latitude={hoverTooltip.lat}
|
||||
closeButton={false} closeOnClick={false} anchor="bottom"
|
||||
className="gl-popup" maxWidth={selectedGearGroup === name ? '280px' : '220px'}
|
||||
>
|
||||
<div style={{ fontFamily: FONT_MONO, fontSize: 10, padding: 4, color: '#e2e8f0' }}>
|
||||
<div style={{ fontWeight: 700, color: '#f97316', marginBottom: 3 }}>
|
||||
{name} <span style={{ fontWeight: 400, color: '#94a3b8' }}>어구 {gearMembers.length}개</span>
|
||||
</div>
|
||||
{parentMember && (
|
||||
<div style={{ fontSize: 9, color: '#fbbf24' }}>모선: {parentMember.name || parentMember.mmsi}</div>
|
||||
)}
|
||||
{selectedGearGroup === name && gearMembers.slice(0, 5).map(m => (
|
||||
<div key={m.mmsi} style={{ fontSize: 9, color: '#7ea8c4', marginTop: 1 }}>
|
||||
· {m.name || m.mmsi}
|
||||
</div>
|
||||
))}
|
||||
<div style={{ fontSize: 8, color: '#4a6b82', marginTop: 3 }}>클릭하여 선택/해제</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{/* ── 연관 대상 트레일 + 마커 (활성 모델 전체) ── */}
|
||||
{selectedGearGroup && hasCorrelationTracks && (
|
||||
<Source id="correlation-trails" type="geojson" data={correlationTrailGeoJson}>
|
||||
<Layer id="correlation-trails-line" type="line" paint={{
|
||||
'line-color': ['get', 'color'], 'line-width': 2, 'line-opacity': 0.6,
|
||||
'line-dasharray': [6, 3],
|
||||
}} />
|
||||
</Source>
|
||||
)}
|
||||
{selectedGearGroup && (
|
||||
<Source id="correlation-vessels" type="geojson" data={correlationVesselGeoJson}>
|
||||
<Layer id="correlation-vessels-icon" type="symbol" layout={{
|
||||
'icon-image': ['case', ['==', ['get', 'isVessel'], 1], 'ship-triangle', 'gear-diamond'],
|
||||
'icon-size': ['case', ['==', ['get', 'isVessel'], 1], 0.7, 0.5],
|
||||
'icon-rotate': ['case', ['==', ['get', 'isVessel'], 1], ['get', 'cog'], 0],
|
||||
'icon-rotation-alignment': 'map',
|
||||
'icon-allow-overlap': true,
|
||||
}} paint={{
|
||||
'icon-color': ['get', 'color'],
|
||||
'icon-halo-color': 'rgba(0,0,0,0.6)',
|
||||
'icon-halo-width': 1,
|
||||
}} />
|
||||
<Layer id="correlation-vessels-label" type="symbol" layout={{
|
||||
'text-field': ['get', 'name'],
|
||||
'text-size': 8,
|
||||
'text-offset': [0, 1.5],
|
||||
'text-allow-overlap': false,
|
||||
}} paint={{
|
||||
'text-color': ['get', 'color'],
|
||||
'text-halo-color': 'rgba(0,0,0,0.8)',
|
||||
'text-halo-width': 1,
|
||||
}} />
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{/* ── 모델 배지 (아이콘 우측 컬러 dot) ── */}
|
||||
{selectedGearGroup && (
|
||||
<Source id="model-badges" type="geojson" data={modelBadgesGeoJson}>
|
||||
{MODEL_ORDER.map((model, i) => (
|
||||
enabledModels.has(model) ? (
|
||||
<Layer key={`badge-${model}`} id={`model-badge-${model}`} type="circle"
|
||||
filter={['==', ['get', `m${i}`], 1]}
|
||||
paint={{
|
||||
'circle-radius': 3,
|
||||
'circle-color': MODEL_COLORS[model] ?? '#94a3b8',
|
||||
'circle-stroke-width': 0.5,
|
||||
'circle-stroke-color': 'rgba(0,0,0,0.6)',
|
||||
'circle-translate': [10 + i * 7, -6],
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
))}
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{/* ── 호버 하이라이트 (글로우 + 항적 강조) ── */}
|
||||
{hoveredMmsi && (
|
||||
<Source id="hover-highlight-point" type="geojson" data={hoverHighlightGeoJson}>
|
||||
<Layer id="hover-highlight-glow" type="circle" paint={{
|
||||
'circle-radius': 14, 'circle-color': '#ffffff', 'circle-opacity': 0.25,
|
||||
'circle-blur': 0.8,
|
||||
}} />
|
||||
<Layer id="hover-highlight-ring" type="circle" paint={{
|
||||
'circle-radius': 8, 'circle-color': 'transparent',
|
||||
'circle-stroke-width': 2, 'circle-stroke-color': '#ffffff',
|
||||
}} />
|
||||
</Source>
|
||||
)}
|
||||
{hoveredMmsi && (
|
||||
<Source id="hover-highlight-trail" type="geojson" data={hoverHighlightTrailGeoJson}>
|
||||
<Layer id="hover-highlight-trail-line" type="line" paint={{
|
||||
'line-color': '#ffffff', 'line-width': 3, 'line-opacity': 0.7,
|
||||
}} />
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{/* ── 히스토리 애니메이션 레이어 (최상위) ── */}
|
||||
{historyData && (
|
||||
<Source id="history-member-trails" type="geojson" data={memberTrailsGeoJson}>
|
||||
<Layer id="history-member-trails-line" type="line" paint={{
|
||||
'line-color': '#cbd5e1', 'line-width': 1.5, 'line-opacity': 0.65,
|
||||
}} />
|
||||
</Source>
|
||||
)}
|
||||
{historyData && (
|
||||
<Source id="history-center-trail" type="geojson" data={centerTrailGeoJson}>
|
||||
<Layer id="history-center-trail-line" type="line" paint={{
|
||||
'line-color': ['case', ['==', ['get', 'interpolated'], 1], '#f97316', '#fbbf24'],
|
||||
'line-width': 2,
|
||||
'line-dasharray': [4, 4],
|
||||
'line-opacity': ['case', ['==', ['get', 'interpolated'], 1], 0.8, 0.7],
|
||||
}} filter={['==', '$type', 'LineString']} />
|
||||
<Layer id="history-center-dots" type="circle" paint={{
|
||||
'circle-radius': 2.5, 'circle-color': '#fbbf24', 'circle-opacity': 0.6,
|
||||
}} filter={['==', '$type', 'Point']} />
|
||||
</Source>
|
||||
)}
|
||||
{/* 보간 경로는 centerTrailGeoJson에 통합됨 (interpolated=1 세그먼트) */}
|
||||
{/* 현재 재생 위치 포인트 (실데이터=빨강, 보간=주황) */}
|
||||
{historyData && effectiveSnapIdx >= 0 && (
|
||||
<Source id="history-current-center" type="geojson" data={currentCenterGeoJson}>
|
||||
<Layer id="history-current-center-dot" type="circle" paint={{
|
||||
'circle-radius': 7,
|
||||
'circle-color': ['case', ['==', ['get', 'interpolated'], 1], '#f97316', '#ef4444'],
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#ffffff',
|
||||
}} />
|
||||
</Source>
|
||||
)}
|
||||
{historyData && (
|
||||
<Source id="history-anim-polygon" type="geojson" data={animPolygonGeoJson}>
|
||||
<Layer id="history-anim-fill" type="fill" paint={{
|
||||
'fill-color': ['case', ['==', ['get', 'interpolated'], 1], '#94a3b8', isStale ? '#64748b' : '#fbbf24'],
|
||||
'fill-opacity': ['case', ['==', ['get', 'interpolated'], 1], 0.12, isStale ? 0.08 : 0.15],
|
||||
}} />
|
||||
<Layer id="history-anim-line" type="line" paint={{
|
||||
'line-color': ['case', ['==', ['get', 'interpolated'], 1], '#94a3b8', isStale ? '#64748b' : '#fbbf24'],
|
||||
'line-width': ['case', ['==', ['get', 'interpolated'], 1], 1.5, isStale ? 1 : 2],
|
||||
'line-opacity': ['case', ['==', ['get', 'interpolated'], 1], 0.5, isStale ? 0.4 : 0.7],
|
||||
'line-dasharray': isStale ? [3, 3] : [1, 0],
|
||||
}} />
|
||||
</Source>
|
||||
)}
|
||||
{/* 가상 아이콘 — 현재 프레임 멤버 위치 (최상위) */}
|
||||
{historyData && (
|
||||
<Source id="history-anim-members" type="geojson" data={animMembersGeoJson}>
|
||||
<Layer id="history-anim-members-icon" type="symbol" layout={{
|
||||
'icon-image': ['case', ['==', ['get', 'isGear'], 1], 'gear-diamond', 'ship-triangle'],
|
||||
'icon-size': ['case', ['==', ['get', 'isGear'], 1], 0.55, 0.7],
|
||||
'icon-rotate': ['case', ['==', ['get', 'isGear'], 1], 0, ['get', 'cog']],
|
||||
'icon-rotation-alignment': 'map',
|
||||
'icon-allow-overlap': true,
|
||||
}} paint={{
|
||||
'icon-color': ['case',
|
||||
['==', ['get', 'interpolated'], 1], '#94a3b8',
|
||||
['==', ['get', 'stale'], 1], '#64748b',
|
||||
['==', ['get', 'isGear'], 0], '#fbbf24',
|
||||
'#a8b8c8',
|
||||
],
|
||||
'icon-opacity': ['case',
|
||||
['==', ['get', 'interpolated'], 1], 0.5,
|
||||
['==', ['get', 'stale'], 1], 0.4,
|
||||
0.9,
|
||||
],
|
||||
}} />
|
||||
<Layer id="history-anim-members-label" type="symbol" layout={{
|
||||
'text-field': ['get', 'name'],
|
||||
'text-size': 8,
|
||||
'text-offset': [0, 1.5],
|
||||
'text-allow-overlap': false,
|
||||
}} paint={{
|
||||
'text-color': ['case',
|
||||
['==', ['get', 'interpolated'], 1], '#94a3b8',
|
||||
['==', ['get', 'isGear'], 0], '#fbbf24',
|
||||
'#e2e8f0',
|
||||
],
|
||||
'text-halo-color': 'rgba(0,0,0,0.8)',
|
||||
'text-halo-width': 1,
|
||||
}} />
|
||||
</Source>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FleetClusterMapLayers;
|
||||
171
frontend/src/components/korea/FleetGearListPanel.tsx
Normal file
171
frontend/src/components/korea/FleetGearListPanel.tsx
Normal file
@ -0,0 +1,171 @@
|
||||
import type { FleetCompany } from '../../services/vesselAnalysis';
|
||||
import type { VesselAnalysisDto } from '../../types';
|
||||
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
|
||||
import type { FleetListItem } from './fleetClusterTypes';
|
||||
import { panelStyle, headerStyle, toggleButtonStyle } from './fleetClusterConstants';
|
||||
import GearGroupSection from './GearGroupSection';
|
||||
|
||||
interface FleetGearListPanelProps {
|
||||
fleetList: FleetListItem[];
|
||||
companies: Map<number, FleetCompany>;
|
||||
analysisMap: Map<string, VesselAnalysisDto>;
|
||||
inZoneGearGroups: UseGroupPolygonsResult['gearInZoneGroups'];
|
||||
outZoneGearGroups: UseGroupPolygonsResult['gearOutZoneGroups'];
|
||||
activeSection: string | null;
|
||||
expandedFleet: number | null;
|
||||
expandedGearGroup: string | null;
|
||||
hoveredFleetId: number | null;
|
||||
onToggleSection: (key: string) => void;
|
||||
onExpandFleet: (id: number | null) => void;
|
||||
onHoverFleet: (id: number | null) => void;
|
||||
onFleetZoom: (id: number) => void;
|
||||
onGearGroupZoom: (name: string) => void;
|
||||
onExpandGearGroup: (name: string | null) => void;
|
||||
onShipSelect: (mmsi: string) => void;
|
||||
}
|
||||
|
||||
const FleetGearListPanel = ({
|
||||
fleetList,
|
||||
companies,
|
||||
analysisMap,
|
||||
inZoneGearGroups,
|
||||
outZoneGearGroups,
|
||||
activeSection,
|
||||
expandedFleet,
|
||||
expandedGearGroup,
|
||||
hoveredFleetId,
|
||||
onToggleSection,
|
||||
onExpandFleet,
|
||||
onHoverFleet,
|
||||
onFleetZoom,
|
||||
onGearGroupZoom,
|
||||
onExpandGearGroup,
|
||||
onShipSelect,
|
||||
}: FleetGearListPanelProps) => {
|
||||
return (
|
||||
<div style={panelStyle}>
|
||||
{/* ── 선단 현황 섹션 ── */}
|
||||
<div style={{ ...headerStyle, cursor: 'pointer' }} onClick={() => onToggleSection('fleet')}>
|
||||
<span style={{ fontWeight: 700, color: '#63b3ed', letterSpacing: 0.5 }}>
|
||||
선단 현황 ({fleetList.length}개)
|
||||
</span>
|
||||
<button type="button" style={toggleButtonStyle} aria-label="선단 현황 접기/펴기">
|
||||
{activeSection === 'fleet' ? '▲' : '▼'}
|
||||
</button>
|
||||
</div>
|
||||
{activeSection === 'fleet' && (
|
||||
<div style={{ padding: '4px 0', overflowY: 'auto', flex: 1 }}>
|
||||
{fleetList.length === 0 ? (
|
||||
<div style={{ color: '#64748b', textAlign: 'center', padding: '8px 10px' }}>
|
||||
선단 데이터 없음
|
||||
</div>
|
||||
) : (
|
||||
fleetList.map(({ id, mmsiList, label, color, members }) => {
|
||||
const company = companies.get(id);
|
||||
const companyName = company?.nameCn ?? label ?? `선단 #${id}`;
|
||||
const isOpen = expandedFleet === id;
|
||||
const isHovered = hoveredFleetId === id;
|
||||
|
||||
const mainMembers = members.filter(m => {
|
||||
const dto = analysisMap.get(m.mmsi);
|
||||
return dto?.algorithms.fleetRole.role === 'LEADER' || dto?.algorithms.fleetRole.role === 'MEMBER';
|
||||
});
|
||||
const displayMembers = mainMembers.length > 0 ? mainMembers : members;
|
||||
|
||||
return (
|
||||
<div key={id}>
|
||||
<div
|
||||
onMouseEnter={() => onHoverFleet(id)}
|
||||
onMouseLeave={() => onHoverFleet(null)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 10px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: isHovered ? 'rgba(255,255,255,0.06)' : 'transparent',
|
||||
borderLeft: isOpen ? `2px solid ${color}` : '2px solid transparent',
|
||||
transition: 'background-color 0.1s',
|
||||
}}
|
||||
>
|
||||
<span onClick={() => onExpandFleet(isOpen ? null : id)}
|
||||
style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }}>
|
||||
{isOpen ? '▾' : '▸'}
|
||||
</span>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: color, flexShrink: 0 }} />
|
||||
<span onClick={() => onExpandFleet(isOpen ? null : id)}
|
||||
style={{ flex: 1, color: '#e2e8f0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }}
|
||||
title={company ? `${company.nameCn} / ${company.nameEn}` : companyName}>
|
||||
{companyName}
|
||||
</span>
|
||||
<span style={{ color: '#64748b', fontSize: 10, flexShrink: 0 }}>({mmsiList.length}척)</span>
|
||||
<button type="button" onClick={e => { e.stopPropagation(); onFleetZoom(id); }}
|
||||
style={{ background: 'none', border: '1px solid rgba(99,179,237,0.3)', borderRadius: 3, color: '#63b3ed', fontSize: 9, cursor: 'pointer', padding: '1px 4px', flexShrink: 0 }}
|
||||
title="이 선단으로 지도 이동">
|
||||
zoom
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div style={{ paddingLeft: 22, paddingRight: 10, paddingBottom: 6, fontSize: 10, color: '#94a3b8', borderLeft: `2px solid ${color}33`, marginLeft: 10 }}>
|
||||
<div style={{ color: '#64748b', fontSize: 9, marginBottom: 3 }}>선박:</div>
|
||||
{displayMembers.map(m => {
|
||||
const dto = analysisMap.get(m.mmsi);
|
||||
const role = dto?.algorithms.fleetRole.role ?? m.role;
|
||||
const displayName = m.name || m.mmsi;
|
||||
return (
|
||||
<div key={m.mmsi} style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 2 }}>
|
||||
<span style={{ flex: 1, color: '#cbd5e1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{displayName}
|
||||
</span>
|
||||
<span style={{ color: role === 'LEADER' ? '#fbbf24' : '#64748b', fontSize: 9, flexShrink: 0 }}>
|
||||
({role === 'LEADER' ? 'MAIN' : 'SUB'})
|
||||
</span>
|
||||
<button type="button" onClick={() => onShipSelect(m.mmsi)}
|
||||
style={{ background: 'none', border: 'none', color: '#63b3ed', fontSize: 10, cursor: 'pointer', padding: '0 2px', flexShrink: 0 }}
|
||||
title="선박으로 이동" aria-label={`${displayName} 선박으로 이동`}>
|
||||
▶
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 조업구역내 어구 ── */}
|
||||
<GearGroupSection
|
||||
groups={inZoneGearGroups}
|
||||
sectionKey="inZone"
|
||||
sectionLabel={`조업구역내 어구 (${inZoneGearGroups.length}개)`}
|
||||
accentColor="#dc2626"
|
||||
hoverBgColor="rgba(220,38,38,0.06)"
|
||||
isActive={activeSection === 'inZone'}
|
||||
expandedGroup={expandedGearGroup}
|
||||
onToggleSection={() => onToggleSection('inZone')}
|
||||
onToggleGroup={(name) => onExpandGearGroup(expandedGearGroup === name ? null : name)}
|
||||
onGroupZoom={onGearGroupZoom}
|
||||
onShipSelect={onShipSelect}
|
||||
/>
|
||||
|
||||
{/* ── 비허가 어구 ── */}
|
||||
<GearGroupSection
|
||||
groups={outZoneGearGroups}
|
||||
sectionKey="outZone"
|
||||
sectionLabel={`비허가 어구 (${outZoneGearGroups.length}개)`}
|
||||
accentColor="#f97316"
|
||||
hoverBgColor="rgba(255,255,255,0.04)"
|
||||
isActive={activeSection === 'outZone'}
|
||||
expandedGroup={expandedGearGroup}
|
||||
onToggleSection={() => onToggleSection('outZone')}
|
||||
onToggleGroup={(name) => onExpandGearGroup(expandedGearGroup === name ? null : name)}
|
||||
onGroupZoom={onGearGroupZoom}
|
||||
onShipSelect={onShipSelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FleetGearListPanel;
|
||||
211
frontend/src/components/korea/GearGroupSection.tsx
Normal file
211
frontend/src/components/korea/GearGroupSection.tsx
Normal file
@ -0,0 +1,211 @@
|
||||
import type { GroupPolygonDto } from '../../services/vesselAnalysis';
|
||||
import { FONT_MONO } from '../../styles/fonts';
|
||||
import { headerStyle, toggleButtonStyle } from './fleetClusterConstants';
|
||||
|
||||
interface GearGroupSectionProps {
|
||||
groups: GroupPolygonDto[];
|
||||
sectionKey: string;
|
||||
sectionLabel: string;
|
||||
accentColor: string;
|
||||
hoverBgColor: string;
|
||||
isActive: boolean;
|
||||
expandedGroup: string | null;
|
||||
onToggleSection: () => void;
|
||||
onToggleGroup: (name: string) => void;
|
||||
onGroupZoom: (name: string) => void;
|
||||
onShipSelect: (mmsi: string) => void;
|
||||
}
|
||||
|
||||
const GearGroupSection = ({
|
||||
groups,
|
||||
sectionKey,
|
||||
sectionLabel,
|
||||
accentColor,
|
||||
hoverBgColor,
|
||||
isActive,
|
||||
expandedGroup,
|
||||
onToggleSection,
|
||||
onToggleGroup,
|
||||
onGroupZoom,
|
||||
onShipSelect,
|
||||
}: GearGroupSectionProps) => {
|
||||
const isInZoneSection = sectionKey === 'inZone';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
...headerStyle,
|
||||
borderTop: `1px solid ${accentColor}40`,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={onToggleSection}
|
||||
>
|
||||
<span style={{ fontWeight: 700, color: accentColor, letterSpacing: 0.3, fontFamily: FONT_MONO }}>
|
||||
{sectionLabel} ({groups.length}개)
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
style={toggleButtonStyle}
|
||||
aria-label={`${sectionLabel} 접기/펴기`}
|
||||
>
|
||||
{isActive ? '▲' : '▼'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isActive && (
|
||||
<div style={{ padding: '4px 0', overflowY: 'auto', flex: 1 }}>
|
||||
{groups.map(g => {
|
||||
const name = g.groupKey;
|
||||
const isOpen = expandedGroup === name;
|
||||
const parentMember = g.members.find(m => m.isParent);
|
||||
const gearMembers = g.members.filter(m => !m.isParent);
|
||||
const zoneName = g.zoneName ?? '';
|
||||
|
||||
return (
|
||||
<div key={name} id={`gear-row-${name}`}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '3px 10px',
|
||||
cursor: 'pointer',
|
||||
borderLeft: isOpen ? `2px solid ${accentColor}` : '2px solid transparent',
|
||||
transition: 'background-color 0.1s',
|
||||
fontFamily: FONT_MONO,
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
(e.currentTarget as HTMLDivElement).style.backgroundColor = hoverBgColor;
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
(e.currentTarget as HTMLDivElement).style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
<span
|
||||
onClick={() => onToggleGroup(name)}
|
||||
style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }}
|
||||
>
|
||||
{isOpen ? '▾' : '▸'}
|
||||
</span>
|
||||
<span style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: accentColor,
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
<span
|
||||
onClick={() => onToggleGroup(name)}
|
||||
style={{
|
||||
flex: 1,
|
||||
color: '#e2e8f0',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title={isInZoneSection ? `${name} — ${zoneName}` : name}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
{parentMember && (
|
||||
<span
|
||||
style={{ color: '#fbbf24', fontSize: 8, flexShrink: 0 }}
|
||||
title={`모선: ${parentMember.name}`}
|
||||
>
|
||||
⚓
|
||||
</span>
|
||||
)}
|
||||
{isInZoneSection && zoneName && (
|
||||
<span style={{ color: '#ef4444', fontSize: 9, flexShrink: 0 }}>{zoneName}</span>
|
||||
)}
|
||||
<span style={{ color: '#64748b', fontSize: 10, flexShrink: 0 }}>
|
||||
({gearMembers.length}{isInZoneSection ? '' : '개'})
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onGroupZoom(name);
|
||||
}}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: `1px solid ${accentColor}80`,
|
||||
borderRadius: 3,
|
||||
color: accentColor,
|
||||
fontSize: 9,
|
||||
cursor: 'pointer',
|
||||
padding: '1px 4px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title="이 어구 그룹으로 지도 이동"
|
||||
>
|
||||
zoom
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div style={{
|
||||
paddingLeft: 24,
|
||||
paddingRight: 10,
|
||||
paddingBottom: 4,
|
||||
fontSize: 9,
|
||||
color: '#94a3b8',
|
||||
borderLeft: `2px solid ${accentColor}40`,
|
||||
marginLeft: 10,
|
||||
fontFamily: FONT_MONO,
|
||||
}}>
|
||||
{parentMember && (
|
||||
<div style={{ color: '#fbbf24', marginBottom: 2 }}>
|
||||
모선: {parentMember.name || parentMember.mmsi}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ color: '#64748b', marginBottom: 2 }}>어구 목록:</div>
|
||||
{gearMembers.map(m => (
|
||||
<div key={m.mmsi} style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
marginBottom: 1,
|
||||
}}>
|
||||
<span style={{
|
||||
flex: 1,
|
||||
color: '#475569',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{m.name || m.mmsi}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onShipSelect(m.mmsi)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: accentColor,
|
||||
fontSize: 10,
|
||||
cursor: 'pointer',
|
||||
padding: '0 2px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title="어구 위치로 이동"
|
||||
aria-label={`${m.name || m.mmsi} 위치로 이동`}
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GearGroupSection;
|
||||
171
frontend/src/components/korea/HistoryReplayController.tsx
Normal file
171
frontend/src/components/korea/HistoryReplayController.tsx
Normal file
@ -0,0 +1,171 @@
|
||||
import React from 'react';
|
||||
import { FONT_MONO } from '../../styles/fonts';
|
||||
import type { HistoryFrame } from './fleetClusterTypes';
|
||||
import { TIMELINE_DURATION_MS } from './fleetClusterTypes';
|
||||
|
||||
interface HistoryReplayControllerProps {
|
||||
historyData: HistoryFrame[];
|
||||
effectiveSnapIdx: number;
|
||||
isPlaying: boolean;
|
||||
snapshotRanges: number[];
|
||||
progressBarRef: React.RefObject<HTMLInputElement | null>;
|
||||
progressIndicatorRef: React.RefObject<HTMLDivElement | null>;
|
||||
timeDisplayRef: React.RefObject<HTMLSpanElement | null>;
|
||||
historyStartRef: React.RefObject<number>;
|
||||
timelinePosRef: React.MutableRefObject<number>;
|
||||
frameTimesRef: React.RefObject<number[]>;
|
||||
onTogglePlay: () => void;
|
||||
onFrameChange: (idx: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const HistoryReplayController = ({
|
||||
historyData,
|
||||
effectiveSnapIdx,
|
||||
isPlaying,
|
||||
snapshotRanges,
|
||||
progressBarRef,
|
||||
progressIndicatorRef,
|
||||
timeDisplayRef,
|
||||
historyStartRef,
|
||||
timelinePosRef,
|
||||
frameTimesRef,
|
||||
onTogglePlay,
|
||||
onFrameChange,
|
||||
onClose,
|
||||
}: HistoryReplayControllerProps) => {
|
||||
const hasSnap = effectiveSnapIdx >= 0;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'rgba(12,24,37,0.95)',
|
||||
border: '1px solid rgba(99,179,237,0.25)',
|
||||
borderRadius: 8,
|
||||
padding: '8px 14px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
zIndex: 20,
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: 10,
|
||||
color: '#e2e8f0',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
|
||||
pointerEvents: 'auto',
|
||||
minWidth: 360,
|
||||
}}>
|
||||
{/* 프로그레스 바 — 갭 표시 */}
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
height: 8,
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{snapshotRanges.map((pos, i) => (
|
||||
<div key={i} style={{
|
||||
position: 'absolute',
|
||||
left: `${pos * 100}%`,
|
||||
top: 0,
|
||||
width: 2,
|
||||
height: '100%',
|
||||
background: 'rgba(251,191,36,0.4)',
|
||||
}} />
|
||||
))}
|
||||
{/* 현재 위치 인디케이터 (DOM ref로 업데이트) */}
|
||||
<div ref={progressIndicatorRef} style={{
|
||||
position: 'absolute',
|
||||
left: '0%',
|
||||
top: -1,
|
||||
width: 3,
|
||||
height: 10,
|
||||
background: hasSnap ? '#fbbf24' : '#ef4444',
|
||||
borderRadius: 1,
|
||||
transform: 'translateX(-50%)',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* 컨트롤 행 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTogglePlay}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: '1px solid rgba(99,179,237,0.3)',
|
||||
borderRadius: 4,
|
||||
color: '#e2e8f0',
|
||||
cursor: 'pointer',
|
||||
padding: '2px 6px',
|
||||
fontSize: 12,
|
||||
fontFamily: FONT_MONO,
|
||||
}}
|
||||
>
|
||||
{isPlaying ? '⏸' : '▶'}
|
||||
</button>
|
||||
|
||||
<span
|
||||
ref={timeDisplayRef}
|
||||
style={{ color: hasSnap ? '#fbbf24' : '#ef4444', minWidth: 40, textAlign: 'center' }}
|
||||
>
|
||||
--:--
|
||||
</span>
|
||||
|
||||
<input
|
||||
ref={progressBarRef}
|
||||
type="range"
|
||||
min={0}
|
||||
max={1000}
|
||||
defaultValue={0}
|
||||
onChange={e => {
|
||||
timelinePosRef.current = Number(e.target.value) / 1000;
|
||||
// 수동 드래그 시 즉시 프레임 계산
|
||||
const t = historyStartRef.current + timelinePosRef.current * TIMELINE_DURATION_MS;
|
||||
const ft = frameTimesRef.current ?? [];
|
||||
let best = 0, bestDiff = Infinity;
|
||||
for (let i = 0; i < ft.length; i++) {
|
||||
const d = Math.abs(ft[i] - t);
|
||||
if (d < bestDiff) { bestDiff = d; best = i; }
|
||||
}
|
||||
onFrameChange(bestDiff < 1_800_000 ? best : -1);
|
||||
if (timeDisplayRef.current) {
|
||||
timeDisplayRef.current.textContent = new Date(t).toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
}}
|
||||
style={{ flex: 1, cursor: 'pointer', accentColor: '#fbbf24' }}
|
||||
title="히스토리 타임라인"
|
||||
aria-label="히스토리 타임라인"
|
||||
/>
|
||||
|
||||
<span style={{ color: '#64748b', fontSize: 9 }}>
|
||||
{historyData.length}건
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: '1px solid rgba(239,68,68,0.3)',
|
||||
borderRadius: 4,
|
||||
color: '#ef4444',
|
||||
cursor: 'pointer',
|
||||
padding: '2px 6px',
|
||||
fontSize: 11,
|
||||
fontFamily: FONT_MONO,
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HistoryReplayController;
|
||||
116
frontend/src/components/korea/fleetClusterConstants.ts
Normal file
116
frontend/src/components/korea/fleetClusterConstants.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { FONT_MONO } from '../../styles/fonts';
|
||||
|
||||
// ── 모델 순서/색상/설명 ──
|
||||
export const MODEL_ORDER = ['identity', 'default', 'aggressive', 'conservative', 'proximity-heavy', 'visit-pattern'] as const;
|
||||
|
||||
export const MODEL_COLORS: Record<string, string> = {
|
||||
'identity': '#f97316',
|
||||
'default': '#3b82f6',
|
||||
'aggressive': '#22c55e',
|
||||
'conservative': '#a855f7',
|
||||
'proximity-heavy': '#06b6d4',
|
||||
'visit-pattern': '#f43f5e',
|
||||
};
|
||||
|
||||
export const MODEL_DESC: Record<string, { summary: string; details: string[] }> = {
|
||||
'identity': {
|
||||
summary: '이름 패턴매칭 — 동일 모선명 기반 어구 그룹',
|
||||
details: [
|
||||
'패턴: NAME_인덱스 (_ 필수, 공백만은 선박)',
|
||||
'거리제한: ~10NM 이내 연결 클러스터링',
|
||||
'모선연결: 어구와 ~20NM 이내 시 포함',
|
||||
],
|
||||
},
|
||||
'default': {
|
||||
summary: '기본 모델 — 균형 가중치',
|
||||
details: [
|
||||
'어구-선박: 근접도 45% · 방문 35% · 활동동기화 20%',
|
||||
'선박-선박: DTW 30% · SOG 20% · COG 25% · 근접비 25%',
|
||||
'EMA: α 0.30→0.08 · 추적시작 50% · 폴리곤 70%',
|
||||
'감쇠: 정상 0.015/5분 · 장기(6h+) 0.08/5분',
|
||||
'근접판정: 5NM · 후보반경: 그룹×3배',
|
||||
],
|
||||
},
|
||||
'aggressive': {
|
||||
summary: '공격적 추적 — 빠른 상승, 약한 감쇠',
|
||||
details: [
|
||||
'어구-선박: 근접도 55% · 방문 25% · 활동동기화 20%',
|
||||
'EMA: α 0.40→0.10 · 추적시작 40% · 폴리곤 60%',
|
||||
'감쇠: 정상 0.010/5분 · 장기(8h+) 0.06/5분',
|
||||
'근접판정: 7NM · 후보반경: 그룹×4배',
|
||||
'야간보너스: ×1.5 · shadow: 체류+0.15 복귀+0.20',
|
||||
],
|
||||
},
|
||||
'conservative': {
|
||||
summary: '보수적 추적 — 높은 임계값, 강한 감쇠',
|
||||
details: [
|
||||
'어구-선박: 근접도 40% · 방문 40% · 활동동기화 20%',
|
||||
'EMA: α 0.20→0.05 · 추적시작 60% · 폴리곤 80%',
|
||||
'감쇠: 정상 0.020/5분 · 장기(4h+) 0.10/5분',
|
||||
'근접판정: 4NM · 후보반경: 그룹×2.5배',
|
||||
'야간보너스: ×1.2 · shadow: 체류+0.08 복귀+0.12',
|
||||
],
|
||||
},
|
||||
'proximity-heavy': {
|
||||
summary: '근접 중심 — 거리 기반 판단 우선',
|
||||
details: [
|
||||
'어구-선박: 근접도 70% · 방문 20% · 활동동기화 10%',
|
||||
'선박-선박: 근접비 50% · DTW 20% · SOG 15% · COG 15%',
|
||||
'EMA: α 0.30→0.08 · 추적시작 50% · 폴리곤 70%',
|
||||
'근접판정: 5NM · 후보반경: 그룹×3배',
|
||||
'shadow: 체류+0.12 복귀+0.18',
|
||||
],
|
||||
},
|
||||
'visit-pattern': {
|
||||
summary: '방문 패턴 — 반복 접근 추적',
|
||||
details: [
|
||||
'어구-선박: 근접도 25% · 방문 55% · 활동동기화 20%',
|
||||
'EMA: α 0.30→0.08 · 추적시작 50% · 폴리곤 70%',
|
||||
'근접판정: 6NM · 후보반경: 그룹×3.5배',
|
||||
'야간보너스: ×1.4',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// ── 패널 스타일 상수 ──
|
||||
export const panelStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
bottom: 60,
|
||||
left: 10,
|
||||
zIndex: 10,
|
||||
minWidth: 220,
|
||||
maxWidth: 300,
|
||||
backgroundColor: 'rgba(12, 24, 37, 0.92)',
|
||||
border: '1px solid rgba(99, 179, 237, 0.25)',
|
||||
borderRadius: 8,
|
||||
color: '#e2e8f0',
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: 11,
|
||||
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
pointerEvents: 'auto',
|
||||
maxHeight: 'min(45vh, 400px)',
|
||||
};
|
||||
|
||||
export const headerStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '6px 10px',
|
||||
borderBottom: 'none',
|
||||
cursor: 'default',
|
||||
userSelect: 'none',
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
export const toggleButtonStyle: React.CSSProperties = {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#94a3b8',
|
||||
cursor: 'pointer',
|
||||
fontSize: 10,
|
||||
padding: '0 2px',
|
||||
lineHeight: 1,
|
||||
};
|
||||
58
frontend/src/components/korea/fleetClusterTypes.ts
Normal file
58
frontend/src/components/korea/fleetClusterTypes.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import type { Ship, VesselAnalysisDto } from '../../types';
|
||||
import type { MemberInfo } from '../../services/vesselAnalysis';
|
||||
|
||||
// ── 히스토리 스냅샷 + 보간 플래그 ──
|
||||
export type HistoryFrame = GroupPolygonDto & { _interp?: boolean; _longGap?: boolean };
|
||||
|
||||
// ── 외부 노출 타입 (KoreaMap에서 import) ──
|
||||
export interface SelectedGearGroupData {
|
||||
parent: Ship | null;
|
||||
gears: Ship[];
|
||||
groupName: string;
|
||||
}
|
||||
|
||||
export interface SelectedFleetData {
|
||||
clusterId: number;
|
||||
ships: Ship[];
|
||||
companyName: string;
|
||||
}
|
||||
|
||||
// ── 내부 공유 타입 ──
|
||||
export interface HoverTooltipState {
|
||||
lng: number;
|
||||
lat: number;
|
||||
type: 'fleet' | 'gear';
|
||||
id: number | string;
|
||||
}
|
||||
|
||||
export interface PickerCandidate {
|
||||
name: string;
|
||||
count: number;
|
||||
inZone: boolean;
|
||||
isFleet: boolean;
|
||||
clusterId?: number;
|
||||
}
|
||||
|
||||
export interface GearPickerPopupState {
|
||||
lng: number;
|
||||
lat: number;
|
||||
candidates: PickerCandidate[];
|
||||
}
|
||||
|
||||
export interface FleetListItem {
|
||||
id: number;
|
||||
mmsiList: string[];
|
||||
label: string;
|
||||
memberCount: number;
|
||||
areaSqNm: number;
|
||||
color: string;
|
||||
members: MemberInfo[];
|
||||
}
|
||||
|
||||
// ── 상수 ──
|
||||
export const GEAR_BUFFER_DEG = 0.01;
|
||||
export const CIRCLE_SEGMENTS = 16;
|
||||
export const TIMELINE_DURATION_MS = 12 * 60 * 60_000; // 12시간
|
||||
export const PLAYBACK_CYCLE_SEC = 30; // 30초에 12시간 전체 재생
|
||||
export const TICK_MS = 50; // 50ms 간격 업데이트
|
||||
export const EMPTY_ANALYSIS = new globalThis.Map<string, VesselAnalysisDto>();
|
||||
204
frontend/src/components/korea/fleetClusterUtils.ts
Normal file
204
frontend/src/components/korea/fleetClusterUtils.ts
Normal file
@ -0,0 +1,204 @@
|
||||
import type { GeoJSON } from 'geojson';
|
||||
import type { GroupPolygonDto } from '../../services/vesselAnalysis';
|
||||
import type { HistoryFrame } from './fleetClusterTypes';
|
||||
import { GEAR_BUFFER_DEG, CIRCLE_SEGMENTS } from './fleetClusterTypes';
|
||||
|
||||
/** 트랙 포인트에서 주어진 시각의 보간 위치 반환 */
|
||||
export function interpolateTrackPosition(
|
||||
track: { ts: number; lat: number; lon: number; cog: number }[],
|
||||
timeMs: number,
|
||||
): { lat: number; lon: number; cog: number } | null {
|
||||
if (track.length === 0) return null;
|
||||
if (track.length === 1) return { lat: track[0].lat, lon: track[0].lon, cog: track[0].cog };
|
||||
if (timeMs <= track[0].ts) return { lat: track[0].lat, lon: track[0].lon, cog: track[0].cog };
|
||||
if (timeMs >= track[track.length - 1].ts) {
|
||||
const last = track[track.length - 1];
|
||||
return { lat: last.lat, lon: last.lon, cog: last.cog };
|
||||
}
|
||||
// Binary search for surrounding points
|
||||
let lo = 0, hi = track.length - 1;
|
||||
while (lo < hi - 1) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (track[mid].ts <= timeMs) lo = mid; else hi = mid;
|
||||
}
|
||||
const a = track[lo], b = track[hi];
|
||||
const t = (timeMs - a.ts) / (b.ts - a.ts);
|
||||
return {
|
||||
lat: a.lat + t * (b.lat - a.lat),
|
||||
lon: a.lon + t * (b.lon - a.lon),
|
||||
cog: b.cog,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convex hull + buffer 기반 폴리곤 생성 (Python polygon_builder.py 동일 로직)
|
||||
* - 1점: 원형 버퍼 (GEAR_BUFFER_DEG=0.01 ~ 1.1km)
|
||||
* - 2점: 두 점 잇는 직선 양쪽 버퍼
|
||||
* - 3점+: convex hull + 버퍼
|
||||
*/
|
||||
export function buildInterpPolygon(points: [number, number][]): GeoJSON.Polygon | null {
|
||||
if (points.length === 0) return null;
|
||||
|
||||
if (points.length === 1) {
|
||||
const [cx, cy] = points[0];
|
||||
const ring: [number, number][] = [];
|
||||
for (let i = 0; i <= CIRCLE_SEGMENTS; i++) {
|
||||
const angle = (2 * Math.PI * i) / CIRCLE_SEGMENTS;
|
||||
ring.push([cx + GEAR_BUFFER_DEG * Math.cos(angle), cy + GEAR_BUFFER_DEG * Math.sin(angle)]);
|
||||
}
|
||||
return { type: 'Polygon', coordinates: [ring] };
|
||||
}
|
||||
|
||||
if (points.length === 2) {
|
||||
const [p1, p2] = points;
|
||||
const dx = p2[0] - p1[0];
|
||||
const dy = p2[1] - p1[1];
|
||||
const len = Math.sqrt(dx * dx + dy * dy) || 1e-10;
|
||||
const nx = (-dy / len) * GEAR_BUFFER_DEG;
|
||||
const ny = (dx / len) * GEAR_BUFFER_DEG;
|
||||
const ring: [number, number][] = [];
|
||||
const half = CIRCLE_SEGMENTS / 2;
|
||||
ring.push([p1[0] + nx, p1[1] + ny]);
|
||||
ring.push([p2[0] + nx, p2[1] + ny]);
|
||||
const a2 = Math.atan2(ny, nx);
|
||||
for (let i = 0; i <= half; i++) {
|
||||
const angle = a2 - Math.PI * i / half;
|
||||
ring.push([p2[0] + GEAR_BUFFER_DEG * Math.cos(angle), p2[1] + GEAR_BUFFER_DEG * Math.sin(angle)]);
|
||||
}
|
||||
ring.push([p1[0] - nx, p1[1] - ny]);
|
||||
const a1 = Math.atan2(-ny, -nx);
|
||||
for (let i = 0; i <= half; i++) {
|
||||
const angle = a1 - Math.PI * i / half;
|
||||
ring.push([p1[0] + GEAR_BUFFER_DEG * Math.cos(angle), p1[1] + GEAR_BUFFER_DEG * Math.sin(angle)]);
|
||||
}
|
||||
ring.push(ring[0]);
|
||||
return { type: 'Polygon', coordinates: [ring] };
|
||||
}
|
||||
|
||||
const hull = convexHull(points);
|
||||
return bufferPolygon(hull, GEAR_BUFFER_DEG);
|
||||
}
|
||||
|
||||
/** 단순 convex hull (Graham scan) */
|
||||
export function convexHull(points: [number, number][]): [number, number][] {
|
||||
const pts = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||
if (pts.length <= 2) return pts;
|
||||
|
||||
const cross = (o: [number, number], a: [number, number], b: [number, number]) =>
|
||||
(a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
||||
|
||||
const lower: [number, number][] = [];
|
||||
for (const p of pts) {
|
||||
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop();
|
||||
lower.push(p);
|
||||
}
|
||||
const upper: [number, number][] = [];
|
||||
for (let i = pts.length - 1; i >= 0; i--) {
|
||||
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], pts[i]) <= 0) upper.pop();
|
||||
upper.push(pts[i]);
|
||||
}
|
||||
lower.pop();
|
||||
upper.pop();
|
||||
return lower.concat(upper);
|
||||
}
|
||||
|
||||
/** 폴리곤 외곽에 buffer 적용 (각 변의 오프셋 + 꼭짓점 라운드) */
|
||||
export function bufferPolygon(hull: [number, number][], buf: number): GeoJSON.Polygon {
|
||||
const ring: [number, number][] = [];
|
||||
const n = hull.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const p = hull[i];
|
||||
const prev = hull[(i - 1 + n) % n];
|
||||
const next = hull[(i + 1) % n];
|
||||
const a1 = Math.atan2(p[1] - prev[1], p[0] - prev[0]) - Math.PI / 2;
|
||||
const a2 = Math.atan2(next[1] - p[1], next[0] - p[0]) - Math.PI / 2;
|
||||
const startA = a1;
|
||||
let endA = a2;
|
||||
if (endA < startA) endA += 2 * Math.PI;
|
||||
const steps = Math.max(2, Math.round((endA - startA) / (Math.PI / 8)));
|
||||
for (let s = 0; s <= steps; s++) {
|
||||
const a = startA + (endA - startA) * s / steps;
|
||||
ring.push([p[0] + buf * Math.cos(a), p[1] + buf * Math.sin(a)]);
|
||||
}
|
||||
}
|
||||
ring.push(ring[0]);
|
||||
return { type: 'Polygon', coordinates: [ring] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 히스토리 데이터의 gap 구간에 보간 프레임을 삽입하여 완성 데이터셋 반환.
|
||||
* - gap <= 30분: 5분 간격 직선 보간 (이전 폴리곤 유지, 중심만 직선 이동)
|
||||
* - gap > 30분: 30분 간격으로 멤버 위치 보간 + 가상 폴리곤 생성
|
||||
*/
|
||||
export function fillGapFrames(snapshots: GroupPolygonDto[]): HistoryFrame[] {
|
||||
if (snapshots.length < 2) return snapshots;
|
||||
const STEP_SHORT_MS = 300_000;
|
||||
const STEP_LONG_MS = 1_800_000;
|
||||
const THRESHOLD_MS = 1_800_000;
|
||||
const result: GroupPolygonDto[] = [];
|
||||
|
||||
for (let i = 0; i < snapshots.length; i++) {
|
||||
result.push(snapshots[i]);
|
||||
if (i >= snapshots.length - 1) continue;
|
||||
|
||||
const prev = snapshots[i];
|
||||
const next = snapshots[i + 1];
|
||||
const t0 = new Date(prev.snapshotTime).getTime();
|
||||
const t1 = new Date(next.snapshotTime).getTime();
|
||||
const gap = t1 - t0;
|
||||
if (gap <= STEP_SHORT_MS) continue;
|
||||
|
||||
const nextMap = new Map(next.members.map(m => [m.mmsi, m]));
|
||||
const common = prev.members.filter(m => nextMap.has(m.mmsi));
|
||||
if (common.length === 0) continue;
|
||||
|
||||
if (gap <= THRESHOLD_MS) {
|
||||
for (let t = t0 + STEP_SHORT_MS; t < t1; t += STEP_SHORT_MS) {
|
||||
const ratio = (t - t0) / gap;
|
||||
const cLon = prev.centerLon + (next.centerLon - prev.centerLon) * ratio;
|
||||
const cLat = prev.centerLat + (next.centerLat - prev.centerLat) * ratio;
|
||||
result.push({
|
||||
...prev,
|
||||
snapshotTime: new Date(t).toISOString(),
|
||||
centerLon: cLon,
|
||||
centerLat: cLat,
|
||||
_interp: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (let t = t0 + STEP_LONG_MS; t < t1; t += STEP_LONG_MS) {
|
||||
const ratio = (t - t0) / gap;
|
||||
const positions: [number, number][] = [];
|
||||
const members: typeof prev.members = [];
|
||||
|
||||
for (const pm of common) {
|
||||
const nm = nextMap.get(pm.mmsi)!;
|
||||
const lon = pm.lon + (nm.lon - pm.lon) * ratio;
|
||||
const lat = pm.lat + (nm.lat - pm.lat) * ratio;
|
||||
const dLon = nm.lon - pm.lon;
|
||||
const dLat = nm.lat - pm.lat;
|
||||
const cog = (dLon === 0 && dLat === 0) ? pm.cog : (Math.atan2(dLon, dLat) * 180 / Math.PI + 360) % 360;
|
||||
members.push({ mmsi: pm.mmsi, name: pm.name, lon, lat, sog: pm.sog, cog, role: pm.role, isParent: pm.isParent });
|
||||
positions.push([lon, lat]);
|
||||
}
|
||||
|
||||
const cLon = positions.reduce((s, p) => s + p[0], 0) / positions.length;
|
||||
const cLat = positions.reduce((s, p) => s + p[1], 0) / positions.length;
|
||||
const polygon = buildInterpPolygon(positions);
|
||||
|
||||
result.push({
|
||||
...prev,
|
||||
snapshotTime: new Date(t).toISOString(),
|
||||
polygon,
|
||||
centerLon: cLon,
|
||||
centerLat: cLat,
|
||||
memberCount: members.length,
|
||||
members,
|
||||
_interp: true,
|
||||
_longGap: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
618
frontend/src/components/korea/useFleetClusterGeoJson.ts
Normal file
618
frontend/src/components/korea/useFleetClusterGeoJson.ts
Normal file
@ -0,0 +1,618 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { GeoJSON } from 'geojson';
|
||||
import type { Ship, VesselAnalysisDto } from '../../types';
|
||||
import type { GearCorrelationItem, CorrelationVesselTrack } from '../../services/vesselAnalysis';
|
||||
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
|
||||
import type { HistoryFrame, FleetListItem } from './fleetClusterTypes';
|
||||
import { TIMELINE_DURATION_MS } from './fleetClusterTypes';
|
||||
import { interpolateTrackPosition, buildInterpPolygon } from './fleetClusterUtils';
|
||||
import { MODEL_ORDER, MODEL_COLORS } from './fleetClusterConstants';
|
||||
|
||||
export interface UseFleetClusterGeoJsonParams {
|
||||
ships: Ship[];
|
||||
shipMap: Map<string, Ship>;
|
||||
groupPolygons: UseGroupPolygonsResult | undefined;
|
||||
analysisMap: Map<string, VesselAnalysisDto>;
|
||||
hoveredFleetId: number | null;
|
||||
selectedGearGroup: string | null;
|
||||
pickerHoveredGroup: string | null;
|
||||
historyData: HistoryFrame[] | null;
|
||||
effectiveSnapIdx: number;
|
||||
correlationData: GearCorrelationItem[];
|
||||
correlationTracks: CorrelationVesselTrack[];
|
||||
enabledModels: Set<string>;
|
||||
enabledVessels: Set<string>;
|
||||
hoveredMmsi: string | null;
|
||||
historyStartMs: number;
|
||||
}
|
||||
|
||||
export interface FleetClusterGeoJsonResult {
|
||||
// static/base GeoJSON
|
||||
fleetPolygonGeoJSON: GeoJSON;
|
||||
lineGeoJSON: GeoJSON;
|
||||
hoveredGeoJSON: GeoJSON;
|
||||
gearClusterGeoJson: GeoJSON;
|
||||
memberMarkersGeoJson: GeoJSON;
|
||||
pickerHighlightGeoJson: GeoJSON;
|
||||
selectedGearHighlightGeoJson: GeoJSON.FeatureCollection | null;
|
||||
// history animation GeoJSON
|
||||
memberTrailsGeoJson: GeoJSON;
|
||||
centerTrailGeoJson: GeoJSON;
|
||||
currentCenterGeoJson: GeoJSON;
|
||||
animPolygonGeoJson: GeoJSON;
|
||||
animMembersGeoJson: GeoJSON;
|
||||
// correlation GeoJSON
|
||||
correlationVesselGeoJson: GeoJSON;
|
||||
correlationTrailGeoJson: GeoJSON;
|
||||
modelBadgesGeoJson: GeoJSON;
|
||||
hoverHighlightGeoJson: GeoJSON;
|
||||
hoverHighlightTrailGeoJson: GeoJSON;
|
||||
// operational polygons (per model)
|
||||
operationalPolygons: { modelName: string; color: string; geojson: GeoJSON.FeatureCollection }[];
|
||||
// derived values
|
||||
fleetList: FleetListItem[];
|
||||
currentFrame: HistoryFrame | null;
|
||||
showGray: boolean;
|
||||
isStale: boolean;
|
||||
snapshotRanges: number[];
|
||||
correlationByModel: Map<string, GearCorrelationItem[]>;
|
||||
availableModels: { name: string; count: number; isDefault: boolean }[];
|
||||
}
|
||||
|
||||
const EMPTY_FC: GeoJSON = { type: 'FeatureCollection', features: [] };
|
||||
|
||||
export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): FleetClusterGeoJsonResult {
|
||||
const {
|
||||
ships,
|
||||
shipMap,
|
||||
groupPolygons,
|
||||
hoveredFleetId,
|
||||
selectedGearGroup,
|
||||
pickerHoveredGroup,
|
||||
historyData,
|
||||
effectiveSnapIdx,
|
||||
correlationData,
|
||||
correlationTracks,
|
||||
enabledModels,
|
||||
enabledVessels,
|
||||
hoveredMmsi,
|
||||
historyStartMs,
|
||||
} = params;
|
||||
|
||||
// ── 선단 폴리곤 GeoJSON (서버 제공) ──
|
||||
const fleetPolygonGeoJSON = useMemo((): GeoJSON => {
|
||||
const features: GeoJSON.Feature[] = [];
|
||||
if (!groupPolygons) return { type: 'FeatureCollection', features };
|
||||
for (const g of groupPolygons.fleetGroups) {
|
||||
if (!g.polygon) continue;
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: { clusterId: Number(g.groupKey), color: g.color },
|
||||
geometry: g.polygon,
|
||||
});
|
||||
}
|
||||
return { type: 'FeatureCollection', features };
|
||||
}, [groupPolygons]);
|
||||
|
||||
// 2척 선단 라인은 서버에서 Shapely buffer로 이미 Polygon 처리되므로 빈 컬렉션
|
||||
const lineGeoJSON = useMemo((): GeoJSON => ({
|
||||
type: 'FeatureCollection', features: [],
|
||||
}), []);
|
||||
|
||||
// 호버 하이라이트용 단일 폴리곤
|
||||
const hoveredGeoJSON = useMemo((): GeoJSON => {
|
||||
if (hoveredFleetId === null || !groupPolygons) return EMPTY_FC;
|
||||
const g = groupPolygons.fleetGroups.find(f => Number(f.groupKey) === hoveredFleetId);
|
||||
if (!g?.polygon) return EMPTY_FC;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: [{
|
||||
type: 'Feature',
|
||||
properties: { clusterId: hoveredFleetId, color: g.color },
|
||||
geometry: g.polygon,
|
||||
}],
|
||||
};
|
||||
}, [hoveredFleetId, groupPolygons]);
|
||||
|
||||
// 모델별 연관성 데이터 그룹핑
|
||||
const correlationByModel = useMemo(() => {
|
||||
const map = new Map<string, GearCorrelationItem[]>();
|
||||
for (const c of correlationData) {
|
||||
const list = map.get(c.modelName) ?? [];
|
||||
list.push(c);
|
||||
map.set(c.modelName, list);
|
||||
}
|
||||
return map;
|
||||
}, [correlationData]);
|
||||
|
||||
// 사용 가능한 모델 목록 (데이터가 있는 모델만)
|
||||
const availableModels = useMemo(() => {
|
||||
const models: { name: string; count: number; isDefault: boolean }[] = [];
|
||||
for (const [name, items] of correlationByModel) {
|
||||
models.push({ name, count: items.length, isDefault: items[0]?.isDefault ?? false });
|
||||
}
|
||||
models.sort((a, b) => (a.isDefault ? -1 : 0) - (b.isDefault ? -1 : 0));
|
||||
return models;
|
||||
}, [correlationByModel]);
|
||||
|
||||
// 현재 프레임 및 파생 상태
|
||||
const currentFrame = historyData && effectiveSnapIdx >= 0 ? historyData[effectiveSnapIdx] : null;
|
||||
const showGray = !!currentFrame?._longGap || !!currentFrame?._interp;
|
||||
const isStale = showGray;
|
||||
|
||||
// 스냅샷 존재 구간 맵 (프로그레스 바 갭 표시용)
|
||||
const snapshotRanges = useMemo(() => {
|
||||
if (!historyData) return [];
|
||||
return historyData
|
||||
.filter(h => !h._interp)
|
||||
.map(h => {
|
||||
const t = new Date(h.snapshotTime).getTime();
|
||||
return (t - historyStartMs) / TIMELINE_DURATION_MS;
|
||||
});
|
||||
}, [historyData, historyStartMs]);
|
||||
|
||||
// ── 사전계산: 각 프레임별 연관 대상 보간 위치 ──
|
||||
const correlationPosMap = useMemo(() => {
|
||||
if (!historyData || correlationTracks.length === 0) return null;
|
||||
const trackMap = new Map(correlationTracks.map(v => [v.mmsi, v.track]));
|
||||
return historyData.map(snap => {
|
||||
const t = new Date(snap.snapshotTime).getTime();
|
||||
const m = new Map<string, { lon: number; lat: number; cog: number }>();
|
||||
for (const [mmsi, track] of trackMap) {
|
||||
const p = interpolateTrackPosition(track, t);
|
||||
if (p) m.set(mmsi, p);
|
||||
}
|
||||
return m;
|
||||
});
|
||||
}, [historyData, correlationTracks]);
|
||||
|
||||
// 사전계산: 각 프레임별 트레일 클립 인덱스
|
||||
const trailClipMap = useMemo(() => {
|
||||
if (!historyData || correlationTracks.length === 0) return null;
|
||||
return historyData.map(snap => {
|
||||
const t = new Date(snap.snapshotTime).getTime();
|
||||
const m = new Map<string, number>();
|
||||
for (const vt of correlationTracks) {
|
||||
let idx = vt.track.length;
|
||||
for (let i = 0; i < vt.track.length; i++) {
|
||||
if (vt.track[i].ts > t) { idx = i; break; }
|
||||
}
|
||||
m.set(vt.mmsi, idx);
|
||||
}
|
||||
return m;
|
||||
});
|
||||
}, [historyData, correlationTracks]);
|
||||
|
||||
// 사전계산: 각 프레임별 오퍼레이셔널 폴리곤
|
||||
const operationalPolygonsByFrame = useMemo(() => {
|
||||
if (!historyData || !selectedGearGroup || !groupPolygons) return null;
|
||||
const allGroups = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
|
||||
const group = allGroups.find(g => g.groupKey === selectedGearGroup);
|
||||
if (!group) return null;
|
||||
|
||||
return historyData.map((snap, fi) => {
|
||||
const basePts: [number, number][] = snap.members.map(m => [m.lon, m.lat]);
|
||||
const positions = correlationPosMap?.[fi];
|
||||
const result: { modelName: string; color: string; geojson: GeoJSON.FeatureCollection }[] = [];
|
||||
|
||||
for (const [mn, items] of correlationByModel) {
|
||||
if (!enabledModels.has(mn)) continue;
|
||||
const extra: [number, number][] = [];
|
||||
for (const c of items) {
|
||||
if (c.score < 0.7) continue;
|
||||
const p = positions?.get(c.targetMmsi);
|
||||
if (p) extra.push([p.lon, p.lat]);
|
||||
}
|
||||
if (extra.length === 0) continue;
|
||||
const polygon = buildInterpPolygon([...basePts, ...extra]);
|
||||
if (!polygon) continue;
|
||||
const color = MODEL_COLORS[mn] ?? '#94a3b8';
|
||||
result.push({
|
||||
modelName: mn,
|
||||
color,
|
||||
geojson: {
|
||||
type: 'FeatureCollection',
|
||||
features: [{ type: 'Feature', properties: { modelName: mn, color }, geometry: polygon }],
|
||||
},
|
||||
});
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}, [historyData, selectedGearGroup, groupPolygons, correlationByModel, enabledModels, correlationPosMap]);
|
||||
|
||||
// 재생 시 O(1) 룩업, 비재생 시 기존 로직
|
||||
const operationalPolygons = useMemo(() => {
|
||||
if (operationalPolygonsByFrame && effectiveSnapIdx >= 0) {
|
||||
return operationalPolygonsByFrame[effectiveSnapIdx] ?? [];
|
||||
}
|
||||
if (!selectedGearGroup || !groupPolygons) return [];
|
||||
const allGroups = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
|
||||
const group = allGroups.find(g => g.groupKey === selectedGearGroup);
|
||||
if (!group) return [];
|
||||
const basePts: [number, number][] = group.members.map(m => [m.lon, m.lat]);
|
||||
const result: { modelName: string; color: string; geojson: GeoJSON.FeatureCollection }[] = [];
|
||||
for (const [mn, items] of correlationByModel) {
|
||||
if (!enabledModels.has(mn)) continue;
|
||||
const extra: [number, number][] = [];
|
||||
for (const c of items) {
|
||||
if (c.score < 0.7) continue;
|
||||
const s = ships.find(x => x.mmsi === c.targetMmsi);
|
||||
if (s) extra.push([s.lng, s.lat]);
|
||||
}
|
||||
if (extra.length === 0) continue;
|
||||
const polygon = buildInterpPolygon([...basePts, ...extra]);
|
||||
if (!polygon) continue;
|
||||
const color = MODEL_COLORS[mn] ?? '#94a3b8';
|
||||
result.push({
|
||||
modelName: mn,
|
||||
color,
|
||||
geojson: {
|
||||
type: 'FeatureCollection',
|
||||
features: [{ type: 'Feature', properties: { modelName: mn, color }, geometry: polygon }],
|
||||
},
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [operationalPolygonsByFrame, effectiveSnapIdx, selectedGearGroup, groupPolygons, correlationByModel, enabledModels, ships]);
|
||||
|
||||
// 어구 클러스터 GeoJSON (서버 제공)
|
||||
const gearClusterGeoJson = useMemo((): GeoJSON => {
|
||||
const features: GeoJSON.Feature[] = [];
|
||||
if (!groupPolygons) return { type: 'FeatureCollection', features };
|
||||
for (const g of [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]) {
|
||||
if (!g.polygon) continue;
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
name: g.groupKey,
|
||||
gearCount: g.memberCount,
|
||||
inZone: g.groupType === 'GEAR_IN_ZONE' ? 1 : 0,
|
||||
},
|
||||
geometry: g.polygon,
|
||||
});
|
||||
}
|
||||
return { type: 'FeatureCollection', features };
|
||||
}, [groupPolygons]);
|
||||
|
||||
// 가상 선박 마커 GeoJSON (API members + shipMap heading 보정)
|
||||
const memberMarkersGeoJson = useMemo((): GeoJSON => {
|
||||
const features: GeoJSON.Feature[] = [];
|
||||
if (!groupPolygons) return { type: 'FeatureCollection', features };
|
||||
|
||||
const addMember = (
|
||||
m: { mmsi: string; name: string; lat: number; lon: number; cog: number; isParent: boolean; role: string },
|
||||
groupKey: string,
|
||||
groupType: string,
|
||||
color: string,
|
||||
) => {
|
||||
const realShip = shipMap.get(m.mmsi);
|
||||
const heading = realShip?.heading ?? m.cog ?? 0;
|
||||
const lat = realShip?.lat ?? m.lat;
|
||||
const lon = realShip?.lng ?? m.lon;
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
mmsi: m.mmsi,
|
||||
name: m.name,
|
||||
groupKey,
|
||||
groupType,
|
||||
role: m.role,
|
||||
isParent: m.isParent ? 1 : 0,
|
||||
isGear: (groupType !== 'FLEET' && !m.isParent) ? 1 : 0,
|
||||
color,
|
||||
cog: heading,
|
||||
baseSize: (groupType !== 'FLEET' && !m.isParent) ? 0.11 : m.isParent ? 0.18 : 0.14,
|
||||
},
|
||||
geometry: { type: 'Point', coordinates: [lon, lat] },
|
||||
});
|
||||
};
|
||||
|
||||
for (const g of groupPolygons.fleetGroups) {
|
||||
for (const m of g.members) addMember(m, g.groupKey, 'FLEET', g.color);
|
||||
}
|
||||
for (const g of [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]) {
|
||||
const color = g.groupType === 'GEAR_IN_ZONE' ? '#dc2626' : '#f97316';
|
||||
for (const m of g.members) addMember(m, g.groupKey, g.groupType, color);
|
||||
}
|
||||
return { type: 'FeatureCollection', features };
|
||||
}, [groupPolygons, shipMap]);
|
||||
|
||||
// picker 호버 하이라이트 (선단 + 어구 통합)
|
||||
const pickerHighlightGeoJson = useMemo((): GeoJSON => {
|
||||
if (!pickerHoveredGroup || !groupPolygons) return EMPTY_FC;
|
||||
const fleet = groupPolygons.fleetGroups.find(x => String(x.groupKey) === pickerHoveredGroup);
|
||||
if (fleet?.polygon) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: fleet.polygon }] };
|
||||
const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
|
||||
const g = all.find(x => x.groupKey === pickerHoveredGroup);
|
||||
if (!g?.polygon) return EMPTY_FC;
|
||||
return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: g.polygon }] };
|
||||
}, [pickerHoveredGroup, groupPolygons]);
|
||||
|
||||
// 선택된 어구 그룹 하이라이트 폴리곤 (JSX IIFE → useMemo)
|
||||
const selectedGearHighlightGeoJson = useMemo((): GeoJSON.FeatureCollection | null => {
|
||||
if (!selectedGearGroup || !enabledModels.has('identity') || historyData) return null;
|
||||
const allGroups = groupPolygons
|
||||
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
|
||||
: [];
|
||||
const group = allGroups.find(g => g.groupKey === selectedGearGroup);
|
||||
if (!group?.polygon) return null;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: [{
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: group.polygon,
|
||||
}],
|
||||
};
|
||||
}, [selectedGearGroup, enabledModels, historyData, groupPolygons]);
|
||||
|
||||
// ── 히스토리 애니메이션 GeoJSON ──
|
||||
const memberTrailsGeoJson = useMemo((): GeoJSON => {
|
||||
if (!historyData) return EMPTY_FC;
|
||||
const tracks = new Map<string, [number, number][]>();
|
||||
for (const snap of historyData) {
|
||||
for (const m of snap.members) {
|
||||
const arr = tracks.get(m.mmsi) ?? [];
|
||||
arr.push([m.lon, m.lat]);
|
||||
tracks.set(m.mmsi, arr);
|
||||
}
|
||||
}
|
||||
const features: GeoJSON.Feature[] = [];
|
||||
for (const [, coords] of tracks) {
|
||||
if (coords.length < 2) continue;
|
||||
features.push({ type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } });
|
||||
}
|
||||
return { type: 'FeatureCollection', features };
|
||||
}, [historyData]);
|
||||
|
||||
// center trail: historyData에 이미 보간 프레임 포함 → 전체 좌표 연결
|
||||
const centerTrailGeoJson = useMemo((): GeoJSON => {
|
||||
if (!historyData || historyData.length === 0) return EMPTY_FC;
|
||||
|
||||
const features: GeoJSON.Feature[] = [];
|
||||
|
||||
let segStart = 0;
|
||||
for (let i = 1; i <= historyData.length; i++) {
|
||||
const curInterp = i < historyData.length && !!historyData[i]._longGap;
|
||||
const startInterp = !!historyData[segStart]._longGap;
|
||||
if (i < historyData.length && curInterp === startInterp) continue;
|
||||
|
||||
const from = segStart > 0 ? segStart - 1 : segStart;
|
||||
const seg = historyData.slice(from, i);
|
||||
if (seg.length >= 2) {
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: { interpolated: startInterp ? 1 : 0 },
|
||||
geometry: { type: 'LineString', coordinates: seg.map(s => [s.centerLon, s.centerLat]) },
|
||||
});
|
||||
}
|
||||
segStart = i;
|
||||
}
|
||||
|
||||
for (const h of historyData) {
|
||||
if (h.color === '#94a3b8') continue;
|
||||
features.push({
|
||||
type: 'Feature', properties: { interpolated: 0 },
|
||||
geometry: { type: 'Point', coordinates: [h.centerLon, h.centerLat] },
|
||||
});
|
||||
}
|
||||
|
||||
return { type: 'FeatureCollection', features };
|
||||
}, [historyData]);
|
||||
|
||||
// 현재 재생 위치 포인트
|
||||
const currentCenterGeoJson = useMemo((): GeoJSON => {
|
||||
if (!historyData || effectiveSnapIdx < 0) return EMPTY_FC;
|
||||
const snap = historyData[effectiveSnapIdx];
|
||||
if (!snap) return EMPTY_FC;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: [{
|
||||
type: 'Feature',
|
||||
properties: { interpolated: showGray ? 1 : 0 },
|
||||
geometry: { type: 'Point', coordinates: [snap.centerLon, snap.centerLat] },
|
||||
}],
|
||||
};
|
||||
}, [historyData, effectiveSnapIdx, showGray]);
|
||||
|
||||
const animPolygonGeoJson = useMemo((): GeoJSON => {
|
||||
if (!historyData || effectiveSnapIdx < 0) return EMPTY_FC;
|
||||
const snap = historyData[effectiveSnapIdx];
|
||||
if (!snap?.polygon) return EMPTY_FC;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: [{ type: 'Feature', properties: { stale: isStale ? 1 : 0, interpolated: showGray ? 1 : 0 }, geometry: snap.polygon }],
|
||||
};
|
||||
}, [historyData, effectiveSnapIdx, isStale, showGray]);
|
||||
|
||||
// 현재 프레임의 멤버 위치 (가상 아이콘)
|
||||
const animMembersGeoJson = useMemo((): GeoJSON => {
|
||||
if (!historyData || effectiveSnapIdx < 0) return EMPTY_FC;
|
||||
const snap = historyData[effectiveSnapIdx];
|
||||
if (!snap) return EMPTY_FC;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: snap.members.map(m => ({
|
||||
type: 'Feature' as const,
|
||||
properties: { mmsi: m.mmsi, name: m.name, cog: m.cog ?? 0, role: m.role, isGear: m.role === 'GEAR' ? 1 : 0, stale: isStale ? 1 : 0, interpolated: showGray ? 1 : 0 },
|
||||
geometry: { type: 'Point' as const, coordinates: [m.lon, m.lat] },
|
||||
})),
|
||||
};
|
||||
}, [historyData, effectiveSnapIdx, isStale, showGray]);
|
||||
|
||||
// ── 연관 대상 마커 (사전계산 룩업 or ships fallback) ──
|
||||
const correlationVesselGeoJson = useMemo((): GeoJSON => {
|
||||
if (!selectedGearGroup || correlationByModel.size === 0) return EMPTY_FC;
|
||||
const positions = correlationPosMap && effectiveSnapIdx >= 0 ? correlationPosMap[effectiveSnapIdx] : null;
|
||||
const features: GeoJSON.Feature[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const [mn, items] of correlationByModel) {
|
||||
if (!enabledModels.has(mn)) continue;
|
||||
const color = MODEL_COLORS[mn] ?? '#94a3b8';
|
||||
for (const c of items) {
|
||||
if (seen.has(c.targetMmsi)) continue;
|
||||
let lon: number | undefined, lat: number | undefined, cog = 0;
|
||||
const cached = positions?.get(c.targetMmsi);
|
||||
if (cached) { lon = cached.lon; lat = cached.lat; cog = cached.cog; }
|
||||
if (lon === undefined) {
|
||||
const s = ships.find(x => x.mmsi === c.targetMmsi);
|
||||
if (s) { lon = s.lng; lat = s.lat; cog = s.course ?? 0; }
|
||||
}
|
||||
if (lon === undefined || lat === undefined) continue;
|
||||
seen.add(c.targetMmsi);
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: { mmsi: c.targetMmsi, name: c.targetName || c.targetMmsi, score: c.score, cog, color, isVessel: c.targetType === 'VESSEL' ? 1 : 0 },
|
||||
geometry: { type: 'Point', coordinates: [lon, lat] },
|
||||
});
|
||||
}
|
||||
}
|
||||
return { type: 'FeatureCollection', features };
|
||||
}, [selectedGearGroup, correlationByModel, enabledModels, correlationPosMap, effectiveSnapIdx, ships]);
|
||||
|
||||
// 연관 대상 트레일 (사전계산 클립 인덱스 룩업)
|
||||
const correlationTrailGeoJson = useMemo((): GeoJSON => {
|
||||
if (correlationTracks.length === 0) return EMPTY_FC;
|
||||
const clips = trailClipMap && effectiveSnapIdx >= 0 ? trailClipMap[effectiveSnapIdx] : null;
|
||||
const features: GeoJSON.Feature[] = [];
|
||||
const vesselColor = new Map<string, string>();
|
||||
for (const [mn, items] of correlationByModel) {
|
||||
if (!enabledModels.has(mn)) continue;
|
||||
for (const c of items) {
|
||||
if (!vesselColor.has(c.targetMmsi)) vesselColor.set(c.targetMmsi, MODEL_COLORS[mn] ?? '#60a5fa');
|
||||
}
|
||||
}
|
||||
for (const vt of correlationTracks) {
|
||||
if (!enabledVessels.has(vt.mmsi)) continue;
|
||||
const color = vesselColor.get(vt.mmsi) ?? '#60a5fa';
|
||||
const clipIdx = clips?.get(vt.mmsi) ?? vt.track.length;
|
||||
const coords: [number, number][] = vt.track.slice(0, clipIdx).map(pt => [pt.lon, pt.lat]);
|
||||
if (coords.length >= 2) {
|
||||
features.push({ type: 'Feature', properties: { mmsi: vt.mmsi, color }, geometry: { type: 'LineString', coordinates: coords } });
|
||||
}
|
||||
}
|
||||
return { type: 'FeatureCollection', features };
|
||||
}, [correlationTracks, enabledVessels, correlationByModel, enabledModels, trailClipMap, effectiveSnapIdx]);
|
||||
|
||||
// 모델 배지 GeoJSON (사전계산 위치 룩업)
|
||||
const modelBadgesGeoJson = useMemo((): GeoJSON => {
|
||||
if (!selectedGearGroup) return EMPTY_FC;
|
||||
const positions = correlationPosMap && effectiveSnapIdx >= 0 ? correlationPosMap[effectiveSnapIdx] : null;
|
||||
const targets = new Map<string, { lon: number; lat: number; models: Set<string> }>();
|
||||
|
||||
if (enabledModels.has('identity')) {
|
||||
const members = (historyData && effectiveSnapIdx >= 0)
|
||||
? historyData[effectiveSnapIdx].members
|
||||
: (() => {
|
||||
if (!groupPolygons) return [];
|
||||
const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
|
||||
return all.find(g => g.groupKey === selectedGearGroup)?.members ?? [];
|
||||
})();
|
||||
for (const m of members) {
|
||||
const e = targets.get(m.mmsi) ?? { lon: m.lon, lat: m.lat, models: new Set<string>() };
|
||||
e.lon = m.lon; e.lat = m.lat; e.models.add('identity');
|
||||
targets.set(m.mmsi, e);
|
||||
}
|
||||
}
|
||||
for (const [mn, items] of correlationByModel) {
|
||||
if (!enabledModels.has(mn)) continue;
|
||||
for (const c of items) {
|
||||
if (c.score < 0.3) continue;
|
||||
let lon: number | undefined, lat: number | undefined;
|
||||
const cached = positions?.get(c.targetMmsi);
|
||||
if (cached) { lon = cached.lon; lat = cached.lat; }
|
||||
if (lon === undefined) { const s = ships.find(x => x.mmsi === c.targetMmsi); if (s) { lon = s.lng; lat = s.lat; } }
|
||||
if (lon !== undefined && lat !== undefined) {
|
||||
const e = targets.get(c.targetMmsi) ?? { lon, lat, models: new Set<string>() };
|
||||
e.lon = lon; e.lat = lat; e.models.add(mn);
|
||||
targets.set(c.targetMmsi, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
const features: GeoJSON.Feature[] = [];
|
||||
for (const [mmsi, t] of targets) {
|
||||
if (t.models.size === 0) continue;
|
||||
const props: Record<string, unknown> = { mmsi };
|
||||
for (let i = 0; i < MODEL_ORDER.length; i++) props[`m${i}`] = t.models.has(MODEL_ORDER[i]) ? 1 : 0;
|
||||
features.push({ type: 'Feature', properties: props, geometry: { type: 'Point', coordinates: [t.lon, t.lat] } });
|
||||
}
|
||||
return { type: 'FeatureCollection', features };
|
||||
}, [selectedGearGroup, enabledModels, historyData, effectiveSnapIdx, groupPolygons,
|
||||
correlationByModel, correlationPosMap, ships]);
|
||||
|
||||
// 호버 하이라이트 — 대상 위치 (사전계산 룩업)
|
||||
const hoverHighlightGeoJson = useMemo((): GeoJSON => {
|
||||
if (!hoveredMmsi || !selectedGearGroup) return EMPTY_FC;
|
||||
const positions = correlationPosMap && effectiveSnapIdx >= 0 ? correlationPosMap[effectiveSnapIdx] : null;
|
||||
if (historyData && effectiveSnapIdx >= 0) {
|
||||
const m = historyData[effectiveSnapIdx].members.find(x => x.mmsi === hoveredMmsi);
|
||||
if (m) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [m.lon, m.lat] } }] };
|
||||
}
|
||||
const cached = positions?.get(hoveredMmsi);
|
||||
if (cached) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [cached.lon, cached.lat] } }] };
|
||||
if (groupPolygons) {
|
||||
const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
|
||||
const m = all.find(g => g.groupKey === selectedGearGroup)?.members.find(x => x.mmsi === hoveredMmsi);
|
||||
if (m) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [m.lon, m.lat] } }] };
|
||||
}
|
||||
const s = ships.find(x => x.mmsi === hoveredMmsi);
|
||||
if (s) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [s.lng, s.lat] } }] };
|
||||
return EMPTY_FC;
|
||||
}, [hoveredMmsi, selectedGearGroup, historyData, effectiveSnapIdx, correlationPosMap, groupPolygons, ships]);
|
||||
|
||||
// 호버 하이라이트 — 대상 항적 (사전계산 클립 룩업)
|
||||
const hoverHighlightTrailGeoJson = useMemo((): GeoJSON => {
|
||||
if (!hoveredMmsi) return EMPTY_FC;
|
||||
const vt = correlationTracks.find(v => v.mmsi === hoveredMmsi);
|
||||
if (!vt) return EMPTY_FC;
|
||||
const clipIdx = trailClipMap && effectiveSnapIdx >= 0
|
||||
? (trailClipMap[effectiveSnapIdx]?.get(hoveredMmsi) ?? vt.track.length)
|
||||
: vt.track.length;
|
||||
const coords: [number, number][] = vt.track.slice(0, clipIdx).map(pt => [pt.lon, pt.lat]);
|
||||
if (coords.length < 2) return EMPTY_FC;
|
||||
return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } }] };
|
||||
}, [hoveredMmsi, correlationTracks, trailClipMap, effectiveSnapIdx]);
|
||||
|
||||
// 선단 목록 (멤버 수 내림차순)
|
||||
const fleetList = useMemo((): FleetListItem[] => {
|
||||
if (!groupPolygons) return [];
|
||||
return groupPolygons.fleetGroups.map(g => ({
|
||||
id: Number(g.groupKey),
|
||||
mmsiList: g.members.map(m => m.mmsi),
|
||||
label: g.groupLabel,
|
||||
memberCount: g.memberCount,
|
||||
areaSqNm: g.areaSqNm,
|
||||
color: g.color,
|
||||
members: g.members,
|
||||
})).sort((a, b) => b.memberCount - a.memberCount);
|
||||
}, [groupPolygons]);
|
||||
|
||||
return {
|
||||
fleetPolygonGeoJSON,
|
||||
lineGeoJSON,
|
||||
hoveredGeoJSON,
|
||||
gearClusterGeoJson,
|
||||
memberMarkersGeoJson,
|
||||
pickerHighlightGeoJson,
|
||||
selectedGearHighlightGeoJson,
|
||||
memberTrailsGeoJson,
|
||||
centerTrailGeoJson,
|
||||
currentCenterGeoJson,
|
||||
animPolygonGeoJson,
|
||||
animMembersGeoJson,
|
||||
correlationVesselGeoJson,
|
||||
correlationTrailGeoJson,
|
||||
modelBadgesGeoJson,
|
||||
hoverHighlightGeoJson,
|
||||
hoverHighlightTrailGeoJson,
|
||||
operationalPolygons,
|
||||
fleetList,
|
||||
currentFrame,
|
||||
showGray,
|
||||
isStale,
|
||||
snapshotRanges,
|
||||
correlationByModel,
|
||||
availableModels,
|
||||
};
|
||||
}
|
||||
452
frontend/src/hooks/useGearReplayLayers.ts
Normal file
452
frontend/src/hooks/useGearReplayLayers.ts
Normal file
@ -0,0 +1,452 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import type { Layer } from '@deck.gl/core';
|
||||
import { TripsLayer } from '@deck.gl/geo-layers';
|
||||
import { ScatterplotLayer, PathLayer, PolygonLayer, TextLayer } from '@deck.gl/layers';
|
||||
import { useGearReplayStore } from '../stores/gearReplayStore';
|
||||
import { findFrameAtTime, interpolateMemberPositions } from '../stores/gearReplayPreprocess';
|
||||
import type { MemberPosition } from '../stores/gearReplayPreprocess';
|
||||
import { MODEL_COLORS } from '../components/korea/fleetClusterConstants';
|
||||
import { buildInterpPolygon } from '../components/korea/fleetClusterUtils';
|
||||
import type { GearCorrelationItem } from '../services/vesselAnalysis';
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const TRAIL_LENGTH_MS = 3_600_000; // 1 hour trail
|
||||
const RENDER_INTERVAL_MS = 100; // 10fps throttle during playback
|
||||
|
||||
// ── Helper ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function hexToRgb(hex: string): [number, number, number] {
|
||||
const h = hex.replace('#', '');
|
||||
return [
|
||||
parseInt(h.substring(0, 2), 16),
|
||||
parseInt(h.substring(2, 4), 16),
|
||||
parseInt(h.substring(4, 6), 16),
|
||||
];
|
||||
}
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CorrPosition {
|
||||
mmsi: string;
|
||||
name: string;
|
||||
lon: number;
|
||||
lat: number;
|
||||
color: [number, number, number, number];
|
||||
isVessel: boolean;
|
||||
}
|
||||
|
||||
// ── Hook ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Gear group replay animation layers for deck.gl.
|
||||
*
|
||||
* Performance:
|
||||
* - currentTime changes are subscribed via zustand.subscribe (NOT React selectors).
|
||||
* React never re-renders during playback.
|
||||
* - Layer objects are built imperatively and written to replayLayerRef.
|
||||
* - The parent calls overlay.setProps() to push layers to WebGL.
|
||||
*/
|
||||
export function useGearReplayLayers(
|
||||
replayLayerRef: React.MutableRefObject<Layer[]>,
|
||||
requestRender: () => void,
|
||||
): void {
|
||||
// ── React selectors (infrequent changes only) ────────────────────────────
|
||||
const historyFrames = useGearReplayStore(s => s.historyFrames);
|
||||
const memberTripsData = useGearReplayStore(s => s.memberTripsData);
|
||||
const correlationTripsData = useGearReplayStore(s => s.correlationTripsData);
|
||||
const centerTrailSegments = useGearReplayStore(s => s.centerTrailSegments);
|
||||
const centerDotsPositions = useGearReplayStore(s => s.centerDotsPositions);
|
||||
const enabledModels = useGearReplayStore(s => s.enabledModels);
|
||||
const enabledVessels = useGearReplayStore(s => s.enabledVessels);
|
||||
const hoveredMmsi = useGearReplayStore(s => s.hoveredMmsi);
|
||||
const correlationByModel = useGearReplayStore(s => s.correlationByModel);
|
||||
|
||||
// ── Refs ─────────────────────────────────────────────────────────────────
|
||||
const cursorRef = useRef(0); // frame cursor for O(1) forward lookup
|
||||
|
||||
// ── renderFrame ──────────────────────────────────────────────────────────
|
||||
|
||||
const renderFrame = useCallback(() => {
|
||||
if (historyFrames.length === 0) {
|
||||
replayLayerRef.current = [];
|
||||
requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
const state = useGearReplayStore.getState();
|
||||
const ct = state.currentTime;
|
||||
const st = state.startTime;
|
||||
|
||||
// Find current frame
|
||||
const { index: frameIdx, cursor } = findFrameAtTime(state.frameTimes, ct, cursorRef.current);
|
||||
cursorRef.current = cursor;
|
||||
|
||||
const layers: Layer[] = [];
|
||||
|
||||
// ── Static layers (center trail + dots) ───────────────────────────────
|
||||
|
||||
// Center trail segments (PathLayer)
|
||||
for (let i = 0; i < centerTrailSegments.length; i++) {
|
||||
const seg = centerTrailSegments[i];
|
||||
if (seg.path.length < 2) continue;
|
||||
layers.push(new PathLayer({
|
||||
id: `replay-center-trail-${i}`,
|
||||
data: [{ path: seg.path }],
|
||||
getPath: (d: { path: [number, number][] }) => d.path,
|
||||
getColor: seg.isInterpolated
|
||||
? [249, 115, 22, 200]
|
||||
: [251, 191, 36, 180],
|
||||
widthMinPixels: 2,
|
||||
}));
|
||||
}
|
||||
|
||||
// Center dots (real data only)
|
||||
if (centerDotsPositions.length > 0) {
|
||||
layers.push(new ScatterplotLayer({
|
||||
id: 'replay-center-dots',
|
||||
data: centerDotsPositions,
|
||||
getPosition: (d: [number, number]) => d,
|
||||
getFillColor: [251, 191, 36, 150],
|
||||
getRadius: 80,
|
||||
radiusUnits: 'meters',
|
||||
radiusMinPixels: 2.5,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Dynamic layers (depend on currentTime) ────────────────────────────
|
||||
|
||||
if (frameIdx < 0) {
|
||||
// No valid frame at this time — only show static layers
|
||||
replayLayerRef.current = layers;
|
||||
requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = state.historyFrames[frameIdx];
|
||||
const isStale = !!frame._longGap || !!frame._interp;
|
||||
|
||||
// Member positions (interpolated)
|
||||
const members = interpolateMemberPositions(state.historyFrames, frameIdx, ct);
|
||||
|
||||
// 1. TripsLayer — member trails (GPU animated)
|
||||
if (memberTripsData.length > 0) {
|
||||
layers.push(new TripsLayer({
|
||||
id: 'replay-member-trails',
|
||||
data: memberTripsData,
|
||||
getPath: d => d.path,
|
||||
getTimestamps: d => d.timestamps,
|
||||
getColor: d => d.color,
|
||||
widthMinPixels: 1.5,
|
||||
fadeTrail: true,
|
||||
trailLength: TRAIL_LENGTH_MS,
|
||||
currentTime: ct - st,
|
||||
}));
|
||||
}
|
||||
|
||||
// 2. TripsLayer — correlation trails (GPU animated)
|
||||
if (correlationTripsData.length > 0) {
|
||||
const enabledTrips = correlationTripsData.filter(d => enabledVessels.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: d => d.color,
|
||||
widthMinPixels: 2,
|
||||
fadeTrail: true,
|
||||
trailLength: TRAIL_LENGTH_MS,
|
||||
currentTime: ct - st,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Current animated polygon (convex hull of members)
|
||||
const memberPts: [number, number][] = members.map(m => [m.lon, m.lat]);
|
||||
const polygon = buildInterpPolygon(memberPts);
|
||||
if (polygon) {
|
||||
layers.push(new PolygonLayer({
|
||||
id: 'replay-polygon',
|
||||
data: [{ polygon: polygon.coordinates }],
|
||||
getPolygon: (d: { polygon: number[][][] }) => d.polygon,
|
||||
getFillColor: isStale ? [148, 163, 184, 30] : [251, 191, 36, 40],
|
||||
getLineColor: isStale ? [148, 163, 184, 100] : [251, 191, 36, 180],
|
||||
getLineWidth: isStale ? 1 : 2,
|
||||
lineWidthMinPixels: 1,
|
||||
filled: true,
|
||||
stroked: true,
|
||||
}));
|
||||
}
|
||||
|
||||
// 4. Current center point
|
||||
layers.push(new ScatterplotLayer({
|
||||
id: 'replay-center',
|
||||
data: [{ position: [frame.centerLon, frame.centerLat] as [number, number] }],
|
||||
getPosition: (d: { position: [number, number] }) => d.position,
|
||||
getFillColor: isStale ? [249, 115, 22, 255] : [239, 68, 68, 255],
|
||||
getRadius: 200,
|
||||
radiusUnits: 'meters',
|
||||
radiusMinPixels: 7,
|
||||
stroked: true,
|
||||
getLineColor: [255, 255, 255, 255],
|
||||
lineWidthMinPixels: 2,
|
||||
}));
|
||||
|
||||
// 5. Member position markers
|
||||
if (members.length > 0) {
|
||||
layers.push(new ScatterplotLayer<MemberPosition>({
|
||||
id: 'replay-members',
|
||||
data: members,
|
||||
getPosition: d => [d.lon, d.lat],
|
||||
getFillColor: d => {
|
||||
if (d.stale) return [100, 116, 139, 150];
|
||||
if (d.isGear) return [168, 184, 200, 230];
|
||||
return [251, 191, 36, 230];
|
||||
},
|
||||
getRadius: d => d.isParent ? 150 : d.isGear ? 80 : 120,
|
||||
radiusUnits: 'meters',
|
||||
radiusMinPixels: 3,
|
||||
stroked: true,
|
||||
getLineColor: [0, 0, 0, 150],
|
||||
lineWidthMinPixels: 0.5,
|
||||
}));
|
||||
|
||||
// Member labels
|
||||
layers.push(new TextLayer<MemberPosition>({
|
||||
id: 'replay-member-labels',
|
||||
data: members,
|
||||
getPosition: d => [d.lon, d.lat],
|
||||
getText: d => d.name || d.mmsi,
|
||||
getColor: d => d.stale
|
||||
? [148, 163, 184, 200]
|
||||
: d.isGear
|
||||
? [226, 232, 240, 255]
|
||||
: [251, 191, 36, 255],
|
||||
getSize: 10,
|
||||
getPixelOffset: [0, 14],
|
||||
background: true,
|
||||
getBackgroundColor: [0, 0, 0, 200],
|
||||
backgroundPadding: [2, 1],
|
||||
fontFamily: '"Fira Code Variable", monospace',
|
||||
}));
|
||||
}
|
||||
|
||||
// 6. Correlation vessel positions (interpolated from correlationTripsData)
|
||||
const corrPositions: CorrPosition[] = [];
|
||||
const corrTrackMap = new Map(correlationTripsData.map(d => [d.id, d]));
|
||||
|
||||
for (const [mn, items] of correlationByModel) {
|
||||
if (!enabledModels.has(mn)) continue;
|
||||
const color = MODEL_COLORS[mn] ?? '#94a3b8';
|
||||
const [r, g, b] = hexToRgb(color);
|
||||
|
||||
for (const c of items as GearCorrelationItem[]) {
|
||||
if (corrPositions.some(p => p.mmsi === c.targetMmsi)) continue;
|
||||
|
||||
const tripData = corrTrackMap.get(c.targetMmsi);
|
||||
if (!tripData) continue;
|
||||
|
||||
const relTime = ct - st;
|
||||
const ts = tripData.timestamps;
|
||||
const path = tripData.path;
|
||||
if (ts.length === 0) continue;
|
||||
if (relTime < ts[0] || relTime > ts[ts.length - 1]) continue;
|
||||
|
||||
// Binary search in timestamps
|
||||
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 lon = path[lo][0] + (path[hi][0] - path[lo][0]) * ratio;
|
||||
const lat = path[lo][1] + (path[hi][1] - path[lo][1]) * ratio;
|
||||
|
||||
corrPositions.push({
|
||||
mmsi: c.targetMmsi,
|
||||
name: c.targetName || c.targetMmsi,
|
||||
lon,
|
||||
lat,
|
||||
color: [r, g, b, 230],
|
||||
isVessel: c.targetType === 'VESSEL',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (corrPositions.length > 0) {
|
||||
layers.push(new ScatterplotLayer<CorrPosition>({
|
||||
id: 'replay-corr-vessels',
|
||||
data: corrPositions,
|
||||
getPosition: d => [d.lon, d.lat],
|
||||
getFillColor: d => d.color,
|
||||
getRadius: d => d.isVessel ? 130 : 80,
|
||||
radiusUnits: 'meters',
|
||||
radiusMinPixels: 3,
|
||||
stroked: true,
|
||||
getLineColor: [0, 0, 0, 150],
|
||||
lineWidthMinPixels: 1,
|
||||
}));
|
||||
|
||||
layers.push(new TextLayer<CorrPosition>({
|
||||
id: 'replay-corr-labels',
|
||||
data: corrPositions,
|
||||
getPosition: d => [d.lon, d.lat],
|
||||
getText: d => d.name,
|
||||
getColor: d => d.color,
|
||||
getSize: 8,
|
||||
getPixelOffset: [0, 15],
|
||||
background: true,
|
||||
getBackgroundColor: [0, 0, 0, 200],
|
||||
backgroundPadding: [2, 1],
|
||||
}));
|
||||
}
|
||||
|
||||
// 7. Hover highlight
|
||||
if (hoveredMmsi) {
|
||||
const hoveredMember = members.find(m => m.mmsi === hoveredMmsi);
|
||||
const hoveredCorr = corrPositions.find(c => c.mmsi === hoveredMmsi);
|
||||
const hoveredPos: [number, number] | null = hoveredMember
|
||||
? [hoveredMember.lon, hoveredMember.lat]
|
||||
: hoveredCorr
|
||||
? [hoveredCorr.lon, hoveredCorr.lat]
|
||||
: null;
|
||||
|
||||
if (hoveredPos) {
|
||||
layers.push(new ScatterplotLayer({
|
||||
id: 'replay-hover-glow',
|
||||
data: [{ position: hoveredPos }],
|
||||
getPosition: (d: { position: [number, number] }) => d.position,
|
||||
getFillColor: [255, 255, 255, 60],
|
||||
getRadius: 400,
|
||||
radiusUnits: 'meters',
|
||||
radiusMinPixels: 14,
|
||||
}));
|
||||
layers.push(new ScatterplotLayer({
|
||||
id: 'replay-hover-ring',
|
||||
data: [{ position: hoveredPos }],
|
||||
getPosition: (d: { position: [number, number] }) => d.position,
|
||||
getFillColor: [0, 0, 0, 0],
|
||||
getRadius: 250,
|
||||
radiusUnits: 'meters',
|
||||
radiusMinPixels: 8,
|
||||
stroked: true,
|
||||
getLineColor: [255, 255, 255, 255],
|
||||
lineWidthMinPixels: 2,
|
||||
}));
|
||||
}
|
||||
|
||||
// Hover trail (from correlation track)
|
||||
const hoveredTrack = correlationTripsData.find(d => d.id === hoveredMmsi);
|
||||
if (hoveredTrack) {
|
||||
const relTime = ct - st;
|
||||
let clipIdx = hoveredTrack.timestamps.length;
|
||||
for (let i = 0; i < hoveredTrack.timestamps.length; i++) {
|
||||
if (hoveredTrack.timestamps[i] > relTime) {
|
||||
clipIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const clippedPath = hoveredTrack.path.slice(0, clipIdx);
|
||||
if (clippedPath.length >= 2) {
|
||||
layers.push(new PathLayer({
|
||||
id: 'replay-hover-trail',
|
||||
data: [{ path: clippedPath }],
|
||||
getPath: (d: { path: [number, number][] }) => d.path,
|
||||
getColor: [255, 255, 255, 180],
|
||||
widthMinPixels: 3,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Operational polygons (per model — union of member positions + high-score correlation vessels)
|
||||
for (const [mn, items] of correlationByModel) {
|
||||
if (!enabledModels.has(mn)) continue;
|
||||
const color = MODEL_COLORS[mn] ?? '#94a3b8';
|
||||
const [r, g, b] = hexToRgb(color);
|
||||
|
||||
const extraPts: [number, number][] = [];
|
||||
for (const c of items as GearCorrelationItem[]) {
|
||||
if (c.score < 0.7) continue;
|
||||
const cp = corrPositions.find(p => p.mmsi === c.targetMmsi);
|
||||
if (cp) extraPts.push([cp.lon, cp.lat]);
|
||||
}
|
||||
if (extraPts.length === 0) continue;
|
||||
|
||||
const opPolygon = buildInterpPolygon([...memberPts, ...extraPts]);
|
||||
if (!opPolygon) continue;
|
||||
|
||||
layers.push(new PolygonLayer({
|
||||
id: `replay-op-${mn}`,
|
||||
data: [{ polygon: opPolygon.coordinates }],
|
||||
getPolygon: (d: { polygon: number[][][] }) => d.polygon,
|
||||
getFillColor: [r, g, b, 30],
|
||||
getLineColor: [r, g, b, 200],
|
||||
getLineWidth: 2,
|
||||
lineWidthMinPixels: 2,
|
||||
filled: true,
|
||||
stroked: true,
|
||||
}));
|
||||
}
|
||||
|
||||
replayLayerRef.current = layers;
|
||||
requestRender();
|
||||
}, [
|
||||
historyFrames, memberTripsData, correlationTripsData,
|
||||
centerTrailSegments, centerDotsPositions,
|
||||
enabledModels, enabledVessels, hoveredMmsi, correlationByModel,
|
||||
replayLayerRef, requestRender,
|
||||
]);
|
||||
|
||||
// ── zustand.subscribe effect (currentTime → renderFrame) ─────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (historyFrames.length === 0) return;
|
||||
|
||||
// Initial render
|
||||
renderFrame();
|
||||
|
||||
let lastRenderTime = 0;
|
||||
let pendingRafId: number | null = null;
|
||||
|
||||
const unsub = useGearReplayStore.subscribe(
|
||||
s => s.currentTime,
|
||||
() => {
|
||||
const isPlaying = useGearReplayStore.getState().isPlaying;
|
||||
if (!isPlaying) {
|
||||
// Seek/pause — immediate render for responsiveness
|
||||
renderFrame();
|
||||
return;
|
||||
}
|
||||
const now = performance.now();
|
||||
if (now - lastRenderTime >= RENDER_INTERVAL_MS) {
|
||||
lastRenderTime = now;
|
||||
renderFrame();
|
||||
} else if (!pendingRafId) {
|
||||
pendingRafId = requestAnimationFrame(() => {
|
||||
pendingRafId = null;
|
||||
lastRenderTime = performance.now();
|
||||
renderFrame();
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
if (pendingRafId) cancelAnimationFrame(pendingRafId);
|
||||
};
|
||||
}, [historyFrames, renderFrame]);
|
||||
|
||||
// ── Cleanup on unmount ────────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
replayLayerRef.current = [];
|
||||
requestRender();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: run only on unmount
|
||||
}, []);
|
||||
}
|
||||
@ -131,6 +131,41 @@ export async function fetchGroupCorrelations(
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/* ── Correlation Tracks (Prediction API) ──────────────────────── */
|
||||
|
||||
export interface CorrelationTrackPoint {
|
||||
ts: number; // epoch ms
|
||||
lat: number;
|
||||
lon: number;
|
||||
sog: number;
|
||||
cog: number;
|
||||
}
|
||||
|
||||
export interface CorrelationVesselTrack {
|
||||
mmsi: string;
|
||||
name: string;
|
||||
score: number;
|
||||
modelName: string;
|
||||
track: CorrelationTrackPoint[];
|
||||
}
|
||||
|
||||
export interface CorrelationTracksResponse {
|
||||
groupKey: string;
|
||||
vessels: CorrelationVesselTrack[];
|
||||
}
|
||||
|
||||
export async function fetchCorrelationTracks(
|
||||
groupKey: string,
|
||||
hours = 24,
|
||||
minScore = 0.3,
|
||||
): Promise<CorrelationTracksResponse> {
|
||||
const res = await fetch(
|
||||
`/api/prediction/v1/correlation/${encodeURIComponent(groupKey)}/tracks?hours=${hours}&minScore=${minScore}`,
|
||||
);
|
||||
if (!res.ok) return { groupKey, vessels: [] };
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/* ── Fleet Companies ─────────────────────────────────────────── */
|
||||
|
||||
// 캐시 (세션 중 1회 로드)
|
||||
|
||||
235
frontend/src/stores/gearReplayPreprocess.ts
Normal file
235
frontend/src/stores/gearReplayPreprocess.ts
Normal file
@ -0,0 +1,235 @@
|
||||
import type { HistoryFrame } from '../components/korea/fleetClusterTypes';
|
||||
import type { CorrelationVesselTrack } from '../services/vesselAnalysis';
|
||||
|
||||
export interface TripsLayerDatum {
|
||||
id: string;
|
||||
path: [number, number][]; // [lon, lat][]
|
||||
timestamps: number[]; // relative ms from startTime (TripsLayer requirement)
|
||||
color: [number, number, number, number];
|
||||
}
|
||||
|
||||
export interface MemberPosition {
|
||||
mmsi: string;
|
||||
name: string;
|
||||
lon: number;
|
||||
lat: number;
|
||||
cog: number;
|
||||
role: string;
|
||||
isParent: boolean;
|
||||
isGear: boolean;
|
||||
stale: boolean;
|
||||
}
|
||||
|
||||
export interface CenterTrailSegment {
|
||||
path: [number, number][];
|
||||
isInterpolated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk all frames and collect per-MMSI tracks for TripsLayer rendering.
|
||||
* timestamps are relative ms from startTime (deck.gl TripsLayer requirement).
|
||||
*/
|
||||
export function buildMemberTripsData(frames: HistoryFrame[], startTime: number): TripsLayerDatum[] {
|
||||
const memberMap = new Map<string, { path: [number, number][]; timestamps: number[] }>();
|
||||
|
||||
for (const frame of frames) {
|
||||
const t = new Date(frame.snapshotTime).getTime() - startTime;
|
||||
for (const member of frame.members) {
|
||||
const entry = memberMap.get(member.mmsi) ?? { path: [], timestamps: [] };
|
||||
entry.path.push([member.lon, member.lat]);
|
||||
entry.timestamps.push(t);
|
||||
memberMap.set(member.mmsi, entry);
|
||||
}
|
||||
}
|
||||
|
||||
const result: TripsLayerDatum[] = [];
|
||||
for (const [mmsi, data] of memberMap) {
|
||||
if (data.path.length >= 2) {
|
||||
result.push({
|
||||
id: mmsi,
|
||||
path: data.path,
|
||||
timestamps: data.timestamps,
|
||||
color: [200, 200, 200, 180],
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert correlation vessel tracks to TripsLayer format.
|
||||
* timestamps are relative ms from startTime (deck.gl TripsLayer requirement).
|
||||
*/
|
||||
export function buildCorrelationTripsData(
|
||||
tracks: CorrelationVesselTrack[],
|
||||
startTime: number,
|
||||
): TripsLayerDatum[] {
|
||||
const result: TripsLayerDatum[] = [];
|
||||
for (const vt of tracks) {
|
||||
if (vt.track.length >= 2) {
|
||||
result.push({
|
||||
id: vt.mmsi,
|
||||
path: vt.track.map(pt => [pt.lon, pt.lat]),
|
||||
timestamps: vt.track.map(pt => pt.ts - startTime),
|
||||
color: [96, 165, 250, 150],
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split center trail into real/interpolated segments and collect real-data dot positions.
|
||||
* Consecutive frames with the same _longGap flag form one segment.
|
||||
*/
|
||||
export function buildCenterTrailData(
|
||||
frames: HistoryFrame[],
|
||||
): { segments: CenterTrailSegment[]; dots: [number, number][] } {
|
||||
const segments: CenterTrailSegment[] = [];
|
||||
const dots: [number, number][] = [];
|
||||
|
||||
if (frames.length === 0) return { segments, dots };
|
||||
|
||||
let segStart = 0;
|
||||
|
||||
for (let i = 1; i <= frames.length; i++) {
|
||||
const curInterp = i < frames.length ? !!frames[i]._longGap : null;
|
||||
const startInterp = !!frames[segStart]._longGap;
|
||||
|
||||
if (i < frames.length && curInterp === startInterp) continue;
|
||||
|
||||
const from = segStart > 0 ? segStart - 1 : segStart;
|
||||
const seg = frames.slice(from, i);
|
||||
if (seg.length >= 2) {
|
||||
segments.push({
|
||||
path: seg.map(s => [s.centerLon, s.centerLat]),
|
||||
isInterpolated: startInterp,
|
||||
});
|
||||
}
|
||||
segStart = i;
|
||||
}
|
||||
|
||||
for (const frame of frames) {
|
||||
if (!frame._longGap && !frame._interp) {
|
||||
dots.push([frame.centerLon, frame.centerLat]);
|
||||
}
|
||||
}
|
||||
|
||||
return { segments, dots };
|
||||
}
|
||||
|
||||
/**
|
||||
* Map real (non-interpolated) frames to normalized [0, 1] positions
|
||||
* along the timeline, for progress bar gap indicators.
|
||||
*/
|
||||
export function buildSnapshotRanges(
|
||||
frames: HistoryFrame[],
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
): number[] {
|
||||
const duration = endTime - startTime;
|
||||
if (duration <= 0) return [];
|
||||
return frames
|
||||
.filter(h => !h._interp)
|
||||
.map(h => (new Date(h.snapshotTime).getTime() - startTime) / duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursor-based frame index lookup.
|
||||
* Uses forward linear scan from cursorHint during normal playback (O(1–2)),
|
||||
* falls back to binary search when time goes backward or hint is invalid.
|
||||
* Returns { index: -1 } when the closest frame is more than 30 minutes away.
|
||||
*/
|
||||
export function findFrameAtTime(
|
||||
frameTimes: number[],
|
||||
timeMs: number,
|
||||
cursorHint: number,
|
||||
): { index: number; cursor: number } {
|
||||
if (frameTimes.length === 0) return { index: -1, cursor: 0 };
|
||||
|
||||
// Forward linear scan from cursor
|
||||
if (cursorHint >= 0 && cursorHint < frameTimes.length) {
|
||||
if (frameTimes[cursorHint] <= timeMs) {
|
||||
let i = cursorHint;
|
||||
while (i < frameTimes.length - 1 && frameTimes[i + 1] <= timeMs) {
|
||||
i++;
|
||||
}
|
||||
return { index: i, cursor: i };
|
||||
}
|
||||
// Time went backward — fall through to binary search
|
||||
}
|
||||
|
||||
// Binary search fallback
|
||||
let lo = 0;
|
||||
let hi = frameTimes.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >> 1;
|
||||
if (frameTimes[mid] <= timeMs) {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (Math.abs(frameTimes[lo] - timeMs) > 1_800_000) {
|
||||
return { index: -1, cursor: lo };
|
||||
}
|
||||
return { index: lo, cursor: lo };
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate member positions between frameIdx and frameIdx+1 at timeMs.
|
||||
* Returns stale=true for frames marked as _longGap or _interp.
|
||||
*/
|
||||
export function interpolateMemberPositions(
|
||||
frames: HistoryFrame[],
|
||||
frameIdx: number,
|
||||
timeMs: number,
|
||||
): MemberPosition[] {
|
||||
if (frameIdx < 0 || frameIdx >= frames.length) return [];
|
||||
|
||||
const frame = frames[frameIdx];
|
||||
const isStale = !!frame._longGap || !!frame._interp;
|
||||
|
||||
const toPosition = (
|
||||
m: { mmsi: string; name: string; lon: number; lat: number; cog: number; role: string; isParent: boolean },
|
||||
lon: number,
|
||||
lat: number,
|
||||
cog: number,
|
||||
): MemberPosition => ({
|
||||
mmsi: m.mmsi,
|
||||
name: m.name,
|
||||
lon,
|
||||
lat,
|
||||
cog,
|
||||
role: m.role,
|
||||
isParent: m.isParent,
|
||||
isGear: m.role === 'GEAR' || !m.isParent,
|
||||
stale: isStale,
|
||||
});
|
||||
|
||||
// No next frame — return current positions as-is
|
||||
if (frameIdx >= frames.length - 1) {
|
||||
return frame.members.map(m => toPosition(m, m.lon, m.lat, m.cog));
|
||||
}
|
||||
|
||||
const nextFrame = frames[frameIdx + 1];
|
||||
const t0 = new Date(frame.snapshotTime).getTime();
|
||||
const t1 = new Date(nextFrame.snapshotTime).getTime();
|
||||
const ratio = t1 > t0 ? Math.max(0, Math.min(1, (timeMs - t0) / (t1 - t0))) : 0;
|
||||
|
||||
const nextMap = new Map(nextFrame.members.map(m => [m.mmsi, m]));
|
||||
|
||||
return frame.members.map(m => {
|
||||
const nm = nextMap.get(m.mmsi);
|
||||
if (!nm) {
|
||||
return toPosition(m, m.lon, m.lat, m.cog);
|
||||
}
|
||||
return toPosition(
|
||||
m,
|
||||
m.lon + (nm.lon - m.lon) * ratio,
|
||||
m.lat + (nm.lat - m.lat) * ratio,
|
||||
nm.cog,
|
||||
);
|
||||
});
|
||||
}
|
||||
245
frontend/src/stores/gearReplayStore.ts
Normal file
245
frontend/src/stores/gearReplayStore.ts
Normal file
@ -0,0 +1,245 @@
|
||||
import { create } from 'zustand';
|
||||
import { subscribeWithSelector } from 'zustand/middleware';
|
||||
import type { HistoryFrame } from '../components/korea/fleetClusterTypes';
|
||||
import type { GearCorrelationItem, CorrelationVesselTrack } from '../services/vesselAnalysis';
|
||||
import {
|
||||
buildMemberTripsData,
|
||||
buildCorrelationTripsData,
|
||||
buildCenterTrailData,
|
||||
buildSnapshotRanges,
|
||||
} from './gearReplayPreprocess';
|
||||
|
||||
// ── Pre-processed data types for deck.gl layers ──────────────────
|
||||
|
||||
export interface TripsLayerDatum {
|
||||
id: string;
|
||||
path: [number, number][];
|
||||
timestamps: number[];
|
||||
color: [number, number, number, number];
|
||||
}
|
||||
|
||||
export interface MemberPosition {
|
||||
mmsi: string;
|
||||
name: string;
|
||||
lon: number;
|
||||
lat: number;
|
||||
cog: number;
|
||||
role: string;
|
||||
isParent: boolean;
|
||||
isGear: boolean;
|
||||
stale: boolean;
|
||||
}
|
||||
|
||||
export interface CenterTrailSegment {
|
||||
path: [number, number][];
|
||||
isInterpolated: boolean;
|
||||
}
|
||||
|
||||
// ── Speed factor: 1x = 30 real seconds covers 12 timeline hours ──
|
||||
const SPEED_FACTOR = (12 * 60 * 60 * 1000) / (30 * 1000); // 1440
|
||||
|
||||
// ── Module-level rAF state (outside React) ───────────────────────
|
||||
let animationFrameId: number | null = null;
|
||||
let lastFrameTime: number | null = null;
|
||||
|
||||
// ── Store interface ───────────────────────────────────────────────
|
||||
|
||||
interface GearReplayState {
|
||||
// Playback state
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
playbackSpeed: number;
|
||||
|
||||
// Source data
|
||||
historyFrames: HistoryFrame[];
|
||||
frameTimes: number[];
|
||||
selectedGroupKey: string | null;
|
||||
|
||||
// Pre-computed layer data
|
||||
memberTripsData: TripsLayerDatum[];
|
||||
correlationTripsData: TripsLayerDatum[];
|
||||
centerTrailSegments: CenterTrailSegment[];
|
||||
centerDotsPositions: [number, number][];
|
||||
snapshotRanges: number[];
|
||||
|
||||
// Filter state
|
||||
enabledModels: Set<string>;
|
||||
enabledVessels: Set<string>;
|
||||
hoveredMmsi: string | null;
|
||||
correlationByModel: Map<string, GearCorrelationItem[]>;
|
||||
|
||||
// Actions
|
||||
loadHistory: (
|
||||
frames: HistoryFrame[],
|
||||
corrTracks: CorrelationVesselTrack[],
|
||||
corrData: GearCorrelationItem[],
|
||||
enabledModels: Set<string>,
|
||||
enabledVessels: Set<string>,
|
||||
) => void;
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
seek: (timeMs: number) => void;
|
||||
setPlaybackSpeed: (speed: number) => void;
|
||||
setEnabledModels: (models: Set<string>) => void;
|
||||
setEnabledVessels: (vessels: Set<string>) => void;
|
||||
setHoveredMmsi: (mmsi: string | null) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
// ── Store ─────────────────────────────────────────────────────────
|
||||
|
||||
export const useGearReplayStore = create<GearReplayState>()(
|
||||
subscribeWithSelector((set, get) => {
|
||||
const animate = (): void => {
|
||||
const state = get();
|
||||
if (!state.isPlaying) return;
|
||||
|
||||
const now = performance.now();
|
||||
if (lastFrameTime === null) lastFrameTime = now;
|
||||
|
||||
const delta = now - lastFrameTime;
|
||||
lastFrameTime = now;
|
||||
|
||||
const newTime = state.currentTime + delta * SPEED_FACTOR * state.playbackSpeed;
|
||||
|
||||
if (newTime >= state.endTime) {
|
||||
set({ currentTime: state.startTime });
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
return;
|
||||
}
|
||||
|
||||
set({ currentTime: newTime });
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
return {
|
||||
// Playback state
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
startTime: 0,
|
||||
endTime: 0,
|
||||
playbackSpeed: 1,
|
||||
|
||||
// Source data
|
||||
historyFrames: [],
|
||||
frameTimes: [],
|
||||
selectedGroupKey: null,
|
||||
|
||||
// Pre-computed layer data
|
||||
memberTripsData: [],
|
||||
correlationTripsData: [],
|
||||
centerTrailSegments: [],
|
||||
centerDotsPositions: [],
|
||||
snapshotRanges: [],
|
||||
|
||||
// Filter state
|
||||
enabledModels: new Set<string>(),
|
||||
enabledVessels: new Set<string>(),
|
||||
hoveredMmsi: null,
|
||||
correlationByModel: new Map<string, GearCorrelationItem[]>(),
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────
|
||||
|
||||
loadHistory: (frames, corrTracks, corrData, enabledModels, enabledVessels) => {
|
||||
const startTime = Date.now() - 12 * 60 * 60 * 1000;
|
||||
const endTime = Date.now();
|
||||
const frameTimes = frames.map(f => new Date(f.snapshotTime).getTime());
|
||||
|
||||
const memberTrips = buildMemberTripsData(frames, startTime);
|
||||
const corrTrips = buildCorrelationTripsData(corrTracks, startTime);
|
||||
const { segments, dots } = buildCenterTrailData(frames);
|
||||
const ranges = buildSnapshotRanges(frames, startTime, endTime);
|
||||
|
||||
const byModel = new Map<string, GearCorrelationItem[]>();
|
||||
for (const c of corrData) {
|
||||
const list = byModel.get(c.modelName) ?? [];
|
||||
list.push(c);
|
||||
byModel.set(c.modelName, list);
|
||||
}
|
||||
|
||||
set({
|
||||
historyFrames: frames,
|
||||
frameTimes,
|
||||
startTime,
|
||||
endTime,
|
||||
currentTime: startTime,
|
||||
memberTripsData: memberTrips,
|
||||
correlationTripsData: corrTrips,
|
||||
centerTrailSegments: segments,
|
||||
centerDotsPositions: dots,
|
||||
snapshotRanges: ranges,
|
||||
enabledModels,
|
||||
enabledVessels,
|
||||
correlationByModel: byModel,
|
||||
selectedGroupKey: frames[0]?.groupKey ?? null,
|
||||
});
|
||||
},
|
||||
|
||||
play: () => {
|
||||
const state = get();
|
||||
if (state.endTime <= state.startTime) return;
|
||||
|
||||
lastFrameTime = null;
|
||||
|
||||
if (state.currentTime >= state.endTime) {
|
||||
set({ isPlaying: true, currentTime: state.startTime });
|
||||
} else {
|
||||
set({ isPlaying: true });
|
||||
}
|
||||
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
},
|
||||
|
||||
pause: () => {
|
||||
if (animationFrameId !== null) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
}
|
||||
lastFrameTime = null;
|
||||
set({ isPlaying: false });
|
||||
},
|
||||
|
||||
seek: (timeMs) => {
|
||||
const { startTime, endTime } = get();
|
||||
set({ currentTime: Math.max(startTime, Math.min(endTime, timeMs)) });
|
||||
},
|
||||
|
||||
setPlaybackSpeed: (speed) => set({ playbackSpeed: speed }),
|
||||
|
||||
setEnabledModels: (models) => set({ enabledModels: models }),
|
||||
|
||||
setEnabledVessels: (vessels) => set({ enabledVessels: vessels }),
|
||||
|
||||
setHoveredMmsi: (mmsi) => set({ hoveredMmsi: mmsi }),
|
||||
|
||||
reset: () => {
|
||||
if (animationFrameId !== null) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
}
|
||||
lastFrameTime = null;
|
||||
set({
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
startTime: 0,
|
||||
endTime: 0,
|
||||
playbackSpeed: 1,
|
||||
historyFrames: [],
|
||||
frameTimes: [],
|
||||
selectedGroupKey: null,
|
||||
memberTripsData: [],
|
||||
correlationTripsData: [],
|
||||
centerTrailSegments: [],
|
||||
centerDotsPositions: [],
|
||||
snapshotRanges: [],
|
||||
enabledModels: new Set<string>(),
|
||||
enabledVessels: new Set<string>(),
|
||||
hoveredMmsi: null,
|
||||
correlationByModel: new Map<string, GearCorrelationItem[]>(),
|
||||
});
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
@ -115,6 +115,11 @@ export default defineConfig(({ mode }): UserConfig => ({
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
},
|
||||
'/api/prediction': {
|
||||
target: 'http://localhost:8001',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api\/prediction/, ''),
|
||||
},
|
||||
'/ollama': {
|
||||
target: 'http://localhost:11434',
|
||||
changeOrigin: true,
|
||||
|
||||
@ -537,7 +537,7 @@ def run_gear_correlation(
|
||||
import time as _time
|
||||
import re as _re
|
||||
|
||||
_gear_re = _re.compile(r'^.+_\d+_\d*$')
|
||||
_gear_re = _re.compile(r'^.+_(?=\S*\d)\S+(?:[_ ]\S*)*[_ ]*$|^.+%$|^\d+$')
|
||||
|
||||
t0 = _time.time()
|
||||
now = datetime.now(timezone.utc)
|
||||
@ -768,6 +768,8 @@ def _batch_upsert_scores(conn, batch: list[tuple]):
|
||||
VALUES %s
|
||||
ON CONFLICT (model_id, group_key, target_mmsi)
|
||||
DO UPDATE SET
|
||||
target_type = EXCLUDED.target_type,
|
||||
target_name = EXCLUDED.target_name,
|
||||
current_score = EXCLUDED.current_score,
|
||||
streak_count = EXCLUDED.streak_count,
|
||||
freeze_state = EXCLUDED.freeze_state,
|
||||
|
||||
@ -23,8 +23,8 @@ from algorithms.location import classify_zone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 프론트 FleetClusterLayer.tsx gearGroupMap 패턴과 동일
|
||||
GEAR_PATTERN = re.compile(r'^(.+?)_\d+_\d+_?$')
|
||||
# 어구 이름 패턴 — _ 필수 (공백만으로는 어구 미판정, fleet_tracker.py와 동일)
|
||||
GEAR_PATTERN = re.compile(r'^(.+?)_(?=\S*\d)\S+(?:[_ ]\S*)*[_ ]*$|^(\d+)$')
|
||||
MAX_DIST_DEG = 0.15 # ~10NM
|
||||
STALE_SEC = 21600 # 6시간 (어구 P75 갭 3.5h, P90 갭 8h 커버)
|
||||
FLEET_BUFFER_DEG = 0.02
|
||||
@ -130,14 +130,21 @@ def detect_gear_groups(
|
||||
all_positions = vessel_store.get_all_latest_positions()
|
||||
|
||||
# 선박명 → mmsi 맵 (모선 탐색용, 어구 패턴이 아닌 선박만)
|
||||
# 정규화 키(공백 제거) + 원본 이름 모두 등록
|
||||
name_to_mmsi: dict[str, str] = {}
|
||||
for mmsi, pos in all_positions.items():
|
||||
name = (pos.get('name') or '').strip()
|
||||
if name and not GEAR_PATTERN.match(name):
|
||||
name_to_mmsi[name] = mmsi
|
||||
name_to_mmsi[name.replace(' ', '')] = mmsi
|
||||
|
||||
# 1단계: 같은 모선명 어구 수집 (60분 이내만)
|
||||
# parent 이름 정규화 — 공백 제거 후 같은 모선은 하나로 통합
|
||||
def _normalize_parent(raw: str) -> str:
|
||||
return raw.replace(' ', '')
|
||||
|
||||
# 1단계: 같은 모선명 어구 수집 (60분 이내만, 공백 정규화)
|
||||
raw_groups: dict[str, list[dict]] = {}
|
||||
parent_display: dict[str, str] = {} # normalized → 대표 원본 이름
|
||||
for mmsi, pos in all_positions.items():
|
||||
name = (pos.get('name') or '').strip()
|
||||
if not name:
|
||||
@ -164,7 +171,11 @@ def detect_gear_groups(
|
||||
if not m:
|
||||
continue
|
||||
|
||||
parent_name = m.group(1).strip()
|
||||
parent_raw = (m.group(1) or name).strip()
|
||||
parent_key = _normalize_parent(parent_raw)
|
||||
# 대표 이름: 공백 없는 버전 우선 (더 정규화된 형태)
|
||||
if parent_key not in parent_display or ' ' not in parent_raw:
|
||||
parent_display[parent_key] = parent_raw
|
||||
entry = {
|
||||
'mmsi': mmsi,
|
||||
'name': name,
|
||||
@ -173,61 +184,121 @@ def detect_gear_groups(
|
||||
'sog': pos.get('sog', 0),
|
||||
'cog': pos.get('cog', 0),
|
||||
}
|
||||
raw_groups.setdefault(parent_name, []).append(entry)
|
||||
raw_groups.setdefault(parent_key, []).append(entry)
|
||||
|
||||
# 2단계: 거리 기반 서브 클러스터링 (anchor 기준 MAX_DIST_DEG 이내만)
|
||||
# 2단계: 연결 기반 서브 클러스터링 (각 어구가 클러스터 내 최소 1개와 MAX_DIST_DEG 이내)
|
||||
# 같은 parent 이름이라도 거리가 먼 어구들은 별도 서브그룹으로 분리
|
||||
results: list[dict] = []
|
||||
for parent_name, gears in raw_groups.items():
|
||||
parent_mmsi = name_to_mmsi.get(parent_name)
|
||||
for parent_key, gears in raw_groups.items():
|
||||
parent_mmsi = name_to_mmsi.get(parent_key)
|
||||
display_name = parent_display.get(parent_key, parent_key)
|
||||
|
||||
# 기준점(anchor): 모선 있으면 모선 위치, 없으면 첫 어구
|
||||
anchor_lat: Optional[float] = None
|
||||
anchor_lon: Optional[float] = None
|
||||
if not gears:
|
||||
continue
|
||||
|
||||
# 모선 위치 (있으면 시드 포인트로 활용)
|
||||
seed_lat: Optional[float] = None
|
||||
seed_lon: Optional[float] = None
|
||||
if parent_mmsi and parent_mmsi in all_positions:
|
||||
parent_pos = all_positions[parent_mmsi]
|
||||
anchor_lat = parent_pos['lat']
|
||||
anchor_lon = parent_pos['lon']
|
||||
p = all_positions[parent_mmsi]
|
||||
seed_lat, seed_lon = p['lat'], p['lon']
|
||||
|
||||
if anchor_lat is None and gears:
|
||||
anchor_lat = gears[0]['lat']
|
||||
anchor_lon = gears[0]['lon']
|
||||
# 연결 기반 클러스터링 (Union-Find 방식)
|
||||
n = len(gears)
|
||||
parent_uf = list(range(n))
|
||||
|
||||
if anchor_lat is None or anchor_lon is None:
|
||||
def find(x: int) -> int:
|
||||
while parent_uf[x] != x:
|
||||
parent_uf[x] = parent_uf[parent_uf[x]]
|
||||
x = parent_uf[x]
|
||||
return x
|
||||
|
||||
def union(a: int, b: int) -> None:
|
||||
ra, rb = find(a), find(b)
|
||||
if ra != rb:
|
||||
parent_uf[ra] = rb
|
||||
|
||||
for i in range(n):
|
||||
for j in range(i + 1, n):
|
||||
if (abs(gears[i]['lat'] - gears[j]['lat']) <= MAX_DIST_DEG
|
||||
and abs(gears[i]['lon'] - gears[j]['lon']) <= MAX_DIST_DEG):
|
||||
union(i, j)
|
||||
|
||||
# 클러스터별 그룹화
|
||||
clusters: dict[int, list[int]] = {}
|
||||
for i in range(n):
|
||||
clusters.setdefault(find(i), []).append(i)
|
||||
|
||||
# 모선이 있으면 모선과 가장 가까운 클러스터에 연결 (MAX_DIST_DEG 이내만)
|
||||
seed_cluster_root: Optional[int] = None
|
||||
if seed_lat is not None and seed_lon is not None:
|
||||
best_dist = float('inf')
|
||||
for root, idxs in clusters.items():
|
||||
for i in idxs:
|
||||
d = abs(gears[i]['lat'] - seed_lat) + abs(gears[i]['lon'] - seed_lon)
|
||||
if d < best_dist:
|
||||
best_dist = d
|
||||
seed_cluster_root = root
|
||||
# 모선이 어느 클러스터와도 MAX_DIST_DEG 초과 → 연결하지 않음
|
||||
if best_dist > MAX_DIST_DEG * 2:
|
||||
seed_cluster_root = None
|
||||
|
||||
# 클러스터마다 서브그룹 생성 (최소 2개 이상이거나 모선 포함)
|
||||
for ci, (root, idxs) in enumerate(clusters.items()):
|
||||
has_seed = (root == seed_cluster_root)
|
||||
if len(idxs) < 2 and not has_seed:
|
||||
continue
|
||||
|
||||
members = [
|
||||
{'mmsi': gears[i]['mmsi'], 'name': gears[i]['name'],
|
||||
'lat': gears[i]['lat'], 'lon': gears[i]['lon'],
|
||||
'sog': gears[i]['sog'], 'cog': gears[i]['cog']}
|
||||
for i in idxs
|
||||
]
|
||||
|
||||
# 서브그룹 이름: 1개면 원본, 2개 이상이면 #1, #2
|
||||
sub_name = display_name if len(clusters) == 1 else f'{display_name}#{ci + 1}'
|
||||
sub_mmsi = parent_mmsi if has_seed else None
|
||||
|
||||
results.append({
|
||||
'parent_name': sub_name,
|
||||
'parent_key': parent_key,
|
||||
'parent_mmsi': sub_mmsi,
|
||||
'members': members,
|
||||
})
|
||||
|
||||
# 3단계: 동일 parent_key 서브그룹 간 근접 병합 (거리 이내 시)
|
||||
# prefix 기반 병합은 과도한 그룹화 유발 → 동일 키만 병합
|
||||
def _groups_nearby(a: dict, b: dict) -> bool:
|
||||
for ma in a['members']:
|
||||
for mb in b['members']:
|
||||
if abs(ma['lat'] - mb['lat']) <= MAX_DIST_DEG and abs(ma['lon'] - mb['lon']) <= MAX_DIST_DEG:
|
||||
return True
|
||||
return False
|
||||
|
||||
merged: list[dict] = []
|
||||
skip: set[int] = set()
|
||||
results.sort(key=lambda g: len(g['members']), reverse=True)
|
||||
for i, big in enumerate(results):
|
||||
if i in skip:
|
||||
continue
|
||||
for j, small in enumerate(results):
|
||||
if j <= i or j in skip:
|
||||
continue
|
||||
# 동일 parent_key만 병합 (prefix 매칭 제거 — 과도한 병합 방지)
|
||||
if big['parent_key'] == small['parent_key'] and _groups_nearby(big, small):
|
||||
existing_mmsis = {m['mmsi'] for m in big['members']}
|
||||
for m in small['members']:
|
||||
if m['mmsi'] not in existing_mmsis:
|
||||
big['members'].append(m)
|
||||
existing_mmsis.add(m['mmsi'])
|
||||
if not big['parent_mmsi'] and small['parent_mmsi']:
|
||||
big['parent_mmsi'] = small['parent_mmsi']
|
||||
skip.add(j)
|
||||
del big['parent_key']
|
||||
merged.append(big)
|
||||
|
||||
# MAX_DIST_DEG 이내 어구만 포함
|
||||
_anchor_lat: float = anchor_lat
|
||||
_anchor_lon: float = anchor_lon
|
||||
nearby = [
|
||||
g for g in gears
|
||||
if abs(g['lat'] - _anchor_lat) <= MAX_DIST_DEG
|
||||
and abs(g['lon'] - _anchor_lon) <= MAX_DIST_DEG
|
||||
]
|
||||
|
||||
if not nearby:
|
||||
continue
|
||||
|
||||
# members 구성: 어구 목록
|
||||
members = [
|
||||
{
|
||||
'mmsi': g['mmsi'],
|
||||
'name': g['name'],
|
||||
'lat': g['lat'],
|
||||
'lon': g['lon'],
|
||||
'sog': g['sog'],
|
||||
'cog': g['cog'],
|
||||
}
|
||||
for g in nearby
|
||||
]
|
||||
|
||||
results.append({
|
||||
'parent_name': parent_name,
|
||||
'parent_mmsi': parent_mmsi,
|
||||
'members': members,
|
||||
})
|
||||
|
||||
return results
|
||||
return merged
|
||||
|
||||
|
||||
def build_all_group_snapshots(
|
||||
@ -340,13 +411,18 @@ def build_all_group_snapshots(
|
||||
if not in_zone and len(gear_members) < MIN_GEAR_GROUP_SIZE:
|
||||
continue
|
||||
|
||||
# 폴리곤 points: 어구 좌표 + 모선 좌표
|
||||
# 폴리곤 points: 어구 좌표 + 모선 좌표 (근접 시에만)
|
||||
points = [(g['lon'], g['lat']) for g in gear_members]
|
||||
parent_nearby = False
|
||||
if parent_mmsi and parent_mmsi in all_positions:
|
||||
parent_pos = all_positions[parent_mmsi]
|
||||
p_lon, p_lat = parent_pos['lon'], parent_pos['lat']
|
||||
if (p_lon, p_lat) not in points:
|
||||
points.append((p_lon, p_lat))
|
||||
# 모선이 어구 클러스터 내 최소 1개와 MAX_DIST_DEG*2 이내일 때만 포함
|
||||
if any(abs(g['lat'] - p_lat) <= MAX_DIST_DEG * 2
|
||||
and abs(g['lon'] - p_lon) <= MAX_DIST_DEG * 2 for g in gear_members):
|
||||
if (p_lon, p_lat) not in points:
|
||||
points.append((p_lon, p_lat))
|
||||
parent_nearby = True
|
||||
|
||||
polygon_wkt, center_wkt, area_sq_nm, _clat, _clon = build_group_polygon(
|
||||
points, GEAR_BUFFER_DEG
|
||||
@ -354,8 +430,8 @@ def build_all_group_snapshots(
|
||||
|
||||
# members JSONB 구성
|
||||
members_out: list[dict] = []
|
||||
# 모선 먼저
|
||||
if parent_mmsi and parent_mmsi in all_positions:
|
||||
# 모선 먼저 (근접 시에만)
|
||||
if parent_nearby and parent_mmsi and parent_mmsi in all_positions:
|
||||
parent_pos = all_positions[parent_mmsi]
|
||||
members_out.append({
|
||||
'mmsi': parent_mmsi,
|
||||
|
||||
60
prediction/cache/vessel_store.py
vendored
60
prediction/cache/vessel_store.py
vendored
@ -349,6 +349,66 @@ class VesselStore:
|
||||
}
|
||||
return result
|
||||
|
||||
def get_vessel_tracks(self, mmsis: list[str], hours: int = 24) -> dict[str, list[dict]]:
|
||||
"""Return track points for given MMSIs within the specified hours window.
|
||||
|
||||
Returns dict mapping mmsi to list of {ts, lat, lon, sog, cog} dicts,
|
||||
sorted by timestamp ascending.
|
||||
"""
|
||||
import datetime as _dt
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
cutoff_aware = now - _dt.timedelta(hours=hours)
|
||||
cutoff_naive = cutoff_aware.replace(tzinfo=None)
|
||||
|
||||
result: dict[str, list[dict]] = {}
|
||||
for mmsi in mmsis:
|
||||
df = self._tracks.get(mmsi)
|
||||
if df is None or len(df) == 0:
|
||||
continue
|
||||
|
||||
ts_col = df['timestamp']
|
||||
if hasattr(ts_col.dtype, 'tz') and ts_col.dtype.tz is not None:
|
||||
mask = ts_col >= pd.Timestamp(cutoff_aware)
|
||||
else:
|
||||
mask = ts_col >= pd.Timestamp(cutoff_naive)
|
||||
|
||||
filtered = df[mask].sort_values('timestamp')
|
||||
if filtered.empty:
|
||||
continue
|
||||
|
||||
# Compute SOG/COG for this vessel's track
|
||||
if len(filtered) >= 2:
|
||||
track_with_sog = _compute_sog_cog(filtered.copy())
|
||||
else:
|
||||
track_with_sog = filtered.copy()
|
||||
if 'sog' not in track_with_sog.columns:
|
||||
track_with_sog['sog'] = track_with_sog.get('raw_sog', 0)
|
||||
if 'cog' not in track_with_sog.columns:
|
||||
track_with_sog['cog'] = 0
|
||||
|
||||
points = []
|
||||
for _, row in track_with_sog.iterrows():
|
||||
ts = row['timestamp']
|
||||
# Convert to epoch ms
|
||||
if hasattr(ts, 'timestamp'):
|
||||
epoch_ms = int(ts.timestamp() * 1000)
|
||||
else:
|
||||
epoch_ms = int(pd.Timestamp(ts).timestamp() * 1000)
|
||||
|
||||
points.append({
|
||||
'ts': epoch_ms,
|
||||
'lat': float(row['lat']),
|
||||
'lon': float(row['lon']),
|
||||
'sog': float(row.get('sog', 0) or 0),
|
||||
'cog': float(row.get('cog', 0) or 0),
|
||||
})
|
||||
|
||||
if points:
|
||||
result[mmsi] = points
|
||||
|
||||
return result
|
||||
|
||||
def get_chinese_mmsis(self) -> set:
|
||||
"""Return the set of all Chinese vessel MMSIs (412*) currently in the store."""
|
||||
return {m for m in self._tracks if m.startswith(_CHINESE_MMSI_PREFIX)}
|
||||
|
||||
@ -9,8 +9,8 @@ import pandas as pd
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 어구 이름 패턴
|
||||
GEAR_PATTERN = re.compile(r'^(.+?)_(\d+)_(\d*)$')
|
||||
# 어구 이름 패턴 — 공백/영숫자 인덱스/끝_ 허용
|
||||
GEAR_PATTERN = re.compile(r'^(.+?)_(?=\S*\d)\S+(?:[_ ]\S*)*[_ ]*$|^(\d+)$')
|
||||
GEAR_PATTERN_PCT = re.compile(r'^(.+?)%$')
|
||||
|
||||
_REGISTRY_CACHE_SEC = 3600
|
||||
@ -139,9 +139,16 @@ class FleetTracker:
|
||||
|
||||
m = GEAR_PATTERN.match(name)
|
||||
if m:
|
||||
parent_name = m.group(1).strip()
|
||||
idx1 = int(m.group(2))
|
||||
idx2 = int(m.group(3)) if m.group(3) else None
|
||||
# group(1): parent+index 패턴, group(2): 순수 숫자 패턴
|
||||
if m.group(1):
|
||||
parent_name = m.group(1).strip()
|
||||
suffix = name[m.end(1):].strip(' _')
|
||||
digits = re.findall(r'\d+', suffix)
|
||||
idx1 = int(digits[0]) if len(digits) >= 1 else None
|
||||
idx2 = int(digits[1]) if len(digits) >= 2 else None
|
||||
else:
|
||||
# 순수 숫자 이름 (예: 12345) — parent 없음, 인덱스만
|
||||
idx1 = int(m.group(2))
|
||||
else:
|
||||
m2 = GEAR_PATTERN_PCT.match(name)
|
||||
if m2:
|
||||
|
||||
@ -68,3 +68,75 @@ def analysis_status():
|
||||
def trigger_analysis(background_tasks: BackgroundTasks):
|
||||
background_tasks.add_task(run_analysis_cycle)
|
||||
return {'message': 'analysis cycle triggered'}
|
||||
|
||||
|
||||
@app.get('/api/v1/correlation/{group_key:path}/tracks')
|
||||
def get_correlation_tracks(
|
||||
group_key: str,
|
||||
hours: int = 24,
|
||||
min_score: float = 0.3,
|
||||
):
|
||||
"""Return correlated vessels with their track history for map rendering.
|
||||
|
||||
Queries gear_correlation_scores (default model) and enriches with
|
||||
24h track data from in-memory vessel_store.
|
||||
"""
|
||||
from cache.vessel_store import vessel_store
|
||||
|
||||
try:
|
||||
conn = kcgdb.get_conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Get correlated vessels from default model
|
||||
cur.execute("""
|
||||
SELECT s.target_mmsi, s.target_type, s.target_name,
|
||||
s.current_score, m.name AS model_name
|
||||
FROM kcg.gear_correlation_scores s
|
||||
JOIN kcg.correlation_param_models m ON s.model_id = m.id
|
||||
WHERE s.group_key = %s
|
||||
AND s.current_score >= %s
|
||||
AND m.is_default = TRUE
|
||||
AND m.is_active = TRUE
|
||||
ORDER BY s.current_score DESC
|
||||
""", (group_key, min_score))
|
||||
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if not rows:
|
||||
return {'groupKey': group_key, 'vessels': []}
|
||||
|
||||
# Collect target MMSIs
|
||||
vessel_info = []
|
||||
mmsis = []
|
||||
for row in rows:
|
||||
vessel_info.append({
|
||||
'mmsi': row[0],
|
||||
'type': row[1],
|
||||
'name': row[2] or '',
|
||||
'score': float(row[3]),
|
||||
'modelName': row[4],
|
||||
})
|
||||
mmsis.append(row[0])
|
||||
|
||||
# Get tracks from vessel_store
|
||||
tracks = vessel_store.get_vessel_tracks(mmsis, hours)
|
||||
|
||||
# Build response
|
||||
vessels = []
|
||||
for info in vessel_info:
|
||||
track = tracks.get(info['mmsi'], [])
|
||||
vessels.append({
|
||||
'mmsi': info['mmsi'],
|
||||
'name': info['name'],
|
||||
'score': info['score'],
|
||||
'modelName': info['modelName'],
|
||||
'track': track,
|
||||
})
|
||||
|
||||
return {'groupKey': group_key, 'vessels': vessels}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning('get_correlation_tracks failed for %s: %s', group_key, e)
|
||||
return {'groupKey': group_key, 'vessels': []}
|
||||
|
||||
@ -75,7 +75,7 @@ def run_analysis_cycle():
|
||||
return
|
||||
|
||||
# 4. 등록 선단 기반 fleet 분석
|
||||
_gear_re = _re.compile(r'^.+_\d+_\d*$|%$')
|
||||
_gear_re = _re.compile(r'^.+_(?=\S*\d)\S+(?:[_ ]\S*)*[_ ]*$|^\d+$|^.+%$')
|
||||
with kcgdb.get_conn() as kcg_conn:
|
||||
fleet_tracker.load_registry(kcg_conn)
|
||||
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user