- 서브클러스터별 독립 폴리곤/센터/center trail 렌더링 - 반경 밖 이탈 선박 강제 감쇠 (OUT_OF_RANGE) - Backend correlation API에 sub_cluster_id 추가 - 모델 패널 5개 항상 표시, 드롭다운 기본값 70% - DISPLAY_STALE_SEC (time_bucket 기반) 폴리곤 노출 필터 - AIS 수집 bbox 122~132E/31~39N 확장 - historyActive 시 deck.gl 이중 렌더링 수정 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
430 lines
17 KiB
TypeScript
430 lines
17 KiB
TypeScript
import { useMemo } from 'react';
|
|
import type { GeoJSON } from 'geojson';
|
|
import type { Ship, VesselAnalysisDto } from '../../types';
|
|
import type { GearCorrelationItem, CorrelationVesselTrack, GroupPolygonDto } 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]);
|
|
|
|
// 오퍼레이셔널 폴리곤 (비재생 정적 연산 — 서브클러스터별 분리, subClusterId 기반)
|
|
const operationalPolygons = useMemo(() => {
|
|
if (!selectedGearGroup || !groupPolygons) return [];
|
|
// 서브클러스터별 개별 그룹 (allGroups = raw, 서브클러스터 분리 유지)
|
|
const rawMatches = groupPolygons.allGroups.filter(
|
|
g => g.groupKey === selectedGearGroup && g.groupType !== 'FLEET',
|
|
);
|
|
if (rawMatches.length === 0) return [];
|
|
|
|
// 서브클러스터별 basePts
|
|
const subMap = new Map<number, [number, number][]>();
|
|
for (const g of rawMatches) {
|
|
const sid = g.subClusterId ?? 0;
|
|
subMap.set(sid, g.members.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 color = MODEL_COLORS[mn] ?? '#94a3b8';
|
|
|
|
// 연관 선박을 subClusterId로 그룹핑
|
|
const subExtras = new Map<number, [number, number][]>();
|
|
for (const c of items) {
|
|
if (c.score < 0.7) continue;
|
|
const s = ships.find(x => x.mmsi === c.targetMmsi);
|
|
if (!s) continue;
|
|
const sid = c.subClusterId ?? 0;
|
|
const list = subExtras.get(sid) ?? [];
|
|
list.push([s.lng, s.lat]);
|
|
subExtras.set(sid, list);
|
|
}
|
|
|
|
const features: GeoJSON.Feature[] = [];
|
|
for (const [sid, extraPts] of subExtras) {
|
|
if (extraPts.length === 0) continue;
|
|
const basePts = subMap.get(sid) ?? subMap.get(0) ?? [];
|
|
const polygon = buildInterpPolygon([...basePts, ...extraPts]);
|
|
if (polygon) features.push({ type: 'Feature', properties: { modelName: mn, color, subClusterId: sid }, geometry: polygon });
|
|
}
|
|
if (features.length > 0) {
|
|
result.push({ modelName: mn, color, geojson: { type: 'FeatureCollection', features } });
|
|
}
|
|
}
|
|
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,
|
|
};
|
|
}
|