fix: 모델 토글을 호버 팝업에서 하단 고정 패널로 이동

호버 팝업은 마우스 이동 시 사라져서 토글 조작 불가 →
어구 그룹 선택 시 하단 중앙에 고정 패널 배치:
- 좌측: 그룹 정보 + 폴리곤 오버레이 토글 (이름 기반 + 5개 모델)
- 우측: 연관 선박 목록 (default 모델 상위 12건, 스크롤)
- ✕ 버튼으로 선택 해제
- 히스토리 재생 컨트롤러와 동일 위치/스타일

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-03-30 12:45:30 +09:00
부모 4c994e277a
커밋 dc6070d619

파일 보기

@ -1240,86 +1240,6 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
· {m.name || m.mmsi}
</div>
))}
{/* 모델 폴리곤 토글 (선택된 그룹만) */}
{selectedGearGroup === name && !correlationLoading && availableModels.length > 0 && (
<div style={{ marginTop: 5, borderTop: '1px solid rgba(255,255,255,0.15)', paddingTop: 4 }}>
<div style={{ fontSize: 9, fontWeight: 700, color: '#93c5fd', marginBottom: 3 }}>
</div>
{/* 이름 기반 (항상 표시) */}
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, cursor: 'pointer', marginBottom: 2 }}>
<input type="checkbox" checked={enabledModels.has('identity')}
onChange={() => setEnabledModels(prev => {
const next = new Set(prev);
if (next.has('identity')) next.delete('identity'); else next.add('identity');
return next;
})}
style={{ accentColor: '#f97316', width: 10, height: 10 }}
/>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#f97316', flexShrink: 0 }} />
<span style={{ color: '#e2e8f0' }}> </span>
<span style={{ color: '#64748b', marginLeft: 'auto' }}>{group.memberCount}</span>
</label>
{/* 모델별 체크박스 */}
{availableModels.map(m => {
const color = MODEL_COLORS[m.name] ?? '#94a3b8';
const modelItems = correlationByModel.get(m.name) ?? [];
const above70 = modelItems.filter(c => c.score >= 0.7).length;
return (
<label key={m.name} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, cursor: 'pointer', marginBottom: 2 }}>
<input type="checkbox" checked={enabledModels.has(m.name)}
onChange={() => setEnabledModels(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: 10, height: 10 }}
/>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: color, flexShrink: 0 }} />
<span style={{ color: '#e2e8f0' }}>{m.name}{m.isDefault ? ' *' : ''}</span>
<span style={{ color: '#64748b', marginLeft: 'auto' }}>{above70}</span>
</label>
);
})}
</div>
)}
{/* 연관 선박 상위 목록 (default 모델) */}
{selectedGearGroup === name && (() => {
const defaultItems = correlationData.filter(c => c.isDefault);
if (defaultItems.length === 0) return null;
return (
<div style={{ marginTop: 4, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 3 }}>
<div style={{ fontSize: 8, color: '#64748b', marginBottom: 2 }}> (default 8)</div>
{defaultItems.slice(0, 8).map(c => {
const pct = (c.score * 100).toFixed(0);
const barW = Math.max(2, c.score * 60);
const barColor = c.score >= 0.7 ? '#22c55e' : c.score >= 0.5 ? '#eab308' : '#94a3b8';
return (
<div key={c.targetMmsi} style={{ fontSize: 9, marginTop: 2, display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ color: c.targetType === 'VESSEL' ? '#60a5fa' : '#f97316', width: 10, textAlign: 'center' }}>
{c.targetType === 'VESSEL' ? '⛴' : '◆'}
</span>
<span style={{ color: '#cbd5e1', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 100 }}>
{c.targetName || c.targetMmsi}
</span>
<div style={{ width: 62, display: 'flex', alignItems: 'center', gap: 2 }}>
<div style={{ width: 40, height: 4, background: 'rgba(255,255,255,0.1)', borderRadius: 2, overflow: 'hidden' }}>
<div style={{ width: barW, height: '100%', background: barColor, borderRadius: 2 }} />
</div>
<span style={{ color: barColor, fontSize: 8, minWidth: 22, textAlign: 'right' }}>{pct}%</span>
</div>
</div>
);
})}
{defaultItems.length > 8 && (
<div style={{ fontSize: 8, color: '#4a6b82', marginTop: 2 }}>+{defaultItems.length - 8} </div>
)}
</div>
);
})()}
{selectedGearGroup === name && correlationLoading && (
<div style={{ fontSize: 8, color: '#64748b', marginTop: 3 }}> ...</div>
)}
<div style={{ fontSize: 8, color: '#4a6b82', marginTop: 3 }}> /</div>
</div>
</Popup>
@ -1414,6 +1334,110 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
</Source>
)}
{/* ── 어구 연관성 패널 (선택된 그룹, 하단 고정) ── */}
{selectedGearGroup && !historyData && (() => {
const allGroups = groupPolygons
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
: [];
const group = allGroups.find(g => g.groupKey === selectedGearGroup);
if (!group) return null;
const defaultItems = correlationData.filter(c => c.isDefault);
return (
<div style={{
position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)',
background: 'rgba(12,24,37,0.95)', border: '1px solid rgba(249,115,22,0.3)',
borderRadius: 8, padding: '8px 12px',
display: 'flex', gap: 12, alignItems: 'flex-start',
zIndex: 20, fontFamily: FONT_MONO, fontSize: 10, color: '#e2e8f0',
boxShadow: '0 4px 16px rgba(0,0,0,0.5)', pointerEvents: 'auto',
maxWidth: 600, minWidth: 320,
}}>
{/* 좌: 그룹 정보 + 모델 토글 */}
<div style={{ minWidth: 150, flexShrink: 0 }}>
<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 }}> {group.memberCount}</span>
<button type="button" onClick={() => setSelectedGearGroup(null)} style={{
background: 'none', border: '1px solid rgba(239,68,68,0.3)', borderRadius: 4,
color: '#ef4444', cursor: 'pointer', padding: '1px 5px', fontSize: 10,
fontFamily: FONT_MONO, marginLeft: 'auto',
}}></button>
</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={() => setEnabledModels(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 }}
/>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#f97316', flexShrink: 0 }} />
<span style={{ color: '#e2e8f0' }}> </span>
<span style={{ color: '#64748b', marginLeft: 'auto' }}>{group.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 above70 = modelItems.filter(c => c.score >= 0.7).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={() => setEnabledModels(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 }}
/>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, flexShrink: 0 }} />
<span style={{ color: '#e2e8f0' }}>{m.name}{m.isDefault ? ' *' : ''}</span>
<span style={{ color: '#64748b', marginLeft: 'auto' }}>{above70}</span>
</label>
);
})}
</div>
{/* 우: 연관 선박 목록 (default 모델) */}
{defaultItems.length > 0 && (
<div style={{ flex: 1, minWidth: 0, borderLeft: '1px solid rgba(255,255,255,0.1)', paddingLeft: 10 }}>
<div style={{ fontSize: 9, fontWeight: 700, color: '#93c5fd', marginBottom: 4 }}>
({defaultItems.length})
</div>
<div style={{ maxHeight: 140, overflowY: 'auto' }}>
{defaultItems.slice(0, 12).map(c => {
const pct = (c.score * 100).toFixed(0);
const barW = Math.max(2, c.score * 60);
const barColor = c.score >= 0.7 ? '#22c55e' : c.score >= 0.5 ? '#eab308' : '#94a3b8';
return (
<div key={c.targetMmsi} style={{ fontSize: 9, marginBottom: 2, display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ color: c.targetType === 'VESSEL' ? '#60a5fa' : '#f97316', width: 10, textAlign: 'center', flexShrink: 0 }}>
{c.targetType === 'VESSEL' ? '⛴' : '◆'}
</span>
<span style={{ color: '#cbd5e1', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{c.targetName || c.targetMmsi}
</span>
<div style={{ width: 70, display: 'flex', alignItems: 'center', gap: 2, flexShrink: 0 }}>
<div style={{ width: 44, height: 4, 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: 24, textAlign: 'right' }}>{pct}%</span>
</div>
</div>
);
})}
{defaultItems.length > 12 && (
<div style={{ fontSize: 8, color: '#4a6b82', marginTop: 2 }}>+{defaultItems.length - 12} </div>
)}
</div>
</div>
)}
</div>
);
})()}
{/* 히스토리 재생 컨트롤러 */}
{historyData && (() => {
const curTime = new Date(currentTimeMs);