- @fontsource-variable Inter, Noto Sans KR, Fira Code 자체 호스팅 - 전체 font-family 통일 (CSS, deck.gl, 인라인 스타일) - 이란 시설물 색상 사막 대비 고채도 팔레트로 교체 - 이란 라벨 fontWeight 600→700, alpha 200→255 - 접힘 패널 상하 패딩 균일화
1015 lines
43 KiB
TypeScript
1015 lines
43 KiB
TypeScript
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
|
import { Source, Layer, Popup, useMap } from 'react-map-gl/maplibre';
|
|
import { FONT_MONO } from '../../styles/fonts';
|
|
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) {
|
|
// 비허가 어구: 2개 이상일 때만 그룹으로 탐지
|
|
if (gears.length >= 2) 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 {
|
|
// 비허가 어구: 2개 이상일 때만 그룹으로 탐지
|
|
if (gears.length >= 2) 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 (수역 내: 붉은색, 수역 외: 오렌지)
|
|
// 비허가 어구(outZone)는 2개 이상만 폴리곤 생성
|
|
const gearClusterGeoJson = useMemo((): GeoJSON => {
|
|
const inZoneNames = new Set(inZoneGearGroups.map(g => g.name));
|
|
const outZoneNames = new Set(outZoneGearGroups.map(g => g.name));
|
|
const features: GeoJSON.Feature[] = [];
|
|
for (const [parentName, { parent, gears }] of gearGroupMap) {
|
|
// 비허가(outZone) 1개짜리는 폴리곤에서 제외
|
|
const isInZone = inZoneNames.has(parentName);
|
|
if (!isInZone && !outZoneNames.has(parentName)) continue;
|
|
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: isInZone ? 1 : 0 },
|
|
geometry: { type: 'Polygon', coordinates: [padded] },
|
|
});
|
|
}
|
|
return { type: 'FeatureCollection', features };
|
|
}, [gearGroupMap, inZoneGearGroups, outZoneGearGroups]);
|
|
|
|
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: FONT_MONO,
|
|
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: FONT_MONO, 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: FONT_MONO, 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;
|