kcg-monitoring/frontend/src/components/korea/useFleetClusterGeoJson.ts
htlee 6f4044ce39 feat: MapLibre → deck.gl 전면 전환 + 어구 서브클러스터 구조 개선
- 실시간 선박 13K: MapLibre symbol → deck.gl IconLayer (useShipDeckLayers + shipDeckStore)
- 선단/어구 폴리곤: MapLibre Source/Layer → deck.gl GeoJsonLayer (useFleetClusterDeckLayers)
- 선박 팝업: MapLibre Popup → React 오버레이 (ShipPopupOverlay + ShipHoverTooltip)
- 리플레이 집중 모드 (focusMode), 라벨 클러스터링, fontScale 연동
- Python: group_key 고정 + sub_cluster_id 분리, 한국 국적 어구 오탐 제외
- DB: sub_cluster_id 컬럼 추가 + 기존 '#N' 데이터 마이그레이션
- Backend: DISTINCT ON CTE로 서브클러스터 중복 제거, subClusterId DTO 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:44:09 +09:00

415 lines
17 KiB
TypeScript

import { useMemo } from 'react';
import type { GeoJSON } from 'geojson';
import type { Ship, VesselAnalysisDto } from '../../types';
import type { GearCorrelationItem, CorrelationVesselTrack } from '../../services/vesselAnalysis';
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
import type { FleetListItem } from './fleetClusterTypes';
import { buildInterpPolygon } from './fleetClusterUtils';
import { MODEL_ORDER, MODEL_COLORS } from './fleetClusterConstants';
export interface UseFleetClusterGeoJsonParams {
ships: Ship[];
shipMap: Map<string, Ship>;
groupPolygons: UseGroupPolygonsResult | undefined;
analysisMap: Map<string, VesselAnalysisDto>;
hoveredFleetId: number | null;
selectedGearGroup: string | null;
pickerHoveredGroup: string | null;
historyActive: boolean;
correlationData: GearCorrelationItem[];
correlationTracks: CorrelationVesselTrack[];
enabledModels: Set<string>;
enabledVessels: Set<string>;
hoveredMmsi: string | null;
}
export interface FleetClusterGeoJsonResult {
// static/base GeoJSON
fleetPolygonGeoJSON: GeoJSON;
lineGeoJSON: GeoJSON;
hoveredGeoJSON: GeoJSON;
gearClusterGeoJson: GeoJSON;
memberMarkersGeoJson: GeoJSON;
pickerHighlightGeoJson: GeoJSON;
selectedGearHighlightGeoJson: GeoJSON.FeatureCollection | null;
// correlation GeoJSON
correlationVesselGeoJson: GeoJSON;
correlationTrailGeoJson: GeoJSON;
modelBadgesGeoJson: GeoJSON;
hoverHighlightGeoJson: GeoJSON;
hoverHighlightTrailGeoJson: GeoJSON;
// operational polygons (per model)
operationalPolygons: { modelName: string; color: string; geojson: GeoJSON.FeatureCollection }[];
// derived values
fleetList: FleetListItem[];
correlationByModel: Map<string, GearCorrelationItem[]>;
availableModels: { name: string; count: number; isDefault: boolean }[];
}
const EMPTY_FC: GeoJSON = { type: 'FeatureCollection', features: [] };
// 선단 색상: 바다색(짙은파랑)과 대비되는 밝은 파스텔 팔레트 (clusterId 해시)
const FLEET_PALETTE = [
'#e879f9', '#a78bfa', '#67e8f9', '#34d399', '#fbbf24',
'#fb923c', '#f87171', '#a3e635', '#38bdf8', '#c084fc',
];
/** 같은 groupKey의 모든 서브클러스터에서 멤버를 합산 (중복 mmsi 제거) */
function mergeSubClusterMembers(groups: GroupPolygonDto[], groupKey: string) {
const matches = groups.filter(g => g.groupKey === groupKey);
if (matches.length === 0) return { members: [] as GroupPolygonDto['members'], groups: matches };
const seen = new Set<string>();
const members: GroupPolygonDto['members'] = [];
for (const g of matches) {
for (const m of g.members) {
if (!seen.has(m.mmsi)) { seen.add(m.mmsi); members.push(m); }
}
}
return { members, groups: matches };
}
export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): FleetClusterGeoJsonResult {
const {
ships,
shipMap,
groupPolygons,
hoveredFleetId,
selectedGearGroup,
pickerHoveredGroup,
historyActive,
correlationData,
correlationTracks,
enabledModels,
enabledVessels,
hoveredMmsi,
} = params;
// ── 선단 폴리곤 GeoJSON (서버 제공) ──
const fleetPolygonGeoJSON = useMemo((): GeoJSON => {
const features: GeoJSON.Feature[] = [];
if (!groupPolygons) return { type: 'FeatureCollection', features };
for (const g of groupPolygons.fleetGroups) {
if (!g.polygon) continue;
const cid = Number(g.groupKey);
const color = FLEET_PALETTE[cid % FLEET_PALETTE.length];
features.push({
type: 'Feature',
properties: { clusterId: cid, color },
geometry: g.polygon,
});
}
return { type: 'FeatureCollection', features };
}, [groupPolygons]);
// 2척 선단 라인은 서버에서 Shapely buffer로 이미 Polygon 처리되므로 빈 컬렉션
const lineGeoJSON = useMemo((): GeoJSON => ({
type: 'FeatureCollection', features: [],
}), []);
// 호버 하이라이트용 단일 폴리곤
const hoveredGeoJSON = useMemo((): GeoJSON => {
if (hoveredFleetId === null || !groupPolygons) return EMPTY_FC;
const g = groupPolygons.fleetGroups.find(f => Number(f.groupKey) === hoveredFleetId);
if (!g?.polygon) return EMPTY_FC;
return {
type: 'FeatureCollection',
features: [{
type: 'Feature',
properties: { clusterId: hoveredFleetId, color: FLEET_PALETTE[hoveredFleetId % FLEET_PALETTE.length] },
geometry: g.polygon,
}],
};
}, [hoveredFleetId, groupPolygons]);
// 모델별 연관성 데이터 그룹핑
const correlationByModel = useMemo(() => {
const map = new Map<string, GearCorrelationItem[]>();
for (const c of correlationData) {
const list = map.get(c.modelName) ?? [];
list.push(c);
map.set(c.modelName, list);
}
return map;
}, [correlationData]);
// 사용 가능한 모델 목록 (데이터가 있는 모델만)
const availableModels = useMemo(() => {
const models: { name: string; count: number; isDefault: boolean }[] = [];
for (const [name, items] of correlationByModel) {
models.push({ name, count: items.length, isDefault: items[0]?.isDefault ?? false });
}
models.sort((a, b) => (a.isDefault ? -1 : 0) - (b.isDefault ? -1 : 0));
return models;
}, [correlationByModel]);
// 오퍼레이셔널 폴리곤 (비재생 정적 연산)
const operationalPolygons = useMemo(() => {
if (!selectedGearGroup || !groupPolygons) return [];
const allGroups = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
const { members: mergedMembers } = mergeSubClusterMembers(allGroups, selectedGearGroup);
if (mergedMembers.length === 0) return [];
const basePts: [number, number][] = mergedMembers.map(m => [m.lon, m.lat]);
const result: { modelName: string; color: string; geojson: GeoJSON.FeatureCollection }[] = [];
for (const [mn, items] of correlationByModel) {
if (!enabledModels.has(mn)) continue;
const extra: [number, number][] = [];
for (const c of items) {
if (c.score < 0.7) continue;
const s = ships.find(x => x.mmsi === c.targetMmsi);
if (s) extra.push([s.lng, s.lat]);
}
if (extra.length === 0) continue;
const polygon = buildInterpPolygon([...basePts, ...extra]);
if (!polygon) continue;
const color = MODEL_COLORS[mn] ?? '#94a3b8';
result.push({
modelName: mn,
color,
geojson: {
type: 'FeatureCollection',
features: [{ type: 'Feature', properties: { modelName: mn, color }, geometry: polygon }],
},
});
}
return result;
}, [selectedGearGroup, groupPolygons, correlationByModel, enabledModels, ships]);
// 어구 클러스터 GeoJSON — allGroups에서 직접 (서브클러스터별 개별 폴리곤 유지)
const gearClusterGeoJson = useMemo((): GeoJSON => {
const features: GeoJSON.Feature[] = [];
if (!groupPolygons) return { type: 'FeatureCollection', features };
for (const g of groupPolygons.allGroups.filter(x => x.groupType !== 'FLEET')) {
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]);
// 가상 선박 마커 GeoJSON (API members + shipMap heading 보정)
const memberMarkersGeoJson = useMemo((): GeoJSON => {
const features: GeoJSON.Feature[] = [];
if (!groupPolygons) return { type: 'FeatureCollection', features };
const addMember = (
m: { mmsi: string; name: string; lat: number; lon: number; cog: number; isParent: boolean; role: string },
groupKey: string,
groupType: string,
color: string,
) => {
const realShip = shipMap.get(m.mmsi);
const heading = realShip?.heading ?? m.cog ?? 0;
const lat = realShip?.lat ?? m.lat;
const lon = realShip?.lng ?? m.lon;
features.push({
type: 'Feature',
properties: {
mmsi: m.mmsi,
name: m.name,
groupKey,
groupType,
role: m.role,
isParent: m.isParent ? 1 : 0,
isGear: (groupType !== 'FLEET' && !m.isParent) ? 1 : 0,
color,
cog: heading,
baseSize: (groupType !== 'FLEET' && !m.isParent) ? 0.11 : m.isParent ? 0.18 : 0.14,
},
geometry: { type: 'Point', coordinates: [lon, lat] },
});
};
for (const g of groupPolygons.fleetGroups) {
const cid = Number(g.groupKey);
const fleetColor = FLEET_PALETTE[cid % FLEET_PALETTE.length];
for (const m of g.members) addMember(m, g.groupKey, 'FLEET', fleetColor);
}
for (const g of [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]) {
const color = g.groupType === 'GEAR_IN_ZONE' ? '#dc2626' : '#f97316';
for (const m of g.members) addMember(m, g.groupKey, g.groupType, color);
}
return { type: 'FeatureCollection', features };
}, [groupPolygons, shipMap]);
// picker 호버 하이라이트 (선단 + 어구 통합)
const pickerHighlightGeoJson = useMemo((): GeoJSON => {
if (!pickerHoveredGroup || !groupPolygons) return EMPTY_FC;
const fleet = groupPolygons.fleetGroups.find(x => String(x.groupKey) === pickerHoveredGroup);
if (fleet?.polygon) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: fleet.polygon }] };
const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
const g = all.find(x => x.groupKey === pickerHoveredGroup);
if (!g?.polygon) return EMPTY_FC;
return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: g.polygon }] };
}, [pickerHoveredGroup, groupPolygons]);
// 선택된 어구 그룹 하이라이트 폴리곤
const selectedGearHighlightGeoJson = useMemo((): GeoJSON.FeatureCollection | null => {
if (!selectedGearGroup || !enabledModels.has('identity') || historyActive) return null;
const allGroups = groupPolygons
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
: [];
const matches = allGroups.filter(g => g.groupKey === selectedGearGroup && g.polygon);
if (matches.length === 0) return null;
return {
type: 'FeatureCollection',
features: matches.map(g => ({
type: 'Feature' as const,
properties: { subClusterId: g.subClusterId },
geometry: g.polygon!,
})),
};
}, [selectedGearGroup, enabledModels, historyActive, groupPolygons]);
// ── 연관 대상 마커 (ships[] fallback) ──
const correlationVesselGeoJson = useMemo((): GeoJSON => {
if (!selectedGearGroup || correlationByModel.size === 0) return EMPTY_FC;
const features: GeoJSON.Feature[] = [];
const seen = new Set<string>();
for (const [mn, items] of correlationByModel) {
if (!enabledModels.has(mn)) continue;
const color = MODEL_COLORS[mn] ?? '#94a3b8';
for (const c of items) {
if (seen.has(c.targetMmsi)) continue;
const s = ships.find(x => x.mmsi === c.targetMmsi);
if (!s) continue;
seen.add(c.targetMmsi);
features.push({
type: 'Feature',
properties: {
mmsi: c.targetMmsi,
name: c.targetName || c.targetMmsi,
score: c.score,
cog: s.course ?? 0,
color,
isVessel: c.targetType === 'VESSEL' ? 1 : 0,
},
geometry: { type: 'Point', coordinates: [s.lng, s.lat] },
});
}
}
return { type: 'FeatureCollection', features };
}, [selectedGearGroup, correlationByModel, enabledModels, ships]);
// 연관 대상 트레일 (전체 항적)
const correlationTrailGeoJson = useMemo((): GeoJSON => {
if (correlationTracks.length === 0) return EMPTY_FC;
const features: GeoJSON.Feature[] = [];
const vesselColor = new Map<string, string>();
for (const [mn, items] of correlationByModel) {
if (!enabledModels.has(mn)) continue;
for (const c of items) {
if (!vesselColor.has(c.targetMmsi)) vesselColor.set(c.targetMmsi, MODEL_COLORS[mn] ?? '#60a5fa');
}
}
for (const vt of correlationTracks) {
if (!enabledVessels.has(vt.mmsi)) continue;
const color = vesselColor.get(vt.mmsi) ?? '#60a5fa';
const coords: [number, number][] = vt.track.map(pt => [pt.lon, pt.lat]);
if (coords.length >= 2) {
features.push({ type: 'Feature', properties: { mmsi: vt.mmsi, color }, geometry: { type: 'LineString', coordinates: coords } });
}
}
return { type: 'FeatureCollection', features };
}, [correlationTracks, enabledVessels, correlationByModel, enabledModels]);
// 모델 배지 GeoJSON (groupPolygons 기반)
const modelBadgesGeoJson = useMemo((): GeoJSON => {
if (!selectedGearGroup) return EMPTY_FC;
const targets = new Map<string, { lon: number; lat: number; models: Set<string> }>();
if (enabledModels.has('identity') && groupPolygons) {
const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
const { members } = mergeSubClusterMembers(all, selectedGearGroup);
for (const m of members) {
const e = targets.get(m.mmsi) ?? { lon: m.lon, lat: m.lat, models: new Set<string>() };
e.lon = m.lon; e.lat = m.lat; e.models.add('identity');
targets.set(m.mmsi, e);
}
}
for (const [mn, items] of correlationByModel) {
if (!enabledModels.has(mn)) continue;
for (const c of items) {
if (c.score < 0.3) continue;
const s = ships.find(x => x.mmsi === c.targetMmsi);
if (!s) continue;
const e = targets.get(c.targetMmsi) ?? { lon: s.lng, lat: s.lat, models: new Set<string>() };
e.lon = s.lng; e.lat = s.lat; e.models.add(mn);
targets.set(c.targetMmsi, e);
}
}
const features: GeoJSON.Feature[] = [];
for (const [mmsi, t] of targets) {
if (t.models.size === 0) continue;
const props: Record<string, unknown> = { mmsi };
for (let i = 0; i < MODEL_ORDER.length; i++) props[`m${i}`] = t.models.has(MODEL_ORDER[i]) ? 1 : 0;
features.push({ type: 'Feature', properties: props, geometry: { type: 'Point', coordinates: [t.lon, t.lat] } });
}
return { type: 'FeatureCollection', features };
}, [selectedGearGroup, enabledModels, groupPolygons, correlationByModel, ships]);
// 호버 하이라이트 — 대상 위치
const hoverHighlightGeoJson = useMemo((): GeoJSON => {
if (!hoveredMmsi || !selectedGearGroup) return EMPTY_FC;
if (groupPolygons) {
const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
const { members: allMembers } = mergeSubClusterMembers(all, selectedGearGroup);
const m = allMembers.find(x => x.mmsi === hoveredMmsi);
if (m) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [m.lon, m.lat] } }] };
}
const s = ships.find(x => x.mmsi === hoveredMmsi);
if (s) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [s.lng, s.lat] } }] };
return EMPTY_FC;
}, [hoveredMmsi, selectedGearGroup, groupPolygons, ships]);
// 호버 하이라이트 — 대상 항적
const hoverHighlightTrailGeoJson = useMemo((): GeoJSON => {
if (!hoveredMmsi) return EMPTY_FC;
const vt = correlationTracks.find(v => v.mmsi === hoveredMmsi);
if (!vt) return EMPTY_FC;
const coords: [number, number][] = vt.track.map(pt => [pt.lon, pt.lat]);
if (coords.length < 2) return EMPTY_FC;
return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } }] };
}, [hoveredMmsi, correlationTracks]);
// 선단 목록 (멤버 수 내림차순)
const fleetList = useMemo((): FleetListItem[] => {
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: FLEET_PALETTE[Number(g.groupKey) % FLEET_PALETTE.length],
members: g.members,
})).sort((a, b) => b.memberCount - a.memberCount);
}, [groupPolygons]);
return {
fleetPolygonGeoJSON,
lineGeoJSON,
hoveredGeoJSON,
gearClusterGeoJson,
memberMarkersGeoJson,
pickerHighlightGeoJson,
selectedGearHighlightGeoJson,
correlationVesselGeoJson,
correlationTrailGeoJson,
modelBadgesGeoJson,
hoverHighlightGeoJson,
hoverHighlightTrailGeoJson,
operationalPolygons,
fleetList,
correlationByModel,
availableModels,
};
}