fix: 모델 토글을 호버 팝업에서 하단 고정 패널로 이동
호버 팝업은 마우스 이동 시 사라져서 토글 조작 불가 → 어구 그룹 선택 시 하단 중앙에 고정 패널 배치: - 좌측: 그룹 정보 + 폴리곤 오버레이 토글 (이름 기반 + 5개 모델) - 우측: 연관 선박 목록 (default 모델 상위 12건, 스크롤) - ✕ 버튼으로 선택 해제 - 히스토리 재생 컨트롤러와 동일 위치/스타일 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
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);
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user