release: 비허가 어구 클러스터 #135
@ -9,6 +9,7 @@ interface Props {
|
||||
isLoading: boolean;
|
||||
analysisMap: Map<string, VesselAnalysisDto>;
|
||||
ships: Ship[];
|
||||
allShips?: Ship[];
|
||||
onShipSelect?: (mmsi: string) => void;
|
||||
onTrackLoad?: (mmsi: string, coords: [number, number][]) => void;
|
||||
}
|
||||
@ -70,7 +71,7 @@ const LEGEND_LINES = [
|
||||
'스푸핑: 순간이동+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 [selectedLevel, setSelectedLevel] = useState<RiskLevel | 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 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[] => {
|
||||
if (!selectedLevel) return [];
|
||||
const list: VesselListItem[] = [];
|
||||
@ -244,6 +262,18 @@ export function AnalysisStatsPanel({ stats, lastUpdated, isLoading, analysisMap,
|
||||
<span style={labelStyle}>선단수</span>
|
||||
<span style={{ ...valueStyle, color: '#06b6d4' }}>{stats.clusterCount}</span>
|
||||
</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} />
|
||||
|
||||
|
||||
@ -88,6 +88,7 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [expandedFleet, setExpandedFleet] = useState<number | null>(null);
|
||||
const [hoveredFleetId, setHoveredFleetId] = useState<number | null>(null);
|
||||
const [expandedGearGroup, setExpandedGearGroup] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFleetCompanies().then(setCompanies).catch(() => {});
|
||||
@ -123,6 +124,71 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
|
||||
return m;
|
||||
}, [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 피처 생성
|
||||
const polygonFeatures = useMemo((): ClusterFeature[] => {
|
||||
const features: ClusterFeature[] = [];
|
||||
@ -291,6 +357,27 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
|
||||
/>
|
||||
</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={headerStyle}>
|
||||
@ -307,7 +394,7 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div style={{ maxHeight: 400, overflowY: 'auto', padding: '4px 0' }}>
|
||||
<div style={{ maxHeight: 500, overflowY: 'auto', padding: '4px 0' }}>
|
||||
{fleetList.length === 0 ? (
|
||||
<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>
|
||||
|
||||
@ -451,6 +451,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
isLoading={vesselAnalysis.isLoading}
|
||||
analysisMap={vesselAnalysis.analysisMap}
|
||||
ships={allShips ?? ships}
|
||||
allShips={allShips ?? ships}
|
||||
onShipSelect={handleAnalysisShipSelect}
|
||||
onTrackLoad={handleTrackLoad}
|
||||
/>
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user