feat(frontend): FleetClusterLayer 서버사이드 폴리곤 전환
- vesselAnalysis.ts: GroupPolygonDto 타입 + fetchGroupPolygons/Detail/History - useGroupPolygons.ts: 5분 폴링 훅 (fleetGroups/gearInZone/gearOutZone) - FleetClusterLayer: 클라이언트 convexHull/padPolygon 제거 → API GeoJSON 렌더링 - KoreaDashboard/KoreaMap: groupPolygons 훅 연결 + props 전달
This commit is contained in:
부모
b0fafca8c9
커밋
9cad89113d
@ -6,8 +6,7 @@ import type { MapLayerMouseEvent } from 'maplibre-gl';
|
|||||||
import type { Ship, VesselAnalysisDto } from '../../types';
|
import type { Ship, VesselAnalysisDto } from '../../types';
|
||||||
import { fetchFleetCompanies } from '../../services/vesselAnalysis';
|
import { fetchFleetCompanies } from '../../services/vesselAnalysis';
|
||||||
import type { FleetCompany } from '../../services/vesselAnalysis';
|
import type { FleetCompany } from '../../services/vesselAnalysis';
|
||||||
import { classifyFishingZone } from '../../utils/fishingAnalysis';
|
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
|
||||||
import { convexHull, padPolygon, clusterColor } from '../../utils/geometry';
|
|
||||||
|
|
||||||
export interface SelectedGearGroupData {
|
export interface SelectedGearGroupData {
|
||||||
parent: Ship | null;
|
parent: Ship | null;
|
||||||
@ -29,31 +28,13 @@ interface Props {
|
|||||||
onFleetZoom?: (bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }) => void;
|
onFleetZoom?: (bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }) => void;
|
||||||
onSelectedGearChange?: (data: SelectedGearGroupData | null) => void;
|
onSelectedGearChange?: (data: SelectedGearGroupData | null) => void;
|
||||||
onSelectedFleetChange?: (data: SelectedFleetData | null) => void;
|
onSelectedFleetChange?: (data: SelectedFleetData | null) => void;
|
||||||
|
groupPolygons?: UseGroupPolygonsResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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_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) {
|
export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipSelect, onFleetZoom, onSelectedGearChange, onSelectedFleetChange, groupPolygons }: Props) {
|
||||||
const analysisMap = analysisMapProp ?? EMPTY_ANALYSIS;
|
const analysisMap = analysisMapProp ?? EMPTY_ANALYSIS;
|
||||||
const clusters = clustersProp ?? EMPTY_CLUSTERS;
|
|
||||||
const [companies, setCompanies] = useState<Map<number, FleetCompany>>(new Map());
|
const [companies, setCompanies] = useState<Map<number, FleetCompany>>(new Map());
|
||||||
const [expandedFleet, setExpandedFleet] = useState<number | null>(null);
|
const [expandedFleet, setExpandedFleet] = useState<number | null>(null);
|
||||||
const [sectionExpanded, setSectionExpanded] = useState<Record<string, boolean>>({
|
const [sectionExpanded, setSectionExpanded] = useState<Record<string, boolean>>({
|
||||||
@ -67,8 +48,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
|||||||
const [hoverTooltip, setHoverTooltip] = useState<{ lng: number; lat: number; type: 'fleet' | 'gear'; id: number | string } | null>(null);
|
const [hoverTooltip, setHoverTooltip] = useState<{ lng: number; lat: number; type: 'fleet' | 'gear'; id: number | string } | null>(null);
|
||||||
const { current: mapRef } = useMap();
|
const { current: mapRef } = useMap();
|
||||||
const registeredRef = useRef(false);
|
const registeredRef = useRef(false);
|
||||||
// dataRef는 shipMap/gearGroupMap 선언 이후에 갱신 (아래 참조)
|
const dataRef = useRef<{ shipMap: Map<string, Ship>; groupPolygons: UseGroupPolygonsResult | undefined; onFleetZoom: Props['onFleetZoom'] }>({ shipMap: new Map(), groupPolygons, onFleetZoom });
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
fetchFleetCompanies().then(setCompanies).catch(() => {});
|
fetchFleetCompanies().then(setCompanies).catch(() => {});
|
||||||
@ -107,17 +87,15 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
|||||||
if (cid == null) return;
|
if (cid == null) return;
|
||||||
const d = dataRef.current;
|
const d = dataRef.current;
|
||||||
setExpandedFleet(prev => prev === cid ? null : cid);
|
setExpandedFleet(prev => prev === cid ? null : cid);
|
||||||
setExpanded(true);
|
setSectionExpanded(prev => ({ ...prev, fleet: true }));
|
||||||
const mmsiList = d.clusters.get(cid) ?? [];
|
const group = d.groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === cid);
|
||||||
if (mmsiList.length === 0) return;
|
if (!group || group.members.length === 0) return;
|
||||||
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
|
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
|
||||||
for (const mmsi of mmsiList) {
|
for (const m of group.members) {
|
||||||
const ship = d.shipMap.get(mmsi);
|
if (m.lat < minLat) minLat = m.lat;
|
||||||
if (!ship) continue;
|
if (m.lat > maxLat) maxLat = m.lat;
|
||||||
if (ship.lat < minLat) minLat = ship.lat;
|
if (m.lon < minLng) minLng = m.lon;
|
||||||
if (ship.lat > maxLat) maxLat = ship.lat;
|
if (m.lon > maxLng) maxLng = m.lon;
|
||||||
if (ship.lng < minLng) minLng = ship.lng;
|
|
||||||
if (ship.lng > maxLng) maxLng = ship.lng;
|
|
||||||
}
|
}
|
||||||
if (minLat !== Infinity) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
|
if (minLat !== Infinity) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
|
||||||
};
|
};
|
||||||
@ -147,16 +125,17 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
|||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
document.getElementById(`gear-row-${name}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
document.getElementById(`gear-row-${name}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
});
|
});
|
||||||
const entry = d.gearGroupMap.get(name);
|
const allGroups = d.groupPolygons
|
||||||
if (!entry) return;
|
? [...d.groupPolygons.gearInZoneGroups, ...d.groupPolygons.gearOutZoneGroups]
|
||||||
const all: Ship[] = [...entry.gears];
|
: [];
|
||||||
if (entry.parent) all.push(entry.parent);
|
const group = allGroups.find(g => g.groupKey === name);
|
||||||
|
if (!group || group.members.length === 0) return;
|
||||||
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
|
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
|
||||||
for (const s of all) {
|
for (const m of group.members) {
|
||||||
if (s.lat < minLat) minLat = s.lat;
|
if (m.lat < minLat) minLat = m.lat;
|
||||||
if (s.lat > maxLat) maxLat = s.lat;
|
if (m.lat > maxLat) maxLat = m.lat;
|
||||||
if (s.lng < minLng) minLng = s.lng;
|
if (m.lon < minLng) minLng = m.lon;
|
||||||
if (s.lng > maxLng) maxLng = s.lng;
|
if (m.lon > maxLng) maxLng = m.lon;
|
||||||
}
|
}
|
||||||
if (minLat !== Infinity) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
|
if (minLat !== Infinity) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
|
||||||
};
|
};
|
||||||
@ -188,29 +167,6 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
|||||||
}
|
}
|
||||||
}, [mapRef]);
|
}, [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)
|
// ships map (mmsi → Ship)
|
||||||
const shipMap = useMemo(() => {
|
const shipMap = useMemo(() => {
|
||||||
const m = new Map<string, Ship>();
|
const m = new Map<string, Ship>();
|
||||||
@ -218,56 +174,8 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
|||||||
return m;
|
return m;
|
||||||
}, [ships]);
|
}, [ships]);
|
||||||
|
|
||||||
// 비허가 어구 클러스터: parentName → { parent: Ship | null, gears: Ship[] }
|
// stale closure 방지
|
||||||
const gearGroupMap = useMemo(() => {
|
dataRef.current = { shipMap, groupPolygons, onFleetZoom };
|
||||||
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 렌더링용)
|
// 선택된 어구 그룹 데이터를 부모에 전달 (deck.gl 렌더링용)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -275,13 +183,33 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
|||||||
onSelectedGearChange?.(null);
|
onSelectedGearChange?.(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const entry = gearGroupMap.get(selectedGearGroup);
|
const allGroups = groupPolygons
|
||||||
if (entry) {
|
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
|
||||||
onSelectedGearChange?.({ parent: entry.parent, gears: entry.gears, groupName: selectedGearGroup });
|
: [];
|
||||||
} else {
|
const group = allGroups.find(g => g.groupKey === selectedGearGroup);
|
||||||
|
if (!group) {
|
||||||
onSelectedGearChange?.(null);
|
onSelectedGearChange?.(null);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [selectedGearGroup, gearGroupMap, onSelectedGearChange]);
|
const parent = group.members.find(m => m.isParent);
|
||||||
|
const gears = group.members.filter(m => !m.isParent);
|
||||||
|
const toShip = (m: typeof group.members[0]): Ship => ({
|
||||||
|
mmsi: m.mmsi,
|
||||||
|
name: m.name,
|
||||||
|
lat: m.lat,
|
||||||
|
lng: m.lon,
|
||||||
|
heading: m.cog,
|
||||||
|
speed: m.sog,
|
||||||
|
course: m.cog,
|
||||||
|
category: 'fishing',
|
||||||
|
lastSeen: Date.now(),
|
||||||
|
});
|
||||||
|
onSelectedGearChange?.({
|
||||||
|
parent: parent ? toShip(parent) : null,
|
||||||
|
gears: gears.map(toShip),
|
||||||
|
groupName: selectedGearGroup,
|
||||||
|
});
|
||||||
|
}, [selectedGearGroup, groupPolygons, onSelectedGearChange]);
|
||||||
|
|
||||||
// 선택된 선단 데이터를 부모에 전달 (deck.gl 강조 렌더링용)
|
// 선택된 선단 데이터를 부모에 전달 (deck.gl 강조 렌더링용)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -289,64 +217,115 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
|||||||
onSelectedFleetChange?.(null);
|
onSelectedFleetChange?.(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const mmsiList = clusters.get(expandedFleet) ?? [];
|
const group = groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === expandedFleet);
|
||||||
const fleetShips = mmsiList.map(mmsi => shipMap.get(mmsi)).filter((s): s is Ship => !!s);
|
|
||||||
const company = companies.get(expandedFleet);
|
const company = companies.get(expandedFleet);
|
||||||
|
if (!group) {
|
||||||
|
onSelectedFleetChange?.(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fleetShips: Ship[] = group.members.map(m => ({
|
||||||
|
mmsi: m.mmsi,
|
||||||
|
name: m.name,
|
||||||
|
lat: m.lat,
|
||||||
|
lng: m.lon,
|
||||||
|
heading: m.cog,
|
||||||
|
speed: m.sog,
|
||||||
|
course: m.cog,
|
||||||
|
category: 'fishing',
|
||||||
|
lastSeen: Date.now(),
|
||||||
|
}));
|
||||||
onSelectedFleetChange?.({
|
onSelectedFleetChange?.({
|
||||||
clusterId: expandedFleet,
|
clusterId: expandedFleet,
|
||||||
ships: fleetShips,
|
ships: fleetShips,
|
||||||
companyName: company?.nameCn || `선단 #${expandedFleet}`,
|
companyName: company?.nameCn || group.groupLabel || `선단 #${expandedFleet}`,
|
||||||
});
|
});
|
||||||
}, [expandedFleet, clusters, shipMap, companies, onSelectedFleetChange]);
|
}, [expandedFleet, groupPolygons, companies, onSelectedFleetChange]);
|
||||||
|
|
||||||
// 어구 그룹을 수역 내/외로 분류
|
// API 기반 어구 그룹 분류
|
||||||
const { inZoneGearGroups, outZoneGearGroups } = useMemo(() => {
|
const inZoneGearGroups = groupPolygons?.gearInZoneGroups ?? [];
|
||||||
const inZone: { name: string; parent: Ship | null; gears: Ship[]; zone: string }[] = [];
|
const outZoneGearGroups = groupPolygons?.gearOutZoneGroups ?? [];
|
||||||
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 (수역 내: 붉은색, 수역 외: 오렌지)
|
// 선단 폴리곤 GeoJSON (서버 제공)
|
||||||
// 비허가 어구(outZone)는 2개 이상만 폴리곤 생성
|
const fleetPolygonGeoJSON = useMemo((): GeoJSON => {
|
||||||
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[] = [];
|
const features: GeoJSON.Feature[] = [];
|
||||||
for (const [parentName, { parent, gears }] of gearGroupMap) {
|
if (!groupPolygons) return { type: 'FeatureCollection', features };
|
||||||
// 비허가(outZone) 1개짜리는 폴리곤에서 제외
|
for (const g of groupPolygons.fleetGroups) {
|
||||||
const isInZone = inZoneNames.has(parentName);
|
if (!g.polygon) continue;
|
||||||
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({
|
features.push({
|
||||||
type: 'Feature',
|
type: 'Feature',
|
||||||
properties: { name: parentName, gearCount: gears.length, inZone: isInZone ? 1 : 0 },
|
properties: { clusterId: Number(g.groupKey), color: g.color },
|
||||||
geometry: { type: 'Polygon', coordinates: [padded] },
|
geometry: g.polygon,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { type: 'FeatureCollection', features };
|
return { type: 'FeatureCollection', features };
|
||||||
}, [gearGroupMap, inZoneGearGroups, outZoneGearGroups]);
|
}, [groupPolygons?.fleetGroups]);
|
||||||
|
|
||||||
|
// 2척 선단 라인은 서버에서 Shapely buffer로 이미 Polygon 처리되므로 빈 컬렉션
|
||||||
|
const lineGeoJSON = useMemo((): GeoJSON => ({
|
||||||
|
type: 'FeatureCollection', features: [],
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
// 호버 하이라이트용 단일 폴리곤
|
||||||
|
const hoveredGeoJSON = useMemo((): GeoJSON => {
|
||||||
|
if (hoveredFleetId === null || !groupPolygons) return { type: 'FeatureCollection', features: [] };
|
||||||
|
const g = groupPolygons.fleetGroups.find(f => Number(f.groupKey) === hoveredFleetId);
|
||||||
|
if (!g?.polygon) return { type: 'FeatureCollection', features: [] };
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: [{
|
||||||
|
type: 'Feature',
|
||||||
|
properties: { clusterId: hoveredFleetId, color: g.color },
|
||||||
|
geometry: g.polygon,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}, [hoveredFleetId, groupPolygons?.fleetGroups]);
|
||||||
|
|
||||||
|
// 어구 클러스터 GeoJSON (서버 제공)
|
||||||
|
const gearClusterGeoJson = useMemo((): GeoJSON => {
|
||||||
|
const features: GeoJSON.Feature[] = [];
|
||||||
|
if (!groupPolygons) return { type: 'FeatureCollection', features };
|
||||||
|
for (const g of [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]) {
|
||||||
|
if (!g.polygon) continue;
|
||||||
|
features.push({
|
||||||
|
type: 'Feature',
|
||||||
|
properties: {
|
||||||
|
name: g.groupKey,
|
||||||
|
gearCount: g.memberCount,
|
||||||
|
inZone: g.groupType === 'GEAR_IN_ZONE' ? 1 : 0,
|
||||||
|
},
|
||||||
|
geometry: g.polygon,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { type: 'FeatureCollection', features };
|
||||||
|
}, [groupPolygons?.gearInZoneGroups, groupPolygons?.gearOutZoneGroups]);
|
||||||
|
|
||||||
|
// 선단 목록 (멤버 수 내림차순)
|
||||||
|
const fleetList = useMemo(() => {
|
||||||
|
if (!groupPolygons) return [];
|
||||||
|
return groupPolygons.fleetGroups.map(g => ({
|
||||||
|
id: Number(g.groupKey),
|
||||||
|
mmsiList: g.members.map(m => m.mmsi),
|
||||||
|
label: g.groupLabel,
|
||||||
|
memberCount: g.memberCount,
|
||||||
|
areaSqNm: g.areaSqNm,
|
||||||
|
color: g.color,
|
||||||
|
members: g.members,
|
||||||
|
})).sort((a, b) => b.memberCount - a.memberCount);
|
||||||
|
}, [groupPolygons?.fleetGroups]);
|
||||||
|
|
||||||
|
const handleFleetZoom = useCallback((clusterId: number) => {
|
||||||
|
const group = groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === clusterId);
|
||||||
|
if (!group || group.members.length === 0) return;
|
||||||
|
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
|
||||||
|
for (const m of group.members) {
|
||||||
|
if (m.lat < minLat) minLat = m.lat;
|
||||||
|
if (m.lat > maxLat) maxLat = m.lat;
|
||||||
|
if (m.lon < minLng) minLng = m.lon;
|
||||||
|
if (m.lon > maxLng) maxLng = m.lon;
|
||||||
|
}
|
||||||
|
if (minLat === Infinity) return;
|
||||||
|
onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
|
||||||
|
}, [groupPolygons?.fleetGroups, onFleetZoom]);
|
||||||
|
|
||||||
const handleGearGroupZoom = useCallback((parentName: string) => {
|
const handleGearGroupZoom = useCallback((parentName: string) => {
|
||||||
setSelectedGearGroup(prev => prev === parentName ? null : parentName);
|
setSelectedGearGroup(prev => prev === parentName ? null : parentName);
|
||||||
@ -354,98 +333,21 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
|||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
document.getElementById(`gear-row-${parentName}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
document.getElementById(`gear-row-${parentName}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
});
|
});
|
||||||
const entry = gearGroupMap.get(parentName);
|
const allGroups = groupPolygons
|
||||||
if (!entry) return;
|
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
|
||||||
const all: Ship[] = [...entry.gears];
|
: [];
|
||||||
if (entry.parent) all.push(entry.parent);
|
const group = allGroups.find(g => g.groupKey === parentName);
|
||||||
if (all.length === 0) return;
|
if (!group || group.members.length === 0) return;
|
||||||
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
|
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
|
||||||
for (const s of all) {
|
for (const m of group.members) {
|
||||||
if (s.lat < minLat) minLat = s.lat;
|
if (m.lat < minLat) minLat = m.lat;
|
||||||
if (s.lat > maxLat) maxLat = s.lat;
|
if (m.lat > maxLat) maxLat = m.lat;
|
||||||
if (s.lng < minLng) minLng = s.lng;
|
if (m.lon < minLng) minLng = m.lon;
|
||||||
if (s.lng > maxLng) maxLng = s.lng;
|
if (m.lon > maxLng) maxLng = m.lon;
|
||||||
}
|
}
|
||||||
if (minLat === Infinity) return;
|
if (minLat === Infinity) return;
|
||||||
onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
|
onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
|
||||||
}, [gearGroupMap, onFleetZoom]);
|
}, [groupPolygons, 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 패턴)
|
// 패널 스타일 (AnalysisStatsPanel 패턴)
|
||||||
const panelStyle: React.CSSProperties = {
|
const panelStyle: React.CSSProperties = {
|
||||||
@ -492,7 +394,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 선단 폴리곤 레이어 */}
|
{/* 선단 폴리곤 레이어 */}
|
||||||
<Source id="fleet-cluster-fill" type="geojson" data={polygonGeoJSON}>
|
<Source id="fleet-cluster-fill" type="geojson" data={fleetPolygonGeoJSON}>
|
||||||
<Layer
|
<Layer
|
||||||
id="fleet-cluster-fill-layer"
|
id="fleet-cluster-fill-layer"
|
||||||
type="fill"
|
type="fill"
|
||||||
@ -512,7 +414,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
|||||||
/>
|
/>
|
||||||
</Source>
|
</Source>
|
||||||
|
|
||||||
{/* 2척 선단 라인 */}
|
{/* 2척 선단 라인 (서버측 Polygon 처리로 빈 컬렉션) */}
|
||||||
<Source id="fleet-cluster-line" type="geojson" data={lineGeoJSON}>
|
<Source id="fleet-cluster-line" type="geojson" data={lineGeoJSON}>
|
||||||
<Layer
|
<Layer
|
||||||
id="fleet-cluster-line-only"
|
id="fleet-cluster-line-only"
|
||||||
@ -538,27 +440,21 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
|||||||
/>
|
/>
|
||||||
</Source>
|
</Source>
|
||||||
|
|
||||||
{/* 선택된 어구 그룹 하이라이트 폴리곤 (deck.gl에서 어구 아이콘 + 모선 마커 표시) */}
|
{/* 선택된 어구 그룹 하이라이트 폴리곤 */}
|
||||||
{selectedGearGroup && (() => {
|
{selectedGearGroup && (() => {
|
||||||
const entry = gearGroupMap.get(selectedGearGroup);
|
const allGroups = groupPolygons
|
||||||
if (!entry) return null;
|
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
|
||||||
const points: [number, number][] = entry.gears.map(g => [g.lng, g.lat]);
|
: [];
|
||||||
if (entry.parent) points.push([entry.parent.lng, entry.parent.lat]);
|
const group = allGroups.find(g => g.groupKey === selectedGearGroup);
|
||||||
|
if (!group?.polygon) return null;
|
||||||
const hlFeatures: GeoJSON.Feature[] = [];
|
const hlGeoJson: GeoJSON.FeatureCollection = {
|
||||||
if (points.length >= 3) {
|
type: 'FeatureCollection',
|
||||||
const hull = convexHull(points);
|
features: [{
|
||||||
const padded = padPolygon(hull, 0.01);
|
|
||||||
padded.push(padded[0]);
|
|
||||||
hlFeatures.push({
|
|
||||||
type: 'Feature',
|
type: 'Feature',
|
||||||
properties: {},
|
properties: {},
|
||||||
geometry: { type: 'Polygon', coordinates: [padded] },
|
geometry: group.polygon,
|
||||||
});
|
}],
|
||||||
}
|
};
|
||||||
if (hlFeatures.length === 0) return null;
|
|
||||||
const hlGeoJson: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: hlFeatures };
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Source id="gear-cluster-selected" type="geojson" data={hlGeoJson}>
|
<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-fill" type="fill" paint={{ 'fill-color': '#f97316', 'fill-opacity': 0.25 }} />
|
||||||
@ -592,28 +488,27 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
|||||||
{hoverTooltip && (() => {
|
{hoverTooltip && (() => {
|
||||||
if (hoverTooltip.type === 'fleet') {
|
if (hoverTooltip.type === 'fleet') {
|
||||||
const cid = hoverTooltip.id as number;
|
const cid = hoverTooltip.id as number;
|
||||||
const mmsiList = clusters.get(cid) ?? [];
|
const group = groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === cid);
|
||||||
const company = companies.get(cid);
|
const company = companies.get(cid);
|
||||||
const gearCount = mmsiList.reduce((acc, mmsi) => acc + (gearsByParent.get(mmsi)?.length ?? 0), 0);
|
const memberCount = group?.memberCount ?? 0;
|
||||||
return (
|
return (
|
||||||
<Popup longitude={hoverTooltip.lng} latitude={hoverTooltip.lat}
|
<Popup longitude={hoverTooltip.lng} latitude={hoverTooltip.lat}
|
||||||
closeButton={false} closeOnClick={false} anchor="bottom"
|
closeButton={false} closeOnClick={false} anchor="bottom"
|
||||||
className="gl-popup" maxWidth="220px"
|
className="gl-popup" maxWidth="220px"
|
||||||
>
|
>
|
||||||
<div style={{ fontFamily: FONT_MONO, fontSize: 10, padding: 4, color: '#e2e8f0' }}>
|
<div style={{ fontFamily: FONT_MONO, fontSize: 10, padding: 4, color: '#e2e8f0' }}>
|
||||||
<div style={{ fontWeight: 700, color: clusterColor(cid), marginBottom: 3 }}>
|
<div style={{ fontWeight: 700, color: group?.color ?? '#63b3ed', marginBottom: 3 }}>
|
||||||
{company?.nameCn || `선단 #${cid}`}
|
{company?.nameCn || group?.groupLabel || `선단 #${cid}`}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: '#94a3b8' }}>선박 {mmsiList.length}척 · 어구 {gearCount}개</div>
|
<div style={{ color: '#94a3b8' }}>선박 {memberCount}척</div>
|
||||||
{expandedFleet === cid && mmsiList.slice(0, 5).map(mmsi => {
|
{expandedFleet === cid && group?.members.slice(0, 5).map(m => {
|
||||||
const s = shipMap.get(mmsi);
|
const dto = analysisMap.get(m.mmsi);
|
||||||
const dto = analysisMap.get(mmsi);
|
const role = dto?.algorithms.fleetRole.role ?? m.role;
|
||||||
const role = dto?.algorithms.fleetRole.role ?? '';
|
return (
|
||||||
return s ? (
|
<div key={m.mmsi} style={{ fontSize: 9, color: '#7ea8c4', marginTop: 2 }}>
|
||||||
<div key={mmsi} style={{ fontSize: 9, color: '#7ea8c4', marginTop: 2 }}>
|
{role === 'LEADER' ? '★' : '·'} {m.name || m.mmsi} <span style={{ color: '#4a6b82' }}>{m.sog.toFixed(1)}kt</span>
|
||||||
{role === 'LEADER' ? '★' : '·'} {s.name || mmsi} <span style={{ color: '#4a6b82' }}>{s.speed?.toFixed(1)}kt</span>
|
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
);
|
||||||
})}
|
})}
|
||||||
<div style={{ fontSize: 8, color: '#4a6b82', marginTop: 3 }}>클릭하여 상세 보기</div>
|
<div style={{ fontSize: 8, color: '#4a6b82', marginTop: 3 }}>클릭하여 상세 보기</div>
|
||||||
</div>
|
</div>
|
||||||
@ -622,8 +517,13 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
|||||||
}
|
}
|
||||||
if (hoverTooltip.type === 'gear') {
|
if (hoverTooltip.type === 'gear') {
|
||||||
const name = hoverTooltip.id as string;
|
const name = hoverTooltip.id as string;
|
||||||
const entry = gearGroupMap.get(name);
|
const allGroups = groupPolygons
|
||||||
if (!entry) return null;
|
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
|
||||||
|
: [];
|
||||||
|
const group = allGroups.find(g => g.groupKey === name);
|
||||||
|
if (!group) return null;
|
||||||
|
const parentMember = group.members.find(m => m.isParent);
|
||||||
|
const gearMembers = group.members.filter(m => !m.isParent);
|
||||||
return (
|
return (
|
||||||
<Popup longitude={hoverTooltip.lng} latitude={hoverTooltip.lat}
|
<Popup longitude={hoverTooltip.lng} latitude={hoverTooltip.lat}
|
||||||
closeButton={false} closeOnClick={false} anchor="bottom"
|
closeButton={false} closeOnClick={false} anchor="bottom"
|
||||||
@ -631,14 +531,14 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
|||||||
>
|
>
|
||||||
<div style={{ fontFamily: FONT_MONO, fontSize: 10, padding: 4, color: '#e2e8f0' }}>
|
<div style={{ fontFamily: FONT_MONO, fontSize: 10, padding: 4, color: '#e2e8f0' }}>
|
||||||
<div style={{ fontWeight: 700, color: '#f97316', marginBottom: 3 }}>
|
<div style={{ fontWeight: 700, color: '#f97316', marginBottom: 3 }}>
|
||||||
{name} <span style={{ fontWeight: 400, color: '#94a3b8' }}>어구 {entry.gears.length}개</span>
|
{name} <span style={{ fontWeight: 400, color: '#94a3b8' }}>어구 {gearMembers.length}개</span>
|
||||||
</div>
|
</div>
|
||||||
{entry.parent && (
|
{parentMember && (
|
||||||
<div style={{ fontSize: 9, color: '#fbbf24' }}>모선: {entry.parent.name || entry.parent.mmsi}</div>
|
<div style={{ fontSize: 9, color: '#fbbf24' }}>모선: {parentMember.name || parentMember.mmsi}</div>
|
||||||
)}
|
)}
|
||||||
{selectedGearGroup === name && entry.gears.slice(0, 5).map(g => (
|
{selectedGearGroup === name && gearMembers.slice(0, 5).map(m => (
|
||||||
<div key={g.mmsi} style={{ fontSize: 9, color: '#7ea8c4', marginTop: 1 }}>
|
<div key={m.mmsi} style={{ fontSize: 9, color: '#7ea8c4', marginTop: 1 }}>
|
||||||
· {g.name || g.mmsi}
|
· {m.name || m.mmsi}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div style={{ fontSize: 8, color: '#4a6b82', marginTop: 3 }}>클릭하여 선택/해제</div>
|
<div style={{ fontSize: 8, color: '#4a6b82', marginTop: 3 }}>클릭하여 선택/해제</div>
|
||||||
@ -657,7 +557,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
|||||||
<span style={{ fontWeight: 700, color: '#63b3ed', letterSpacing: 0.5 }}>
|
<span style={{ fontWeight: 700, color: '#63b3ed', letterSpacing: 0.5 }}>
|
||||||
선단 현황 ({fleetList.length}개)
|
선단 현황 ({fleetList.length}개)
|
||||||
</span>
|
</span>
|
||||||
<button style={toggleButtonStyle} aria-label="선단 현황 접기/펴기">
|
<button type="button" style={toggleButtonStyle} aria-label="선단 현황 접기/펴기">
|
||||||
{sectionExpanded.fleet ? '▲' : '▼'}
|
{sectionExpanded.fleet ? '▲' : '▼'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -668,18 +568,17 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
|||||||
선단 데이터 없음
|
선단 데이터 없음
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
fleetList.map(({ id, mmsiList }) => {
|
fleetList.map(({ id, mmsiList, label, color, members }) => {
|
||||||
const company = companies.get(id);
|
const company = companies.get(id);
|
||||||
const companyName = company?.nameCn ?? `선단 #${id}`;
|
const companyName = company?.nameCn ?? label ?? `선단 #${id}`;
|
||||||
const color = clusterColor(id);
|
|
||||||
const isOpen = expandedFleet === id;
|
const isOpen = expandedFleet === id;
|
||||||
const isHovered = hoveredFleetId === id;
|
const isHovered = hoveredFleetId === id;
|
||||||
|
|
||||||
const mainVessels = mmsiList.filter(mmsi => {
|
const mainMembers = members.filter(m => {
|
||||||
const dto = analysisMap.get(mmsi);
|
const dto = analysisMap.get(m.mmsi);
|
||||||
return dto?.algorithms.fleetRole.role === 'LEADER' || dto?.algorithms.fleetRole.role === 'MEMBER';
|
return dto?.algorithms.fleetRole.role === 'LEADER' || dto?.algorithms.fleetRole.role === 'MEMBER';
|
||||||
});
|
});
|
||||||
const gearCount = mmsiList.reduce((acc, mmsi) => acc + (gearsByParent.get(mmsi)?.length ?? 0), 0);
|
const displayMembers = mainMembers.length > 0 ? mainMembers : members;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={id}>
|
<div key={id}>
|
||||||
@ -721,7 +620,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
|||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
title={company ? `${company.nameCn} / ${company.nameEn}` : `선단 #${id}`}
|
title={company ? `${company.nameCn} / ${company.nameEn}` : companyName}
|
||||||
>
|
>
|
||||||
{companyName}
|
{companyName}
|
||||||
</span>
|
</span>
|
||||||
@ -731,6 +630,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
|||||||
</span>
|
</span>
|
||||||
{/* zoom 버튼 */}
|
{/* zoom 버튼 */}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={e => { e.stopPropagation(); handleFleetZoom(id); }}
|
onClick={e => { e.stopPropagation(); handleFleetZoom(id); }}
|
||||||
style={{
|
style={{
|
||||||
background: 'none',
|
background: 'none',
|
||||||
@ -761,14 +661,13 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
|||||||
}}>
|
}}>
|
||||||
{/* 선박 목록 */}
|
{/* 선박 목록 */}
|
||||||
<div style={{ color: '#64748b', fontSize: 9, marginBottom: 3 }}>선박:</div>
|
<div style={{ color: '#64748b', fontSize: 9, marginBottom: 3 }}>선박:</div>
|
||||||
{(mainVessels.length > 0 ? mainVessels : mmsiList).map(mmsi => {
|
{displayMembers.map(m => {
|
||||||
const ship = shipMap.get(mmsi);
|
const dto = analysisMap.get(m.mmsi);
|
||||||
const dto = analysisMap.get(mmsi);
|
const role = dto?.algorithms.fleetRole.role ?? m.role;
|
||||||
const role = dto?.algorithms.fleetRole.role ?? 'MEMBER';
|
const displayName = m.name || m.mmsi;
|
||||||
const displayName = ship?.name || mmsi;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={mmsi}
|
key={m.mmsi}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@ -783,7 +682,8 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
|||||||
({role === 'LEADER' ? 'MAIN' : 'SUB'})
|
({role === 'LEADER' ? 'MAIN' : 'SUB'})
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => onShipSelect?.(mmsi)}
|
type="button"
|
||||||
|
onClick={() => onShipSelect?.(m.mmsi)}
|
||||||
style={{
|
style={{
|
||||||
background: 'none',
|
background: 'none',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
@ -801,20 +701,6 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
@ -838,9 +724,13 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
|||||||
</div>
|
</div>
|
||||||
{sectionExpanded.inZone && (
|
{sectionExpanded.inZone && (
|
||||||
<div style={{ padding: '4px 0' }}>
|
<div style={{ padding: '4px 0' }}>
|
||||||
{inZoneGearGroups.map(({ name, parent, gears, zone }) => {
|
{inZoneGearGroups.map(g => {
|
||||||
|
const name = g.groupKey;
|
||||||
const isOpen = expandedGearGroup === name;
|
const isOpen = expandedGearGroup === name;
|
||||||
const accentColor = '#dc2626';
|
const accentColor = '#dc2626';
|
||||||
|
const parentMember = g.members.find(m => m.isParent);
|
||||||
|
const gearMembers = g.members.filter(m => !m.isParent);
|
||||||
|
const zoneName = g.zoneName ?? '';
|
||||||
return (
|
return (
|
||||||
<div key={name} id={`gear-row-${name}`}>
|
<div key={name} id={`gear-row-${name}`}>
|
||||||
<div
|
<div
|
||||||
@ -850,19 +740,19 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
|||||||
>
|
>
|
||||||
<span onClick={() => setExpandedGearGroup(prev => (prev === name ? null : name))} style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }}>{isOpen ? '▾' : '▸'}</span>
|
<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 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 onClick={() => setExpandedGearGroup(prev => (prev === name ? null : name))} style={{ flex: 1, color: '#e2e8f0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }} title={`${name} — ${zoneName}`}>{name}</span>
|
||||||
<span style={{ color: '#ef4444', fontSize: 9, flexShrink: 0 }}>{zone}</span>
|
<span style={{ color: '#ef4444', fontSize: 9, flexShrink: 0 }}>{zoneName}</span>
|
||||||
<span style={{ color: '#64748b', fontSize: 10, flexShrink: 0 }}>({gears.length})</span>
|
<span style={{ color: '#64748b', fontSize: 10, flexShrink: 0 }}>({gearMembers.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>
|
<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>
|
</div>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div style={{ paddingLeft: 24, paddingRight: 10, paddingBottom: 4, fontSize: 9, color: '#94a3b8', borderLeft: `2px solid rgba(220,38,38,0.25)`, marginLeft: 10 }}>
|
<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>}
|
{parentMember && <div style={{ color: '#fbbf24', marginBottom: 2 }}>모선: {parentMember.name || parentMember.mmsi}</div>}
|
||||||
<div style={{ color: '#64748b', marginBottom: 2 }}>어구 목록:</div>
|
<div style={{ color: '#64748b', marginBottom: 2 }}>어구 목록:</div>
|
||||||
{gears.map(g => (
|
{gearMembers.map(m => (
|
||||||
<div key={g.mmsi} style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 1 }}>
|
<div key={m.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>
|
<span style={{ flex: 1, color: '#475569', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.name || m.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>
|
<button type="button" onClick={() => onShipSelect?.(m.mmsi)} style={{ background: 'none', border: 'none', color: accentColor, fontSize: 10, cursor: 'pointer', padding: '0 2px', flexShrink: 0 }} title="어구 위치로 이동">▶</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -882,14 +772,17 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
|||||||
<span style={{ fontWeight: 700, color: '#f97316', letterSpacing: 0.3 }}>
|
<span style={{ fontWeight: 700, color: '#f97316', letterSpacing: 0.3 }}>
|
||||||
비허가 어구 ({outZoneGearGroups.length}개)
|
비허가 어구 ({outZoneGearGroups.length}개)
|
||||||
</span>
|
</span>
|
||||||
<button style={toggleButtonStyle} aria-label="비허가 어구 접기/펴기">
|
<button type="button" style={toggleButtonStyle} aria-label="비허가 어구 접기/펴기">
|
||||||
{sectionExpanded.outZone ? '▲' : '▼'}
|
{sectionExpanded.outZone ? '▲' : '▼'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{sectionExpanded.outZone && (
|
{sectionExpanded.outZone && (
|
||||||
<div style={{ padding: '4px 0' }}>
|
<div style={{ padding: '4px 0' }}>
|
||||||
{outZoneGearGroups.map(({ name, parent, gears }) => {
|
{outZoneGearGroups.map(g => {
|
||||||
|
const name = g.groupKey;
|
||||||
const isOpen = expandedGearGroup === name;
|
const isOpen = expandedGearGroup === name;
|
||||||
|
const parentMember = g.members.find(m => m.isParent);
|
||||||
|
const gearMembers = g.members.filter(m => !m.isParent);
|
||||||
return (
|
return (
|
||||||
<div key={name} id={`gear-row-${name}`}>
|
<div key={name} id={`gear-row-${name}`}>
|
||||||
<div
|
<div
|
||||||
@ -930,7 +823,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
|||||||
{name}
|
{name}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ color: '#64748b', fontSize: 10, flexShrink: 0 }}>
|
<span style={{ color: '#64748b', fontSize: 10, flexShrink: 0 }}>
|
||||||
({gears.length}개)
|
({gearMembers.length}개)
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -961,25 +854,25 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
|||||||
borderLeft: '2px solid rgba(249,115,22,0.2)',
|
borderLeft: '2px solid rgba(249,115,22,0.2)',
|
||||||
marginLeft: 10,
|
marginLeft: 10,
|
||||||
}}>
|
}}>
|
||||||
{parent && (
|
{parentMember && (
|
||||||
<div style={{ color: '#fbbf24', marginBottom: 2 }}>
|
<div style={{ color: '#fbbf24', marginBottom: 2 }}>
|
||||||
모선: {parent.name || parent.mmsi}
|
모선: {parentMember.name || parentMember.mmsi}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ color: '#64748b', marginBottom: 2 }}>어구 목록:</div>
|
<div style={{ color: '#64748b', marginBottom: 2 }}>어구 목록:</div>
|
||||||
{gears.map(g => (
|
{gearMembers.map(m => (
|
||||||
<div key={g.mmsi} style={{
|
<div key={m.mmsi} style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 4,
|
gap: 4,
|
||||||
marginBottom: 1,
|
marginBottom: 1,
|
||||||
}}>
|
}}>
|
||||||
<span style={{ flex: 1, color: '#475569', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<span style={{ flex: 1, color: '#475569', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
{g.name || g.mmsi}
|
{m.name || m.mmsi}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onShipSelect?.(g.mmsi)}
|
onClick={() => onShipSelect?.(m.mmsi)}
|
||||||
style={{
|
style={{
|
||||||
background: 'none',
|
background: 'none',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
@ -990,7 +883,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
title="어구 위치로 이동"
|
title="어구 위치로 이동"
|
||||||
aria-label={`${g.name || g.mmsi} 위치로 이동`}
|
aria-label={`${m.name || m.mmsi} 위치로 이동`}
|
||||||
>
|
>
|
||||||
▶
|
▶
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { ReplayControls } from '../common/ReplayControls';
|
|||||||
import { TimelineSlider } from '../common/TimelineSlider';
|
import { TimelineSlider } from '../common/TimelineSlider';
|
||||||
import { useKoreaData } from '../../hooks/useKoreaData';
|
import { useKoreaData } from '../../hooks/useKoreaData';
|
||||||
import { useVesselAnalysis } from '../../hooks/useVesselAnalysis';
|
import { useVesselAnalysis } from '../../hooks/useVesselAnalysis';
|
||||||
|
import { useGroupPolygons } from '../../hooks/useGroupPolygons';
|
||||||
import { useKoreaFilters } from '../../hooks/useKoreaFilters';
|
import { useKoreaFilters } from '../../hooks/useKoreaFilters';
|
||||||
import { useSharedFilters } from '../../hooks/useSharedFilters';
|
import { useSharedFilters } from '../../hooks/useSharedFilters';
|
||||||
import { EAST_ASIA_PORTS } from '../../data/ports';
|
import { EAST_ASIA_PORTS } from '../../data/ports';
|
||||||
@ -161,6 +162,7 @@ export const KoreaDashboard = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const vesselAnalysis = useVesselAnalysis(true);
|
const vesselAnalysis = useVesselAnalysis(true);
|
||||||
|
const groupPolygons = useGroupPolygons(true);
|
||||||
|
|
||||||
const koreaFiltersResult = useKoreaFilters(
|
const koreaFiltersResult = useKoreaFilters(
|
||||||
koreaData.ships,
|
koreaData.ships,
|
||||||
@ -329,6 +331,7 @@ export const KoreaDashboard = ({
|
|||||||
cnFishingSuspects={koreaFiltersResult.cnFishingSuspects}
|
cnFishingSuspects={koreaFiltersResult.cnFishingSuspects}
|
||||||
dokdoAlerts={koreaFiltersResult.dokdoAlerts}
|
dokdoAlerts={koreaFiltersResult.dokdoAlerts}
|
||||||
vesselAnalysis={vesselAnalysis}
|
vesselAnalysis={vesselAnalysis}
|
||||||
|
groupPolygons={groupPolygons}
|
||||||
hiddenShipCategories={hiddenShipCategories}
|
hiddenShipCategories={hiddenShipCategories}
|
||||||
hiddenNationalities={hiddenNationalities}
|
hiddenNationalities={hiddenNationalities}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -36,6 +36,7 @@ import type { PowerFacility } from '../../services/infra';
|
|||||||
import type { Ship, Aircraft, SatellitePosition } from '../../types';
|
import type { Ship, Aircraft, SatellitePosition } from '../../types';
|
||||||
import type { OsintItem } from '../../services/osint';
|
import type { OsintItem } from '../../services/osint';
|
||||||
import type { UseVesselAnalysisResult } from '../../hooks/useVesselAnalysis';
|
import type { UseVesselAnalysisResult } from '../../hooks/useVesselAnalysis';
|
||||||
|
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
|
||||||
import { countryLabelsGeoJSON } from '../../data/countryLabels';
|
import { countryLabelsGeoJSON } from '../../data/countryLabels';
|
||||||
import { useLocalStorage } from '../../hooks/useLocalStorage';
|
import { useLocalStorage } from '../../hooks/useLocalStorage';
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
@ -65,6 +66,7 @@ interface Props {
|
|||||||
cnFishingSuspects: Set<string>;
|
cnFishingSuspects: Set<string>;
|
||||||
dokdoAlerts: { mmsi: string; name: string; dist: number; time: number }[];
|
dokdoAlerts: { mmsi: string; name: string; dist: number; time: number }[];
|
||||||
vesselAnalysis?: UseVesselAnalysisResult;
|
vesselAnalysis?: UseVesselAnalysisResult;
|
||||||
|
groupPolygons?: UseGroupPolygonsResult;
|
||||||
hiddenShipCategories?: Set<string>;
|
hiddenShipCategories?: Set<string>;
|
||||||
hiddenNationalities?: Set<string>;
|
hiddenNationalities?: Set<string>;
|
||||||
}
|
}
|
||||||
@ -142,7 +144,7 @@ const FILTER_I18N_KEY: Record<string, string> = {
|
|||||||
cnFishing: 'filters.cnFishingMonitor',
|
cnFishing: 'filters.cnFishingMonitor',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis, hiddenShipCategories, hiddenNationalities }: Props) {
|
export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis, groupPolygons, hiddenShipCategories, hiddenNationalities }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const mapRef = useRef<MapRef>(null);
|
const mapRef = useRef<MapRef>(null);
|
||||||
const [infra, setInfra] = useState<PowerFacility[]>([]);
|
const [infra, setInfra] = useState<PowerFacility[]>([]);
|
||||||
@ -661,6 +663,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
ships={allShips ?? ships}
|
ships={allShips ?? ships}
|
||||||
analysisMap={vesselAnalysis ? vesselAnalysis.analysisMap : undefined}
|
analysisMap={vesselAnalysis ? vesselAnalysis.analysisMap : undefined}
|
||||||
clusters={vesselAnalysis ? vesselAnalysis.clusters : undefined}
|
clusters={vesselAnalysis ? vesselAnalysis.clusters : undefined}
|
||||||
|
groupPolygons={groupPolygons}
|
||||||
onShipSelect={handleAnalysisShipSelect}
|
onShipSelect={handleAnalysisShipSelect}
|
||||||
onFleetZoom={handleFleetZoom}
|
onFleetZoom={handleFleetZoom}
|
||||||
onSelectedGearChange={setSelectedGearData}
|
onSelectedGearChange={setSelectedGearData}
|
||||||
|
|||||||
69
frontend/src/hooks/useGroupPolygons.ts
Normal file
69
frontend/src/hooks/useGroupPolygons.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||||
|
import { fetchGroupPolygons } from '../services/vesselAnalysis';
|
||||||
|
import type { GroupPolygonDto } from '../services/vesselAnalysis';
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 5 * 60_000; // 5분
|
||||||
|
|
||||||
|
export interface UseGroupPolygonsResult {
|
||||||
|
fleetGroups: GroupPolygonDto[];
|
||||||
|
gearInZoneGroups: GroupPolygonDto[];
|
||||||
|
gearOutZoneGroups: GroupPolygonDto[];
|
||||||
|
allGroups: GroupPolygonDto[];
|
||||||
|
isLoading: boolean;
|
||||||
|
lastUpdated: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY: UseGroupPolygonsResult = {
|
||||||
|
fleetGroups: [],
|
||||||
|
gearInZoneGroups: [],
|
||||||
|
gearOutZoneGroups: [],
|
||||||
|
allGroups: [],
|
||||||
|
isLoading: false,
|
||||||
|
lastUpdated: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useGroupPolygons(enabled: boolean): UseGroupPolygonsResult {
|
||||||
|
const [allGroups, setAllGroups] = useState<GroupPolygonDto[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [lastUpdated, setLastUpdated] = useState(0);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setInterval>>();
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const groups = await fetchGroupPolygons();
|
||||||
|
setAllGroups(groups);
|
||||||
|
setLastUpdated(Date.now());
|
||||||
|
} catch {
|
||||||
|
// 네트워크 오류 시 기존 데이터 유지
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
load();
|
||||||
|
timerRef.current = setInterval(load, POLL_INTERVAL_MS);
|
||||||
|
return () => clearInterval(timerRef.current);
|
||||||
|
}, [enabled, load]);
|
||||||
|
|
||||||
|
const fleetGroups = useMemo(
|
||||||
|
() => allGroups.filter(g => g.groupType === 'FLEET'),
|
||||||
|
[allGroups],
|
||||||
|
);
|
||||||
|
|
||||||
|
const gearInZoneGroups = useMemo(
|
||||||
|
() => allGroups.filter(g => g.groupType === 'GEAR_IN_ZONE'),
|
||||||
|
[allGroups],
|
||||||
|
);
|
||||||
|
|
||||||
|
const gearOutZoneGroups = useMemo(
|
||||||
|
() => allGroups.filter(g => g.groupType === 'GEAR_OUT_ZONE'),
|
||||||
|
[allGroups],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!enabled) return EMPTY;
|
||||||
|
|
||||||
|
return { fleetGroups, gearInZoneGroups, gearOutZoneGroups, allGroups, isLoading, lastUpdated };
|
||||||
|
}
|
||||||
@ -17,6 +17,58 @@ export interface FleetCompany {
|
|||||||
nameEn: string;
|
nameEn: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Group Polygon Types ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
export interface MemberInfo {
|
||||||
|
mmsi: string;
|
||||||
|
name: string;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
sog: number;
|
||||||
|
cog: number;
|
||||||
|
role: string;
|
||||||
|
isParent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupPolygonDto {
|
||||||
|
groupType: 'FLEET' | 'GEAR_IN_ZONE' | 'GEAR_OUT_ZONE';
|
||||||
|
groupKey: string;
|
||||||
|
groupLabel: string;
|
||||||
|
snapshotTime: string;
|
||||||
|
polygon: GeoJSON.Polygon | null;
|
||||||
|
centerLat: number;
|
||||||
|
centerLon: number;
|
||||||
|
areaSqNm: number;
|
||||||
|
memberCount: number;
|
||||||
|
zoneId: string | null;
|
||||||
|
zoneName: string | null;
|
||||||
|
members: MemberInfo[];
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGroupPolygons(): Promise<GroupPolygonDto[]> {
|
||||||
|
const res = await fetch(`${API_BASE}/vessel-analysis/groups`, {
|
||||||
|
headers: { accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data: { count: number; items: GroupPolygonDto[] } = await res.json();
|
||||||
|
return data.items ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGroupDetail(groupKey: string): Promise<GroupPolygonDto | null> {
|
||||||
|
const res = await fetch(`${API_BASE}/vessel-analysis/groups/${encodeURIComponent(groupKey)}/detail`);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGroupHistory(groupKey: string, hours = 24): Promise<GroupPolygonDto[]> {
|
||||||
|
const res = await fetch(`${API_BASE}/vessel-analysis/groups/${encodeURIComponent(groupKey)}/history?hours=${hours}`);
|
||||||
|
if (!res.ok) return [];
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Fleet Companies ─────────────────────────────────────────── */
|
||||||
|
|
||||||
// 캐시 (세션 중 1회 로드)
|
// 캐시 (세션 중 1회 로드)
|
||||||
let companyCache: Map<number, FleetCompany> | null = null;
|
let companyCache: Map<number, FleetCompany> | null = null;
|
||||||
|
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user