|
|
|
|
@ -5,6 +5,7 @@ import type { MapLayerMouseEvent } from 'maplibre-gl';
|
|
|
|
|
import type { Ship, VesselAnalysisDto } from '../../types';
|
|
|
|
|
import { fetchFleetCompanies } from '../../services/vesselAnalysis';
|
|
|
|
|
import type { FleetCompany } from '../../services/vesselAnalysis';
|
|
|
|
|
import { classifyFishingZone } from '../../utils/fishingAnalysis';
|
|
|
|
|
|
|
|
|
|
export interface SelectedGearGroupData {
|
|
|
|
|
parent: Ship | null;
|
|
|
|
|
@ -20,8 +21,8 @@ export interface SelectedFleetData {
|
|
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
ships: Ship[];
|
|
|
|
|
analysisMap: Map<string, VesselAnalysisDto>;
|
|
|
|
|
clusters: Map<number, string[]>;
|
|
|
|
|
analysisMap?: Map<string, VesselAnalysisDto>;
|
|
|
|
|
clusters?: Map<number, string[]>;
|
|
|
|
|
onShipSelect?: (mmsi: string) => void;
|
|
|
|
|
onFleetZoom?: (bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }) => void;
|
|
|
|
|
onSelectedGearChange?: (data: SelectedGearGroupData | null) => void;
|
|
|
|
|
@ -98,10 +99,18 @@ interface ClusterLineFeature {
|
|
|
|
|
|
|
|
|
|
type ClusterFeature = ClusterPolygonFeature | ClusterLineFeature;
|
|
|
|
|
|
|
|
|
|
export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, onFleetZoom, onSelectedGearChange, onSelectedFleetChange }: Props) {
|
|
|
|
|
const EMPTY_ANALYSIS = new globalThis.Map<string, VesselAnalysisDto>();
|
|
|
|
|
const EMPTY_CLUSTERS = new globalThis.Map<number, string[]>();
|
|
|
|
|
|
|
|
|
|
export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, clusters: clustersProp, onShipSelect, onFleetZoom, onSelectedGearChange, onSelectedFleetChange }: Props) {
|
|
|
|
|
const analysisMap = analysisMapProp ?? EMPTY_ANALYSIS;
|
|
|
|
|
const clusters = clustersProp ?? EMPTY_CLUSTERS;
|
|
|
|
|
const [companies, setCompanies] = useState<Map<number, FleetCompany>>(new Map());
|
|
|
|
|
const [expanded, setExpanded] = useState(true);
|
|
|
|
|
const [expandedFleet, setExpandedFleet] = useState<number | null>(null);
|
|
|
|
|
const [sectionExpanded, setSectionExpanded] = useState<Record<string, boolean>>({
|
|
|
|
|
fleet: true, inZone: true, outZone: true,
|
|
|
|
|
});
|
|
|
|
|
const toggleSection = (key: string) => setSectionExpanded(prev => ({ ...prev, [key]: !prev[key] }));
|
|
|
|
|
const [hoveredFleetId, setHoveredFleetId] = useState<number | null>(null);
|
|
|
|
|
const [expandedGearGroup, setExpandedGearGroup] = useState<string | null>(null);
|
|
|
|
|
const [selectedGearGroup, setSelectedGearGroup] = useState<string | null>(null);
|
|
|
|
|
@ -185,7 +194,10 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
|
|
|
|
|
const d = dataRef.current;
|
|
|
|
|
setSelectedGearGroup(prev => prev === name ? null : name);
|
|
|
|
|
setExpandedGearGroup(name);
|
|
|
|
|
setExpanded(true);
|
|
|
|
|
setSectionExpanded(prev => ({ ...prev, inZone: true, outZone: true }));
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
document.getElementById(`gear-row-${name}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
|
|
|
});
|
|
|
|
|
const entry = d.gearGroupMap.get(name);
|
|
|
|
|
if (!entry) return;
|
|
|
|
|
const all: Ship[] = [...entry.gears];
|
|
|
|
|
@ -338,8 +350,28 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
|
|
|
|
|
});
|
|
|
|
|
}, [expandedFleet, clusters, shipMap, companies, onSelectedFleetChange]);
|
|
|
|
|
|
|
|
|
|
// 비허가 어구 클러스터 GeoJSON
|
|
|
|
|
// 어구 그룹을 수역 내/외로 분류
|
|
|
|
|
const { inZoneGearGroups, outZoneGearGroups } = useMemo(() => {
|
|
|
|
|
const inZone: { name: string; parent: Ship | null; gears: Ship[]; zone: string }[] = [];
|
|
|
|
|
const outZone: { name: string; parent: Ship | null; gears: Ship[] }[] = [];
|
|
|
|
|
for (const [name, { parent, gears }] of gearGroupMap) {
|
|
|
|
|
const anchor = parent ?? gears[0];
|
|
|
|
|
if (!anchor) { outZone.push({ name, parent, gears }); continue; }
|
|
|
|
|
const zoneInfo = classifyFishingZone(anchor.lat, anchor.lng);
|
|
|
|
|
if (zoneInfo.zone !== 'OUTSIDE') {
|
|
|
|
|
inZone.push({ name, parent, gears, zone: zoneInfo.name });
|
|
|
|
|
} else {
|
|
|
|
|
outZone.push({ name, parent, gears });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
inZone.sort((a, b) => b.gears.length - a.gears.length);
|
|
|
|
|
outZone.sort((a, b) => b.gears.length - a.gears.length);
|
|
|
|
|
return { inZoneGearGroups: inZone, outZoneGearGroups: outZone };
|
|
|
|
|
}, [gearGroupMap]);
|
|
|
|
|
|
|
|
|
|
// 어구 클러스터 GeoJSON (수역 내: 붉은색, 수역 외: 오렌지)
|
|
|
|
|
const gearClusterGeoJson = useMemo((): GeoJSON => {
|
|
|
|
|
const inZoneNames = new Set(inZoneGearGroups.map(g => g.name));
|
|
|
|
|
const features: GeoJSON.Feature[] = [];
|
|
|
|
|
for (const [parentName, { parent, gears }] of gearGroupMap) {
|
|
|
|
|
const points: [number, number][] = gears.map(g => [g.lng, g.lat]);
|
|
|
|
|
@ -350,23 +382,19 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
|
|
|
|
|
padded.push(padded[0]);
|
|
|
|
|
features.push({
|
|
|
|
|
type: 'Feature',
|
|
|
|
|
properties: { name: parentName, gearCount: gears.length },
|
|
|
|
|
properties: { name: parentName, gearCount: gears.length, inZone: inZoneNames.has(parentName) ? 1 : 0 },
|
|
|
|
|
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]);
|
|
|
|
|
}, [gearGroupMap, inZoneGearGroups]);
|
|
|
|
|
|
|
|
|
|
const handleGearGroupZoom = useCallback((parentName: string) => {
|
|
|
|
|
setSelectedGearGroup(prev => prev === parentName ? null : parentName);
|
|
|
|
|
setExpandedGearGroup(parentName);
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
document.getElementById(`gear-row-${parentName}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
|
|
|
});
|
|
|
|
|
const entry = gearGroupMap.get(parentName);
|
|
|
|
|
if (!entry) return;
|
|
|
|
|
const all: Ship[] = [...entry.gears];
|
|
|
|
|
@ -486,7 +514,7 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
padding: '6px 10px',
|
|
|
|
|
borderBottom: expanded ? '1px solid rgba(99, 179, 237, 0.15)' : 'none',
|
|
|
|
|
borderBottom: 'none',
|
|
|
|
|
cursor: 'default',
|
|
|
|
|
userSelect: 'none',
|
|
|
|
|
flexShrink: 0,
|
|
|
|
|
@ -586,16 +614,16 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
|
|
|
|
|
id="gear-cluster-fill-layer"
|
|
|
|
|
type="fill"
|
|
|
|
|
paint={{
|
|
|
|
|
'fill-color': 'rgba(249, 115, 22, 0.08)',
|
|
|
|
|
'fill-color': ['case', ['==', ['get', 'inZone'], 1], 'rgba(220, 38, 38, 0.12)', 'rgba(249, 115, 22, 0.08)'],
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<Layer
|
|
|
|
|
id="gear-cluster-line-layer"
|
|
|
|
|
type="line"
|
|
|
|
|
paint={{
|
|
|
|
|
'line-color': '#f97316',
|
|
|
|
|
'line-color': ['case', ['==', ['get', 'inZone'], 1], '#dc2626', '#f97316'],
|
|
|
|
|
'line-opacity': 0.7,
|
|
|
|
|
'line-width': 1.5,
|
|
|
|
|
'line-width': ['case', ['==', ['get', 'inZone'], 1], 2, 1.5],
|
|
|
|
|
'line-dasharray': [4, 2],
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
@ -664,27 +692,24 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
|
|
|
|
|
|
|
|
|
|
{/* 선단 목록 패널 */}
|
|
|
|
|
<div style={panelStyle}>
|
|
|
|
|
<div style={headerStyle}>
|
|
|
|
|
<span style={{ fontWeight: 700, color: '#63b3ed', letterSpacing: 0.5 }}>
|
|
|
|
|
선단 현황 ({fleetList.length}개)
|
|
|
|
|
</span>
|
|
|
|
|
<button
|
|
|
|
|
style={toggleButtonStyle}
|
|
|
|
|
onClick={() => setExpanded(prev => !prev)}
|
|
|
|
|
aria-label={expanded ? '패널 접기' : '패널 펼치기'}
|
|
|
|
|
>
|
|
|
|
|
{expanded ? '▲' : '▼'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{expanded && (
|
|
|
|
|
<div style={{ maxHeight: 500, overflowY: 'auto', padding: '4px 0' }}>
|
|
|
|
|
{fleetList.length === 0 ? (
|
|
|
|
|
<div style={{ color: '#64748b', textAlign: 'center', padding: '8px 10px' }}>
|
|
|
|
|
선단 데이터 없음
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
fleetList.map(({ id, mmsiList }) => {
|
|
|
|
|
<div style={{ maxHeight: 500, overflowY: 'auto' }}>
|
|
|
|
|
{/* ── 선단 현황 섹션 ── */}
|
|
|
|
|
<div style={headerStyle} onClick={() => toggleSection('fleet')}>
|
|
|
|
|
<span style={{ fontWeight: 700, color: '#63b3ed', letterSpacing: 0.5 }}>
|
|
|
|
|
선단 현황 ({fleetList.length}개)
|
|
|
|
|
</span>
|
|
|
|
|
<button style={toggleButtonStyle} aria-label="선단 현황 접기/펴기">
|
|
|
|
|
{sectionExpanded.fleet ? '▲' : '▼'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
{sectionExpanded.fleet && (
|
|
|
|
|
<div style={{ padding: '4px 0' }}>
|
|
|
|
|
{fleetList.length === 0 ? (
|
|
|
|
|
<div style={{ color: '#64748b', textAlign: 'center', padding: '8px 10px' }}>
|
|
|
|
|
선단 데이터 없음
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
fleetList.map(({ id, mmsiList }) => {
|
|
|
|
|
const company = companies.get(id);
|
|
|
|
|
const companyName = company?.nameCn ?? `선단 #${id}`;
|
|
|
|
|
const color = clusterColor(id);
|
|
|
|
|
@ -838,26 +863,76 @@ 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>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* ── 조업구역내 어구 그룹 섹션 ── */}
|
|
|
|
|
{inZoneGearGroups.length > 0 && (
|
|
|
|
|
<>
|
|
|
|
|
<div style={{ ...headerStyle, borderTop: '1px solid rgba(220,38,38,0.35)' }} onClick={() => toggleSection('inZone')}>
|
|
|
|
|
<span style={{ fontWeight: 700, color: '#dc2626', letterSpacing: 0.3 }}>
|
|
|
|
|
조업구역내 어구 ({inZoneGearGroups.length}개)
|
|
|
|
|
</span>
|
|
|
|
|
<button style={toggleButtonStyle} aria-label="조업구역내 어구 접기/펴기">
|
|
|
|
|
{sectionExpanded.inZone ? '▲' : '▼'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
{sectionExpanded.inZone && (
|
|
|
|
|
<div style={{ padding: '4px 0' }}>
|
|
|
|
|
{inZoneGearGroups.map(({ name, parent, gears, zone }) => {
|
|
|
|
|
const isOpen = expandedGearGroup === name;
|
|
|
|
|
const accentColor = '#dc2626';
|
|
|
|
|
return (
|
|
|
|
|
<div key={name} id={`gear-row-${name}`}>
|
|
|
|
|
<div
|
|
|
|
|
style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 10px', cursor: 'pointer', borderLeft: isOpen ? `2px solid ${accentColor}` : '2px solid transparent', transition: 'background-color 0.1s' }}
|
|
|
|
|
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(220,38,38,0.06)'; }}
|
|
|
|
|
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: accentColor, 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} — ${zone}`}>{name}</span>
|
|
|
|
|
<span style={{ color: '#ef4444', fontSize: 9, flexShrink: 0 }}>{zone}</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(220,38,38,0.5)`, borderRadius: 3, color: accentColor, 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(220,38,38,0.25)`, 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: accentColor, fontSize: 10, cursor: 'pointer', padding: '0 2px', flexShrink: 0 }} title="어구 위치로 이동">▶</button>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
{gearGroupList.map(({ name, parent, gears }) => {
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* ── 비허가 어구 그룹 섹션 ── */}
|
|
|
|
|
{outZoneGearGroups.length > 0 && (
|
|
|
|
|
<>
|
|
|
|
|
<div style={{ ...headerStyle, borderTop: '1px solid rgba(249,115,22,0.25)' }} onClick={() => toggleSection('outZone')}>
|
|
|
|
|
<span style={{ fontWeight: 700, color: '#f97316', letterSpacing: 0.3 }}>
|
|
|
|
|
비허가 어구 ({outZoneGearGroups.length}개)
|
|
|
|
|
</span>
|
|
|
|
|
<button style={toggleButtonStyle} aria-label="비허가 어구 접기/펴기">
|
|
|
|
|
{sectionExpanded.outZone ? '▲' : '▼'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
{sectionExpanded.outZone && (
|
|
|
|
|
<div style={{ padding: '4px 0' }}>
|
|
|
|
|
{outZoneGearGroups.map(({ name, parent, gears }) => {
|
|
|
|
|
const isOpen = expandedGearGroup === name;
|
|
|
|
|
return (
|
|
|
|
|
<div key={name}>
|
|
|
|
|
<div key={name} id={`gear-row-${name}`}>
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
display: 'flex',
|
|
|
|
|
@ -967,10 +1042,11 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
|