feat: 어구 그룹 멀티모델 폴리곤 오버레이 + 토글 UI

- 어구 그룹 선택 시 전체 모델(5개) 연관성 데이터 로드
- enabledModels 상태: 'identity'(이름 기반) + 'default' 기본 ON
- 모델별 오퍼레이셔널 폴리곤 클라이언트 생성 (70%+ 연관 대상 합산 convex hull)
- Source+Layer 오버레이: 모델별 고유 색상, 대시 라인 구분
- 팝업 UI: 모델 토글 체크박스 (최대 5개), 색상 인디케이터 + 70%+ 대상 수
- 연관 선박 상위 8건 바 그래프 (default 모델 기준)
- 선택 시 팝업 maxWidth 280px로 확장

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

파일 보기

@ -241,9 +241,11 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
candidates: { name: string; count: number; inZone: boolean; isFleet: boolean; clusterId?: number }[];
} | null>(null);
const [pickerHoveredGroup, setPickerHoveredGroup] = useState<string | null>(null);
// 어구 연관성 데이터
// 어구 연관성 데이터 (전체 모델)
const [correlationData, setCorrelationData] = useState<GearCorrelationItem[]>([]);
const [correlationLoading, setCorrelationLoading] = useState(false);
// 활성화된 모델 ('identity' = 이름기반, 'default' = 기본모델, 나머지 = 추가모델)
const [enabledModels, setEnabledModels] = useState<Set<string>>(new Set(['identity', 'default']));
// 히스토리 애니메이션 — 12시간 실시간 타임라인
const [historyData, setHistoryData] = useState<HistoryFrame[] | null>(null);
const [, setHistoryGroupKey] = useState<string | null>(null);
@ -551,8 +553,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
fetchGroupCorrelations(selectedGearGroup, 0.3)
.then(res => {
if (!cancelled) {
// default 모델 결과만 팝업에 표시
setCorrelationData(res.items.filter(i => i.isDefault));
setCorrelationData(res.items);
}
})
.catch(() => { if (!cancelled) setCorrelationData([]); })
@ -630,6 +631,90 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
};
}, [hoveredFleetId, groupPolygons?.fleetGroups]);
// 모델별 연관성 데이터 그룹핑
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]);
// 모델별 오퍼레이셔널 폴리곤 GeoJSON (identity 제외, correlation 모델만)
const MODEL_COLORS: Record<string, string> = {
'default': '#3b82f6', // 파랑
'aggressive': '#22c55e', // 초록
'conservative': '#a855f7', // 보라
'proximity-heavy': '#06b6d4', // 시안
'visit-pattern': '#f43f5e', // 로즈
};
const operationalPolygons = useMemo(() => {
if (!selectedGearGroup || !groupPolygons) return [];
const allGroups = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
const group = allGroups.find(g => g.groupKey === selectedGearGroup);
if (!group) return [];
// 이름 기반 멤버 위치
const baseMemberPositions: [number, number][] = group.members.map(m => [m.lon, m.lat]);
// ships prop에서 위치 조회용 맵
const posMap = new Map<string, { lat: number; lon: number }>();
for (const s of ships) {
posMap.set(s.mmsi, { lat: s.lat, lon: s.lng });
}
const result: { modelName: string; color: string; geojson: GeoJSON.FeatureCollection }[] = [];
for (const [modelName, items] of correlationByModel) {
if (!enabledModels.has(modelName)) continue;
// 70%+ 점수 대상의 위치 수집
const extraPositions: [number, number][] = [];
for (const c of items) {
if (c.score < 0.7) continue;
const pos = posMap.get(c.targetMmsi);
if (pos) extraPositions.push([pos.lon, pos.lat]);
}
if (extraPositions.length === 0) continue;
// 이름 기반 + 연관 대상 합산
const allPoints = [...baseMemberPositions, ...extraPositions];
const polygon = buildInterpPolygon(allPoints);
if (!polygon) continue;
const color = MODEL_COLORS[modelName] ?? '#94a3b8';
result.push({
modelName,
color,
geojson: {
type: 'FeatureCollection',
features: [{
type: 'Feature',
properties: { modelName, color },
geometry: polygon,
}],
},
});
}
return result;
}, [selectedGearGroup, groupPolygons, correlationByModel, enabledModels, ships]);
// 어구 클러스터 GeoJSON (서버 제공)
const gearClusterGeoJson = useMemo((): GeoJSON => {
const features: GeoJSON.Feature[] = [];
@ -957,8 +1042,8 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
/>
</Source>
{/* 선택된 어구 그룹 하이라이트 폴리곤 — 히스토리 모드에서는 숨김 */}
{selectedGearGroup && !historyData && (() => {
{/* 선택된 어구 그룹 — 이름 기반 하이라이트 폴리곤 */}
{selectedGearGroup && !historyData && enabledModels.has('identity') && (() => {
const allGroups = groupPolygons
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
: [];
@ -980,6 +1065,19 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
);
})()}
{/* 선택된 어구 그룹 — 모델별 오퍼레이셔널 폴리곤 오버레이 */}
{selectedGearGroup && !historyData && 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
@ -1128,7 +1226,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
return (
<Popup longitude={hoverTooltip.lng} latitude={hoverTooltip.lat}
closeButton={false} closeOnClick={false} anchor="bottom"
className="gl-popup" maxWidth="220px"
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 }}>
@ -1142,38 +1240,83 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
· {m.name || m.mmsi}
</div>
))}
{/* 연관 선박/어구 (선택된 그룹만) */}
{selectedGearGroup === name && correlationData.length > 0 && (
{/* 모델 폴리곤 토글 (선택된 그룹만) */}
{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: 2 }}>
{correlationData.length}
<div style={{ fontSize: 9, fontWeight: 700, color: '#93c5fd', marginBottom: 3 }}>
</div>
{correlationData.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';
{/* 이름 기반 (항상 표시) */}
<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 (
<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>
<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>
);
})}
{correlationData.length > 8 && (
<div style={{ fontSize: 8, color: '#4a6b82', marginTop: 2 }}>+{correlationData.length - 8} </div>
)}
</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>
)}