feat: 비허가 어구 클러스터 집계 + 폴리곤 시각화
- AnalysisStatsPanel: 어구그룹/어구수 통계 (주황색) - FleetClusterLayer: 비허가 어구 ConvexHull 폴리곤 (주황 점선) + 목록 패널 - 허가 선단(HSL 색상) vs 비허가 어구(주황) 별도 시각화 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
345a5d5250
커밋
730872d47e
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user