feat: 비허가 어구 클러스터 집계 + 폴리곤 시각화

- AnalysisStatsPanel: 어구그룹/어구수 통계 (주황색)
- FleetClusterLayer: 비허가 어구 ConvexHull 폴리곤 (주황 점선) + 목록 패널
- 허가 선단(HSL 색상) vs 비허가 어구(주황) 별도 시각화

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-03-20 18:43:48 +09:00
부모 345a5d5250
커밋 730872d47e
3개의 변경된 파일252개의 추가작업 그리고 2개의 파일을 삭제

파일 보기

@ -9,6 +9,7 @@ interface Props {
isLoading: boolean; isLoading: boolean;
analysisMap: Map<string, VesselAnalysisDto>; analysisMap: Map<string, VesselAnalysisDto>;
ships: Ship[]; ships: Ship[];
allShips?: Ship[];
onShipSelect?: (mmsi: string) => void; onShipSelect?: (mmsi: string) => void;
onTrackLoad?: (mmsi: string, coords: [number, number][]) => void; onTrackLoad?: (mmsi: string, coords: [number, number][]) => void;
} }
@ -70,7 +71,7 @@ const LEGEND_LINES = [
'스푸핑: 순간이동+SOG급변+BD09 종합', '스푸핑: 순간이동+SOG급변+BD09 종합',
]; ];
export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, ships, onShipSelect, onTrackLoad }: Props) { export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap, ships, allShips, onShipSelect, onTrackLoad }: Props) {
const [expanded, setExpanded] = useState(true); const [expanded, setExpanded] = useState(true);
const [selectedLevel, setSelectedLevel] = useState<RiskLevel | null>(null); const [selectedLevel, setSelectedLevel] = useState<RiskLevel | null>(null);
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null); const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
@ -78,6 +79,23 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
const isEmpty = useMemo(() => stats.total === 0, [stats.total]); const isEmpty = useMemo(() => stats.total === 0, [stats.total]);
const gearStats = useMemo(() => {
const source = allShips ?? ships;
const gearPattern = /^(.+?)_\d+_\d+_?$/;
const parentMap = new Map<string, number>();
for (const s of source) {
const m = (s.name || '').match(gearPattern);
if (m) {
const parent = m[1].trim();
parentMap.set(parent, (parentMap.get(parent) || 0) + 1);
}
}
return {
groups: parentMap.size,
count: Array.from(parentMap.values()).reduce((a, b) => a + b, 0),
};
}, [allShips, ships]);
const vesselList = useMemo((): VesselListItem[] => { const vesselList = useMemo((): VesselListItem[] => {
if (!selectedLevel) return []; if (!selectedLevel) return [];
const list: VesselListItem[] = []; const list: VesselListItem[] = [];
@ -244,6 +262,18 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
<span style={labelStyle}></span> <span style={labelStyle}></span>
<span style={{ ...valueStyle, color: '#06b6d4' }}>{stats.clusterCount}</span> <span style={{ ...valueStyle, color: '#06b6d4' }}>{stats.clusterCount}</span>
</div> </div>
{gearStats.groups > 0 && (
<>
<div style={rowStyle}>
<span style={labelStyle}></span>
<span style={{ ...valueStyle, color: '#f97316' }}>{gearStats.groups}</span>
</div>
<div style={rowStyle}>
<span style={labelStyle}></span>
<span style={{ ...valueStyle, color: '#f97316' }}>{gearStats.count}</span>
</div>
</>
)}
<div style={dividerStyle} /> <div style={dividerStyle} />

파일 보기

@ -88,6 +88,7 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
const [expanded, setExpanded] = useState(true); const [expanded, setExpanded] = useState(true);
const [expandedFleet, setExpandedFleet] = useState<number | null>(null); const [expandedFleet, setExpandedFleet] = useState<number | null>(null);
const [hoveredFleetId, setHoveredFleetId] = useState<number | null>(null); const [hoveredFleetId, setHoveredFleetId] = useState<number | null>(null);
const [expandedGearGroup, setExpandedGearGroup] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
fetchFleetCompanies().then(setCompanies).catch(() => {}); fetchFleetCompanies().then(setCompanies).catch(() => {});
@ -123,6 +124,71 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
return m; return m;
}, [ships]); }, [ships]);
// 비허가 어구 클러스터: parentName → { parent: Ship | null, gears: Ship[] }
const gearGroupMap = useMemo(() => {
const gearPattern = /^(.+?)_\d+_\d+_?$/;
const nameToShip = new Map<string, Ship>();
for (const s of ships) {
const nm = (s.name || '').trim();
if (nm && !gearPattern.test(nm)) {
nameToShip.set(nm, s);
}
}
const map = new Map<string, { parent: Ship | null; gears: Ship[] }>();
for (const s of ships) {
const m = (s.name || '').match(gearPattern);
if (!m) continue;
const parentName = m[1].trim();
const entry = map.get(parentName) ?? { parent: nameToShip.get(parentName) ?? null, gears: [] };
entry.gears.push(s);
map.set(parentName, entry);
}
return map;
}, [ships]);
// 비허가 어구 클러스터 GeoJSON
const gearClusterGeoJson = useMemo((): GeoJSON => {
const features: GeoJSON.Feature[] = [];
for (const [parentName, { parent, gears }] of gearGroupMap) {
const points: [number, number][] = gears.map(g => [g.lng, g.lat]);
if (parent) points.push([parent.lng, parent.lat]);
if (points.length < 3) continue;
const hull = convexHull(points);
const padded = padPolygon(hull, 0.01);
padded.push(padded[0]);
features.push({
type: 'Feature',
properties: { name: parentName, gearCount: gears.length },
geometry: { type: 'Polygon', coordinates: [padded] },
});
}
return { type: 'FeatureCollection', features };
}, [gearGroupMap]);
// 어구 그룹 목록 (어구 수 내림차순)
const gearGroupList = useMemo(() => {
return Array.from(gearGroupMap.entries())
.map(([name, { parent, gears }]) => ({ name, parent, gears }))
.sort((a, b) => b.gears.length - a.gears.length);
}, [gearGroupMap]);
const handleGearGroupZoom = useCallback((parentName: string) => {
const entry = gearGroupMap.get(parentName);
if (!entry) return;
const all: Ship[] = [...entry.gears];
if (entry.parent) all.push(entry.parent);
if (all.length === 0) return;
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
for (const s of all) {
if (s.lat < minLat) minLat = s.lat;
if (s.lat > maxLat) maxLat = s.lat;
if (s.lng < minLng) minLng = s.lng;
if (s.lng > maxLng) maxLng = s.lng;
}
if (minLat === Infinity) return;
onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
}, [gearGroupMap, onFleetZoom]);
// GeoJSON 피처 생성 // GeoJSON 피처 생성
const polygonFeatures = useMemo((): ClusterFeature[] => { const polygonFeatures = useMemo((): ClusterFeature[] => {
const features: ClusterFeature[] = []; const features: ClusterFeature[] = [];
@ -291,6 +357,27 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
/> />
</Source> </Source>
{/* 비허가 어구 클러스터 폴리곤 */}
<Source id="gear-clusters" type="geojson" data={gearClusterGeoJson}>
<Layer
id="gear-cluster-fill-layer"
type="fill"
paint={{
'fill-color': 'rgba(249, 115, 22, 0.08)',
}}
/>
<Layer
id="gear-cluster-line-layer"
type="line"
paint={{
'line-color': '#f97316',
'line-opacity': 0.7,
'line-width': 1.5,
'line-dasharray': [4, 2],
}}
/>
</Source>
{/* 선단 목록 패널 */} {/* 선단 목록 패널 */}
<div style={panelStyle}> <div style={panelStyle}>
<div style={headerStyle}> <div style={headerStyle}>
@ -307,7 +394,7 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
</div> </div>
{expanded && ( {expanded && (
<div style={{ maxHeight: 400, overflowY: 'auto', padding: '4px 0' }}> <div style={{ maxHeight: 500, overflowY: 'auto', padding: '4px 0' }}>
{fleetList.length === 0 ? ( {fleetList.length === 0 ? (
<div style={{ color: '#64748b', textAlign: 'center', padding: '8px 10px' }}> <div style={{ color: '#64748b', textAlign: 'center', padding: '8px 10px' }}>
@ -466,6 +553,138 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
); );
}) })
)} )}
{/* 비허가 어구 그룹 섹션 */}
{gearGroupList.length > 0 && (
<>
<div style={{
borderTop: '1px solid rgba(249,115,22,0.25)',
margin: '6px 10px',
}} />
<div style={{
padding: '2px 10px 4px',
fontSize: 10,
color: '#f97316',
fontWeight: 700,
letterSpacing: 0.3,
}}>
({gearGroupList.length})
</div>
{gearGroupList.map(({ name, parent, gears }) => {
const isOpen = expandedGearGroup === name;
return (
<div key={name}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '3px 10px',
cursor: 'pointer',
borderLeft: isOpen ? '2px solid #f97316' : '2px solid transparent',
transition: 'background-color 0.1s',
}}
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(255,255,255,0.04)'; }}
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.backgroundColor = 'transparent'; }}
>
<span
onClick={() => setExpandedGearGroup(prev => (prev === name ? null : name))}
style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }}
>
{isOpen ? '▾' : '▸'}
</span>
<span style={{
width: 8, height: 8, borderRadius: '50%',
backgroundColor: '#f97316', flexShrink: 0,
}} />
<span
onClick={() => setExpandedGearGroup(prev => (prev === name ? null : name))}
style={{
flex: 1,
color: '#e2e8f0',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
cursor: 'pointer',
}}
title={name}
>
{name}
</span>
<span style={{ color: '#64748b', fontSize: 10, flexShrink: 0 }}>
({gears.length})
</span>
<button
type="button"
onClick={e => { e.stopPropagation(); handleGearGroupZoom(name); }}
style={{
background: 'none',
border: '1px solid rgba(249,115,22,0.4)',
borderRadius: 3,
color: '#f97316',
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 rgba(249,115,22,0.2)',
marginLeft: 10,
}}>
{parent && (
<div style={{ color: '#fbbf24', marginBottom: 2 }}>
: {parent.name || parent.mmsi}
</div>
)}
<div style={{ color: '#64748b', marginBottom: 2 }}> :</div>
{gears.map(g => (
<div key={g.mmsi} style={{
display: 'flex',
alignItems: 'center',
gap: 4,
marginBottom: 1,
}}>
<span style={{ flex: 1, color: '#475569', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{g.name || g.mmsi}
</span>
<button
type="button"
onClick={() => onShipSelect?.(g.mmsi)}
style={{
background: 'none',
border: 'none',
color: '#f97316',
fontSize: 10,
cursor: 'pointer',
padding: '0 2px',
flexShrink: 0,
}}
title="어구 위치로 이동"
aria-label={`${g.name || g.mmsi} 위치로 이동`}
>
</button>
</div>
))}
</div>
)}
</div>
);
})}
</>
)}
</div> </div>
)} )}
</div> </div>

파일 보기

@ -451,6 +451,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
isLoading={vesselAnalysis.isLoading} isLoading={vesselAnalysis.isLoading}
analysisMap={vesselAnalysis.analysisMap} analysisMap={vesselAnalysis.analysisMap}
ships={allShips ?? ships} ships={allShips ?? ships}
allShips={allShips ?? ships}
onShipSelect={handleAnalysisShipSelect} onShipSelect={handleAnalysisShipSelect}
onTrackLoad={handleTrackLoad} onTrackLoad={handleTrackLoad}
/> />