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:
htlee 2026-03-31 07:44:07 +09:00
부모 2fb0842523
커밋 bbbc326e38
23개의 변경된 파일4813개의 추가작업 그리고 1632개의 파일을 삭제

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. 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",

파일 보기

@ -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

파일 보기

@ -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;

파일 보기

@ -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;

파일 보기

@ -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;

파일 보기

@ -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;

파일 보기

@ -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,
};

파일 보기

@ -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>();

파일 보기

@ -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;
}

파일 보기

@ -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,
};
}

파일 보기

@ -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회 로드)

파일 보기

@ -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(12)),
* 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,
);
});
}

파일 보기

@ -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,

파일 보기

@ -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)