kcg-monitoring/frontend/src/components/korea/FleetClusterLayer.tsx

1004 lines
42 KiB
TypeScript

import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { Source, Layer, Popup, useMap } from 'react-map-gl/maplibre';
import type { GeoJSON } from 'geojson';
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';
import { convexHull, padPolygon, clusterColor } from '../../utils/geometry';
export interface SelectedGearGroupData {
parent: Ship | null;
gears: Ship[];
groupName: string;
}
export interface SelectedFleetData {
clusterId: number;
ships: Ship[];
companyName: string;
}
interface Props {
ships: Ship[];
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;
onSelectedFleetChange?: (data: SelectedFleetData | null) => void;
}
// GeoJSON feature에 color 속성으로 주입
interface ClusterPolygonFeature {
type: 'Feature';
id: number;
properties: { clusterId: number; color: string };
geometry: { type: 'Polygon'; coordinates: [number, number][][] };
}
interface ClusterLineFeature {
type: 'Feature';
id: number;
properties: { clusterId: number; color: string };
geometry: { type: 'LineString'; coordinates: [number, number][] };
}
type ClusterFeature = ClusterPolygonFeature | ClusterLineFeature;
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 [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);
// 폴리곤 호버 툴팁
const [hoverTooltip, setHoverTooltip] = useState<{ lng: number; lat: number; type: 'fleet' | 'gear'; id: number | string } | null>(null);
const { current: mapRef } = useMap();
const registeredRef = useRef(false);
// dataRef는 shipMap/gearGroupMap 선언 이후에 갱신 (아래 참조)
const dataRef = useRef<{ clusters: Map<number, string[]>; shipMap: Map<string, Ship>; gearGroupMap: Map<string, { parent: Ship | null; gears: Ship[] }>; onFleetZoom: Props['onFleetZoom'] }>({ clusters, shipMap: new Map(), gearGroupMap: new Map(), onFleetZoom });
useEffect(() => {
fetchFleetCompanies().then(setCompanies).catch(() => {});
}, []);
// ── 맵 폴리곤 클릭/호버 이벤트 등록
useEffect(() => {
const map = mapRef?.getMap();
if (!map || registeredRef.current) return;
const fleetLayers = ['fleet-cluster-fill-layer'];
const gearLayers = ['gear-cluster-fill-layer'];
const allLayers = [...fleetLayers, ...gearLayers];
const setCursor = (cursor: string) => { map.getCanvas().style.cursor = cursor; };
const onFleetEnter = (e: MapLayerMouseEvent) => {
setCursor('pointer');
const feat = e.features?.[0];
if (!feat) return;
const cid = feat.properties?.clusterId as number | undefined;
if (cid != null) {
setHoveredFleetId(cid);
setHoverTooltip({ lng: e.lngLat.lng, lat: e.lngLat.lat, type: 'fleet', id: cid });
}
};
const onFleetLeave = () => {
setCursor('');
setHoveredFleetId(null);
setHoverTooltip(prev => prev?.type === 'fleet' ? null : prev);
};
const onFleetClick = (e: MapLayerMouseEvent) => {
const feat = e.features?.[0];
if (!feat) return;
const cid = feat.properties?.clusterId as number | undefined;
if (cid == null) return;
const d = dataRef.current;
setExpandedFleet(prev => prev === cid ? null : cid);
setExpanded(true);
const mmsiList = d.clusters.get(cid) ?? [];
if (mmsiList.length === 0) return;
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
for (const mmsi of mmsiList) {
const ship = d.shipMap.get(mmsi);
if (!ship) continue;
if (ship.lat < minLat) minLat = ship.lat;
if (ship.lat > maxLat) maxLat = ship.lat;
if (ship.lng < minLng) minLng = ship.lng;
if (ship.lng > maxLng) maxLng = ship.lng;
}
if (minLat !== Infinity) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
};
const onGearEnter = (e: MapLayerMouseEvent) => {
setCursor('pointer');
const feat = e.features?.[0];
if (!feat) return;
const name = feat.properties?.name as string | undefined;
if (name) {
setHoverTooltip({ lng: e.lngLat.lng, lat: e.lngLat.lat, type: 'gear', id: name });
}
};
const onGearLeave = () => {
setCursor('');
setHoverTooltip(prev => prev?.type === 'gear' ? null : prev);
};
const onGearClick = (e: MapLayerMouseEvent) => {
const feat = e.features?.[0];
if (!feat) return;
const name = feat.properties?.name as string | undefined;
if (!name) return;
const d = dataRef.current;
setSelectedGearGroup(prev => prev === name ? null : name);
setExpandedGearGroup(name);
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];
if (entry.parent) all.push(entry.parent);
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) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
};
const register = () => {
const ready = allLayers.every(id => map.getLayer(id));
if (!ready) return;
registeredRef.current = true;
for (const id of fleetLayers) {
map.on('mouseenter', id, onFleetEnter);
map.on('mouseleave', id, onFleetLeave);
map.on('click', id, onFleetClick);
}
for (const id of gearLayers) {
map.on('mouseenter', id, onGearEnter);
map.on('mouseleave', id, onGearLeave);
map.on('click', id, onGearClick);
}
};
register();
if (!registeredRef.current) {
const interval = setInterval(() => {
register();
if (registeredRef.current) clearInterval(interval);
}, 500);
return () => clearInterval(interval);
}
}, [mapRef]);
// 선박명 → mmsi 맵 (어구 매칭용)
const gearsByParent = useMemo(() => {
const map = new Map<string, Ship[]>(); // parent_mmsi → gears
const gearPattern = /^(.+?)_\d+_\d*$/;
const parentNames = new Map<string, string>(); // name → mmsi
for (const s of ships) {
if (s.name && !gearPattern.test(s.name)) {
parentNames.set(s.name.trim(), s.mmsi);
}
}
for (const s of ships) {
const m = s.name?.match(gearPattern);
if (!m) continue;
const parentMmsi = parentNames.get(m[1].trim());
if (parentMmsi) {
const arr = map.get(parentMmsi) ?? [];
arr.push(s);
map.set(parentMmsi, arr);
}
}
return map;
}, [ships]);
// ships map (mmsi → Ship)
const shipMap = useMemo(() => {
const m = new Map<string, Ship>();
for (const s of ships) m.set(s.mmsi, s);
return m;
}, [ships]);
// 비허가 어구 클러스터: parentName → { parent: Ship | null, gears: Ship[] }
const gearGroupMap = useMemo(() => {
const gearPattern = /^(.+?)_\d+_\d+_?$/;
const MAX_DIST_DEG = 0.15; // ~10NM
const STALE_MS = 60 * 60_000;
const now = Date.now();
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);
}
}
// 1단계: 같은 모선명 어구 수집 (60분 이내만)
const rawGroups = new Map<string, Ship[]>();
for (const s of ships) {
if (now - s.lastSeen > STALE_MS) continue;
const m = (s.name || '').match(gearPattern);
if (!m) continue;
const parentName = m[1].trim();
const arr = rawGroups.get(parentName) ?? [];
arr.push(s);
rawGroups.set(parentName, arr);
}
// 2단계: 거리 기반 서브 클러스터링 (같은 이름이라도 멀면 분리)
const map = new Map<string, { parent: Ship | null; gears: Ship[] }>();
for (const [parentName, gears] of rawGroups) {
const parent = nameToShip.get(parentName) ?? null;
// 기준점: 모선 있으면 모선 위치, 없으면 첫 어구
const anchor = parent ?? gears[0];
if (!anchor) continue;
const nearby = gears.filter(g => {
const dlat = Math.abs(g.lat - anchor.lat);
const dlng = Math.abs(g.lng - anchor.lng);
return dlat <= MAX_DIST_DEG && dlng <= MAX_DIST_DEG;
});
if (nearby.length === 0) continue;
map.set(parentName, { parent, gears: nearby });
}
return map;
}, [ships]);
// stale closure 방지 — shipMap/gearGroupMap 선언 이후 갱신
dataRef.current = { clusters, shipMap, gearGroupMap, onFleetZoom };
// 선택된 어구 그룹 데이터를 부모에 전달 (deck.gl 렌더링용)
useEffect(() => {
if (!selectedGearGroup) {
onSelectedGearChange?.(null);
return;
}
const entry = gearGroupMap.get(selectedGearGroup);
if (entry) {
onSelectedGearChange?.({ parent: entry.parent, gears: entry.gears, groupName: selectedGearGroup });
} else {
onSelectedGearChange?.(null);
}
}, [selectedGearGroup, gearGroupMap, onSelectedGearChange]);
// 선택된 선단 데이터를 부모에 전달 (deck.gl 강조 렌더링용)
useEffect(() => {
if (expandedFleet === null) {
onSelectedFleetChange?.(null);
return;
}
const mmsiList = clusters.get(expandedFleet) ?? [];
const fleetShips = mmsiList.map(mmsi => shipMap.get(mmsi)).filter((s): s is Ship => !!s);
const company = companies.get(expandedFleet);
onSelectedFleetChange?.({
clusterId: expandedFleet,
ships: fleetShips,
companyName: company?.nameCn || `선단 #${expandedFleet}`,
});
}, [expandedFleet, clusters, shipMap, companies, onSelectedFleetChange]);
// 어구 그룹을 수역 내/외로 분류
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]);
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, inZone: inZoneNames.has(parentName) ? 1 : 0 },
geometry: { type: 'Polygon', coordinates: [padded] },
});
}
return { type: 'FeatureCollection', features };
}, [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];
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[] = [];
for (const [clusterId, mmsiList] of clusters) {
const points: [number, number][] = [];
for (const mmsi of mmsiList) {
const ship = shipMap.get(mmsi);
if (ship) points.push([ship.lng, ship.lat]);
}
if (points.length < 2) continue;
const color = clusterColor(clusterId);
if (points.length === 2) {
features.push({
type: 'Feature',
id: clusterId,
properties: { clusterId, color },
geometry: { type: 'LineString', coordinates: points },
});
continue;
}
const hull = convexHull(points);
const padded = padPolygon(hull, 0.02);
// 폴리곤 닫기
const ring = [...padded, padded[0]];
features.push({
type: 'Feature',
id: clusterId,
properties: { clusterId, color },
geometry: { type: 'Polygon', coordinates: [ring] },
});
}
return features;
}, [clusters, shipMap]);
const polygonGeoJSON = useMemo((): GeoJSON => ({
type: 'FeatureCollection',
features: polygonFeatures.filter(f => f.geometry.type === 'Polygon'),
}), [polygonFeatures]);
const lineGeoJSON = useMemo((): GeoJSON => ({
type: 'FeatureCollection',
features: polygonFeatures.filter(f => f.geometry.type === 'LineString'),
}), [polygonFeatures]);
// 호버 하이라이트용 단일 폴리곤
const hoveredGeoJSON = useMemo((): GeoJSON => {
if (hoveredFleetId === null) return { type: 'FeatureCollection', features: [] };
const f = polygonFeatures.find(p => p.properties.clusterId === hoveredFleetId && p.geometry.type === 'Polygon');
if (!f) return { type: 'FeatureCollection', features: [] };
return { type: 'FeatureCollection', features: [f] };
}, [hoveredFleetId, polygonFeatures]);
const handleFleetZoom = useCallback((clusterId: number) => {
const mmsiList = clusters.get(clusterId) ?? [];
if (mmsiList.length === 0) return;
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
for (const mmsi of mmsiList) {
const ship = shipMap.get(mmsi);
if (!ship) continue;
if (ship.lat < minLat) minLat = ship.lat;
if (ship.lat > maxLat) maxLat = ship.lat;
if (ship.lng < minLng) minLng = ship.lng;
if (ship.lng > maxLng) maxLng = ship.lng;
}
if (minLat === Infinity) return;
onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
}, [clusters, shipMap, onFleetZoom]);
const fleetList = useMemo(() => {
return Array.from(clusters.entries())
.map(([id, mmsiList]) => ({ id, mmsiList }))
.sort((a, b) => b.mmsiList.length - a.mmsiList.length);
}, [clusters]);
// 패널 스타일 (AnalysisStatsPanel 패턴)
const panelStyle: React.CSSProperties = {
position: 'absolute',
bottom: 60,
left: 10,
zIndex: 10,
minWidth: 220,
maxWidth: 300,
backgroundColor: 'rgba(12, 24, 37, 0.92)',
border: '1px solid rgba(99, 179, 237, 0.25)',
borderRadius: 8,
color: '#e2e8f0',
fontFamily: 'monospace, sans-serif',
fontSize: 11,
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
pointerEvents: 'auto',
};
const headerStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '6px 10px',
borderBottom: 'none',
cursor: 'default',
userSelect: 'none',
flexShrink: 0,
};
const toggleButtonStyle: React.CSSProperties = {
background: 'none',
border: 'none',
color: '#94a3b8',
cursor: 'pointer',
fontSize: 10,
padding: '0 2px',
lineHeight: 1,
};
return (
<>
{/* 선단 폴리곤 레이어 */}
<Source id="fleet-cluster-fill" type="geojson" data={polygonGeoJSON}>
<Layer
id="fleet-cluster-fill-layer"
type="fill"
paint={{
'fill-color': ['get', 'color'],
'fill-opacity': 0.1,
}}
/>
<Layer
id="fleet-cluster-line-layer"
type="line"
paint={{
'line-color': ['get', 'color'],
'line-opacity': 0.5,
'line-width': 1.5,
}}
/>
</Source>
{/* 2척 선단 라인 */}
<Source id="fleet-cluster-line" type="geojson" data={lineGeoJSON}>
<Layer
id="fleet-cluster-line-only"
type="line"
paint={{
'line-color': ['get', 'color'],
'line-opacity': 0.5,
'line-width': 1.5,
'line-dasharray': [4, 2],
}}
/>
</Source>
{/* 호버 하이라이트 (별도 Source) */}
<Source id="fleet-cluster-hovered" type="geojson" data={hoveredGeoJSON}>
<Layer
id="fleet-cluster-hovered-fill"
type="fill"
paint={{
'fill-color': ['get', 'color'],
'fill-opacity': 0.25,
}}
/>
</Source>
{/* 선택된 어구 그룹 하이라이트 폴리곤 (deck.gl에서 어구 아이콘 + 모선 마커 표시) */}
{selectedGearGroup && (() => {
const entry = gearGroupMap.get(selectedGearGroup);
if (!entry) return null;
const points: [number, number][] = entry.gears.map(g => [g.lng, g.lat]);
if (entry.parent) points.push([entry.parent.lng, entry.parent.lat]);
const hlFeatures: GeoJSON.Feature[] = [];
if (points.length >= 3) {
const hull = convexHull(points);
const padded = padPolygon(hull, 0.01);
padded.push(padded[0]);
hlFeatures.push({
type: 'Feature',
properties: {},
geometry: { type: 'Polygon', coordinates: [padded] },
});
}
if (hlFeatures.length === 0) return null;
const hlGeoJson: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: hlFeatures };
return (
<Source id="gear-cluster-selected" type="geojson" data={hlGeoJson}>
<Layer id="gear-selected-fill" type="fill" paint={{ 'fill-color': '#f97316', 'fill-opacity': 0.25 }} />
<Layer id="gear-selected-line" type="line" paint={{ 'line-color': '#f97316', 'line-width': 3, 'line-opacity': 0.9 }} />
</Source>
);
})()}
{/* 비허가 어구 클러스터 폴리곤 */}
<Source id="gear-clusters" type="geojson" data={gearClusterGeoJson}>
<Layer
id="gear-cluster-fill-layer"
type="fill"
paint={{
'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': ['case', ['==', ['get', 'inZone'], 1], '#dc2626', '#f97316'],
'line-opacity': 0.7,
'line-width': ['case', ['==', ['get', 'inZone'], 1], 2, 1.5],
'line-dasharray': [4, 2],
}}
/>
</Source>
{/* 폴리곤 호버 툴팁 */}
{hoverTooltip && (() => {
if (hoverTooltip.type === 'fleet') {
const cid = hoverTooltip.id as number;
const mmsiList = clusters.get(cid) ?? [];
const company = companies.get(cid);
const gearCount = mmsiList.reduce((acc, mmsi) => acc + (gearsByParent.get(mmsi)?.length ?? 0), 0);
return (
<Popup longitude={hoverTooltip.lng} latitude={hoverTooltip.lat}
closeButton={false} closeOnClick={false} anchor="bottom"
className="gl-popup" maxWidth="220px"
>
<div style={{ fontFamily: 'monospace', fontSize: 10, padding: 4, color: '#e2e8f0' }}>
<div style={{ fontWeight: 700, color: clusterColor(cid), marginBottom: 3 }}>
{company?.nameCn || `선단 #${cid}`}
</div>
<div style={{ color: '#94a3b8' }}> {mmsiList.length} · {gearCount}</div>
{expandedFleet === cid && mmsiList.slice(0, 5).map(mmsi => {
const s = shipMap.get(mmsi);
const dto = analysisMap.get(mmsi);
const role = dto?.algorithms.fleetRole.role ?? '';
return s ? (
<div key={mmsi} style={{ fontSize: 9, color: '#7ea8c4', marginTop: 2 }}>
{role === 'LEADER' ? '★' : '·'} {s.name || mmsi} <span style={{ color: '#4a6b82' }}>{s.speed?.toFixed(1)}kt</span>
</div>
) : null;
})}
<div style={{ fontSize: 8, color: '#4a6b82', marginTop: 3 }}> </div>
</div>
</Popup>
);
}
if (hoverTooltip.type === 'gear') {
const name = hoverTooltip.id as string;
const entry = gearGroupMap.get(name);
if (!entry) return null;
return (
<Popup longitude={hoverTooltip.lng} latitude={hoverTooltip.lat}
closeButton={false} closeOnClick={false} anchor="bottom"
className="gl-popup" maxWidth="220px"
>
<div style={{ fontFamily: 'monospace', fontSize: 10, padding: 4, color: '#e2e8f0' }}>
<div style={{ fontWeight: 700, color: '#f97316', marginBottom: 3 }}>
{name} <span style={{ fontWeight: 400, color: '#94a3b8' }}> {entry.gears.length}</span>
</div>
{entry.parent && (
<div style={{ fontSize: 9, color: '#fbbf24' }}>: {entry.parent.name || entry.parent.mmsi}</div>
)}
{selectedGearGroup === name && entry.gears.slice(0, 5).map(g => (
<div key={g.mmsi} style={{ fontSize: 9, color: '#7ea8c4', marginTop: 1 }}>
· {g.name || g.mmsi}
</div>
))}
<div style={{ fontSize: 8, color: '#4a6b82', marginTop: 3 }}> /</div>
</div>
</Popup>
);
}
return null;
})()}
{/* 선단 목록 패널 */}
<div style={panelStyle}>
<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);
const isOpen = expandedFleet === id;
const isHovered = hoveredFleetId === id;
const mainVessels = mmsiList.filter(mmsi => {
const dto = analysisMap.get(mmsi);
return dto?.algorithms.fleetRole.role === 'LEADER' || dto?.algorithms.fleetRole.role === 'MEMBER';
});
const gearCount = mmsiList.reduce((acc, mmsi) => acc + (gearsByParent.get(mmsi)?.length ?? 0), 0);
return (
<div key={id}>
{/* 선단 행 */}
<div
onMouseEnter={() => setHoveredFleetId(id)}
onMouseLeave={() => setHoveredFleetId(null)}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '4px 10px',
cursor: 'pointer',
backgroundColor: isHovered ? 'rgba(255,255,255,0.06)' : 'transparent',
borderLeft: isOpen ? `2px solid ${color}` : '2px solid transparent',
transition: 'background-color 0.1s',
}}
>
{/* 펼침 토글 */}
<span
onClick={() => setExpandedFleet(prev => (prev === id ? null : id))}
style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }}
>
{isOpen ? '▾' : '▸'}
</span>
{/* 색상 인디케이터 */}
<span style={{
width: 8, height: 8, borderRadius: '50%',
backgroundColor: color, flexShrink: 0,
}} />
{/* 회사명 */}
<span
onClick={() => setExpandedFleet(prev => (prev === id ? null : id))}
style={{
flex: 1,
color: '#e2e8f0',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
cursor: 'pointer',
}}
title={company ? `${company.nameCn} / ${company.nameEn}` : `선단 #${id}`}
>
{companyName}
</span>
{/* 선박 수 */}
<span style={{ color: '#64748b', fontSize: 10, flexShrink: 0 }}>
({mmsiList.length})
</span>
{/* zoom 버튼 */}
<button
onClick={e => { e.stopPropagation(); handleFleetZoom(id); }}
style={{
background: 'none',
border: '1px solid rgba(99,179,237,0.3)',
borderRadius: 3,
color: '#63b3ed',
fontSize: 9,
cursor: 'pointer',
padding: '1px 4px',
flexShrink: 0,
}}
title="이 선단으로 지도 이동"
>
zoom
</button>
</div>
{/* 선단 상세 */}
{isOpen && (
<div style={{
paddingLeft: 22,
paddingRight: 10,
paddingBottom: 6,
fontSize: 10,
color: '#94a3b8',
borderLeft: `2px solid ${color}33`,
marginLeft: 10,
}}>
{/* 선박 목록 */}
<div style={{ color: '#64748b', fontSize: 9, marginBottom: 3 }}>:</div>
{(mainVessels.length > 0 ? mainVessels : mmsiList).map(mmsi => {
const ship = shipMap.get(mmsi);
const dto = analysisMap.get(mmsi);
const role = dto?.algorithms.fleetRole.role ?? 'MEMBER';
const displayName = ship?.name || mmsi;
return (
<div
key={mmsi}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
marginBottom: 2,
}}
>
<span style={{ flex: 1, color: '#cbd5e1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{displayName}
</span>
<span style={{ color: role === 'LEADER' ? '#fbbf24' : '#64748b', fontSize: 9, flexShrink: 0 }}>
({role === 'LEADER' ? 'MAIN' : 'SUB'})
</span>
<button
onClick={() => onShipSelect?.(mmsi)}
style={{
background: 'none',
border: 'none',
color: '#63b3ed',
fontSize: 10,
cursor: 'pointer',
padding: '0 2px',
flexShrink: 0,
}}
title="선박으로 이동"
aria-label={`${displayName} 선박으로 이동`}
>
</button>
</div>
);
})}
{/* 어구 목록 */}
{gearCount > 0 && (
<>
<div style={{ color: '#64748b', fontSize: 9, marginTop: 4, marginBottom: 2 }}>
: {gearCount}
</div>
{mmsiList.flatMap(mmsi => gearsByParent.get(mmsi) ?? []).map(gear => (
<div key={gear.mmsi} style={{ color: '#475569', fontSize: 9, marginBottom: 1 }}>
{gear.name || gear.mmsi}
</div>
))}
</>
)}
</div>
)}
</div>
);
})
)}
</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>
)}
</>
)}
{/* ── 비허가 어구 그룹 섹션 ── */}
{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} id={`gear-row-${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>
</>
);
}
export default FleetClusterLayer;