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:
부모
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>
|
||||
)}
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user