feat: 어구 1h/6h 듀얼 폴리곤 + 리플레이 컨트롤러 개선 + 심볼 스케일 #212
@ -60,15 +60,16 @@ public class GroupPolygonService {
|
||||
|
||||
private static final String GROUP_CORRELATIONS_SQL = """
|
||||
WITH best_scores AS (
|
||||
SELECT DISTINCT ON (m.id, s.target_mmsi)
|
||||
SELECT DISTINCT ON (m.id, s.sub_cluster_id, s.target_mmsi)
|
||||
s.target_mmsi, s.target_type, s.target_name,
|
||||
s.current_score, s.streak_count, s.observation_count,
|
||||
s.freeze_state, s.shadow_bonus_total,
|
||||
s.sub_cluster_id,
|
||||
m.id AS model_id, m.name AS model_name, m.is_default
|
||||
FROM kcg.gear_correlation_scores s
|
||||
JOIN kcg.correlation_param_models m ON s.model_id = m.id
|
||||
WHERE s.group_key = ? AND s.current_score >= ? AND m.is_active = TRUE
|
||||
ORDER BY m.id, s.target_mmsi, s.current_score DESC
|
||||
ORDER BY m.id, s.sub_cluster_id, s.target_mmsi, s.current_score DESC
|
||||
)
|
||||
SELECT bs.*,
|
||||
r.proximity_ratio, r.visit_score, r.heading_coherence
|
||||
@ -120,6 +121,7 @@ public class GroupPolygonService {
|
||||
row.put("observations", rs.getInt("observation_count"));
|
||||
row.put("freezeState", rs.getString("freeze_state"));
|
||||
row.put("shadowBonus", rs.getDouble("shadow_bonus_total"));
|
||||
row.put("subClusterId", rs.getInt("sub_cluster_id"));
|
||||
row.put("proximityRatio", rs.getObject("proximity_ratio"));
|
||||
row.put("visitScore", rs.getObject("visit_score"));
|
||||
row.put("headingCoherence", rs.getObject("heading_coherence"));
|
||||
|
||||
@ -292,23 +292,26 @@ const CorrelationPanel = ({
|
||||
<span style={{ color: '#64748b', marginLeft: 'auto' }}>{memberCount}</span>
|
||||
</label>
|
||||
{correlationLoading && <div style={{ fontSize: 8, color: '#64748b' }}>로딩...</div>}
|
||||
{availableModels.map(m => {
|
||||
const color = MODEL_COLORS[m.name] ?? '#94a3b8';
|
||||
const modelItems = correlationByModel.get(m.name) ?? [];
|
||||
{_MODEL_ORDER.filter(mn => mn !== 'identity').map(mn => {
|
||||
const color = MODEL_COLORS[mn] ?? '#94a3b8';
|
||||
const modelItems = correlationByModel.get(mn) ?? [];
|
||||
const hasData = modelItems.length > 0;
|
||||
const vc = modelItems.filter(c => c.targetType === 'VESSEL').length;
|
||||
const gc = modelItems.filter(c => c.targetType !== 'VESSEL').length;
|
||||
const am = availableModels.find(m => m.name === mn);
|
||||
return (
|
||||
<label key={m.name} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, cursor: 'pointer', marginBottom: 3 }}>
|
||||
<input type="checkbox" checked={enabledModels.has(m.name)}
|
||||
<label key={mn} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, cursor: hasData ? 'pointer' : 'default', marginBottom: 3, opacity: hasData ? 1 : 0.4 }}>
|
||||
<input type="checkbox" checked={enabledModels.has(mn)}
|
||||
disabled={!hasData}
|
||||
onChange={() => onEnabledModelsChange(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(m.name)) next.delete(m.name); else next.add(m.name);
|
||||
if (next.has(mn)) next.delete(mn); else next.add(mn);
|
||||
return next;
|
||||
})}
|
||||
style={{ accentColor: color, width: 11, height: 11 }} title={m.name} />
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, flexShrink: 0 }} />
|
||||
<span style={{ color: '#e2e8f0', flex: 1 }}>{m.name}{m.isDefault ? '*' : ''}</span>
|
||||
<span style={{ color: '#64748b', fontSize: 8 }}>{vc}⛴{gc}◆</span>
|
||||
style={{ accentColor: color, width: 11, height: 11 }} title={mn} />
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, flexShrink: 0, opacity: hasData ? 1 : 0.3 }} />
|
||||
<span style={{ color: hasData ? '#e2e8f0' : '#64748b', flex: 1 }}>{mn}{am?.isDefault ? '*' : ''}</span>
|
||||
<span style={{ color: '#64748b', fontSize: 8 }}>{hasData ? `${vc}⛴${gc}◆` : '—'}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -42,7 +42,7 @@ function splitAndMergeHistory(history: GroupPolygonDto[]) {
|
||||
([subClusterId, data]) => ({ subClusterId, ...data }),
|
||||
);
|
||||
|
||||
// 2. 시간별 멤버 합산 프레임 (기존 리플레이 호환)
|
||||
// 2. 시간별 그룹핑 후 서브클러스터 보존
|
||||
const byTime = new Map<string, GroupPolygonDto[]>();
|
||||
for (const h of sorted) {
|
||||
const list = byTime.get(h.snapshotTime) ?? [];
|
||||
@ -52,34 +52,63 @@ function splitAndMergeHistory(history: GroupPolygonDto[]) {
|
||||
|
||||
const frames: GroupPolygonDto[] = [];
|
||||
for (const [, items] of byTime) {
|
||||
if (items.length === 1) {
|
||||
frames.push(items[0]);
|
||||
continue;
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const allMembers: GroupPolygonDto['members'] = [];
|
||||
for (const item of items) {
|
||||
for (const m of item.members) {
|
||||
if (!seen.has(m.mmsi)) { seen.add(m.mmsi); allMembers.push(m); }
|
||||
const allSameId = items.every(item => (item.subClusterId ?? 0) === 0);
|
||||
|
||||
if (items.length === 1 || allSameId) {
|
||||
// 단일 아이템 또는 모두 subClusterId=0: 통합 서브프레임 1개
|
||||
const base = items.length === 1 ? items[0] : (() => {
|
||||
const seen = new Set<string>();
|
||||
const allMembers: GroupPolygonDto['members'] = [];
|
||||
for (const item of items) {
|
||||
for (const m of item.members) {
|
||||
if (!seen.has(m.mmsi)) { seen.add(m.mmsi); allMembers.push(m); }
|
||||
}
|
||||
}
|
||||
const biggest = items.reduce((a, b) => (a.memberCount >= b.memberCount ? a : b));
|
||||
return { ...biggest, subClusterId: 0, members: allMembers, memberCount: allMembers.length };
|
||||
})();
|
||||
const subFrames: SubFrame[] = [{
|
||||
subClusterId: 0,
|
||||
centerLon: base.centerLon,
|
||||
centerLat: base.centerLat,
|
||||
members: base.members,
|
||||
memberCount: base.memberCount,
|
||||
}];
|
||||
frames.push({ ...base, subFrames } as GroupPolygonDto & { subFrames: SubFrame[] });
|
||||
} else {
|
||||
// 서로 다른 subClusterId: 각 아이템을 개별 서브프레임으로 보존
|
||||
const subFrames: SubFrame[] = items.map(item => ({
|
||||
subClusterId: item.subClusterId ?? 0,
|
||||
centerLon: item.centerLon,
|
||||
centerLat: item.centerLat,
|
||||
members: item.members,
|
||||
memberCount: item.memberCount,
|
||||
}));
|
||||
const seen = new Set<string>();
|
||||
const allMembers: GroupPolygonDto['members'] = [];
|
||||
for (const sf of subFrames) {
|
||||
for (const m of sf.members) {
|
||||
if (!seen.has(m.mmsi)) { seen.add(m.mmsi); allMembers.push(m); }
|
||||
}
|
||||
}
|
||||
const biggest = items.reduce((a, b) => (a.memberCount >= b.memberCount ? a : b));
|
||||
frames.push({
|
||||
...biggest,
|
||||
subClusterId: 0,
|
||||
members: allMembers,
|
||||
memberCount: allMembers.length,
|
||||
centerLat: biggest.centerLat,
|
||||
centerLon: biggest.centerLon,
|
||||
subFrames,
|
||||
} as GroupPolygonDto & { subFrames: SubFrame[] });
|
||||
}
|
||||
const biggest = items.reduce((a, b) => (a.memberCount >= b.memberCount ? a : b));
|
||||
frames.push({
|
||||
...biggest,
|
||||
subClusterId: 0,
|
||||
members: allMembers,
|
||||
memberCount: allMembers.length,
|
||||
// 가장 큰 서브클러스터의 center 사용 (가중 평균 아닌 대표 center)
|
||||
centerLat: biggest.centerLat,
|
||||
centerLon: biggest.centerLon,
|
||||
});
|
||||
}
|
||||
|
||||
return { frames: fillGapFrames(frames), subClusterCenters };
|
||||
return { frames: fillGapFrames(frames as HistoryFrame[]), subClusterCenters };
|
||||
}
|
||||
|
||||
// ── 분리된 모듈 ──
|
||||
import type { PickerCandidate, HoverTooltipState, GearPickerPopupState } from './fleetClusterTypes';
|
||||
import type { PickerCandidate, HoverTooltipState, GearPickerPopupState, SubFrame, HistoryFrame } from './fleetClusterTypes';
|
||||
import { EMPTY_ANALYSIS } from './fleetClusterTypes';
|
||||
import { fillGapFrames } from './fleetClusterUtils';
|
||||
import { useFleetClusterGeoJson } from './useFleetClusterGeoJson';
|
||||
@ -521,8 +550,9 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
||||
onClose={closeHistory}
|
||||
onFilterByScore={(minPct) => {
|
||||
// 전역: 모든 모델의 모든 대상에 적용 (모델 on/off 무관)
|
||||
// null(전체) = 30% 이상 전부 ON (API minScore=0.3 기준)
|
||||
if (minPct === null) {
|
||||
setEnabledVessels(new Set(correlationTracks.map(v => v.mmsi)));
|
||||
setEnabledVessels(new Set(correlationTracks.filter(v => v.score >= 0.3).map(v => v.mmsi)));
|
||||
} else {
|
||||
const threshold = minPct / 100;
|
||||
const filtered = new Set<string>();
|
||||
|
||||
@ -111,6 +111,7 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
|
||||
<span style={{ color: '#475569', margin: '0 2px' }}>|</span>
|
||||
<span style={{ color: '#64748b', fontSize: 9 }}>일치율</span>
|
||||
<select
|
||||
defaultValue="70"
|
||||
onChange={e => {
|
||||
const val = e.target.value;
|
||||
onFilterByScore(val === '' ? null : Number(val));
|
||||
@ -122,7 +123,7 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
|
||||
}}
|
||||
title="일치율 이상만 표시" aria-label="일치율 필터"
|
||||
>
|
||||
<option value="">전체</option>
|
||||
<option value="">전체 (30%+)</option>
|
||||
<option value="50">50%+</option>
|
||||
<option value="60">60%+</option>
|
||||
<option value="70">70%+</option>
|
||||
|
||||
@ -17,7 +17,6 @@ import { useGearReplayStore } from '../../stores/gearReplayStore';
|
||||
import { useShipDeckLayers } from '../../hooks/useShipDeckLayers';
|
||||
import { useShipDeckStore } from '../../stores/shipDeckStore';
|
||||
import { ShipPopupOverlay, ShipHoverTooltip } from '../layers/ShipPopupOverlay';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import { InfraLayer } from './InfraLayer';
|
||||
import { SatelliteLayer } from '../layers/SatelliteLayer';
|
||||
import { AircraftLayer } from '../layers/AircraftLayer';
|
||||
@ -276,29 +275,8 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
fetchKoreaInfra().then(setInfra).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// MapLibre에 ship-triangle/gear-diamond SDF 이미지 등록 (FleetClusterMapLayers에서 사용)
|
||||
const handleMapLoad = useCallback(() => {
|
||||
const m = mapRef.current?.getMap() as maplibregl.Map | undefined;
|
||||
if (!m) return;
|
||||
if (!m.hasImage('ship-triangle')) {
|
||||
const s = 64;
|
||||
const c = document.createElement('canvas'); c.width = s; c.height = s;
|
||||
const ctx = c.getContext('2d')!;
|
||||
ctx.beginPath(); ctx.moveTo(s/2,2); ctx.lineTo(s*0.12,s-2); ctx.lineTo(s/2,s*0.62); ctx.lineTo(s*0.88,s-2); ctx.closePath();
|
||||
ctx.fillStyle = '#fff'; ctx.fill();
|
||||
const d = ctx.getImageData(0,0,s,s);
|
||||
m.addImage('ship-triangle', { width: s, height: s, data: new Uint8Array(d.data.buffer) }, { sdf: true });
|
||||
}
|
||||
if (!m.hasImage('gear-diamond')) {
|
||||
const s = 64;
|
||||
const c = document.createElement('canvas'); c.width = s; c.height = s;
|
||||
const ctx = c.getContext('2d')!;
|
||||
ctx.beginPath(); ctx.moveTo(s/2,4); ctx.lineTo(s-4,s/2); ctx.lineTo(s/2,s-4); ctx.lineTo(4,s/2); ctx.closePath();
|
||||
ctx.fillStyle = '#fff'; ctx.fill();
|
||||
const d = ctx.getImageData(0,0,s,s);
|
||||
m.addImage('gear-diamond', { width: s, height: s, data: new Uint8Array(d.data.buffer) }, { sdf: true });
|
||||
}
|
||||
}, []);
|
||||
// MapLibre 맵 로드 완료 콜백 (ship-triangle/gear-diamond → deck.gl 전환 완료로 삭제)
|
||||
const handleMapLoad = useCallback(() => {}, []);
|
||||
|
||||
// ── shipDeckStore 동기화 ──
|
||||
useEffect(() => {
|
||||
|
||||
@ -1,8 +1,21 @@
|
||||
import type { Ship, VesselAnalysisDto } from '../../types';
|
||||
import type { MemberInfo } from '../../services/vesselAnalysis';
|
||||
import type { MemberInfo, GroupPolygonDto } from '../../services/vesselAnalysis';
|
||||
|
||||
// ── 서브클러스터 프레임 ──
|
||||
export interface SubFrame {
|
||||
subClusterId: number; // 0=통합, 1,2,...=분리
|
||||
centerLon: number;
|
||||
centerLat: number;
|
||||
members: MemberInfo[];
|
||||
memberCount: number;
|
||||
}
|
||||
|
||||
// ── 히스토리 스냅샷 + 보간 플래그 ──
|
||||
export type HistoryFrame = GroupPolygonDto & { _interp?: boolean; _longGap?: boolean };
|
||||
export type HistoryFrame = GroupPolygonDto & {
|
||||
_interp?: boolean;
|
||||
_longGap?: boolean;
|
||||
subFrames: SubFrame[]; // 항상 1개 이상
|
||||
};
|
||||
|
||||
// ── 외부 노출 타입 (KoreaMap에서 import) ──
|
||||
export interface SelectedGearGroupData {
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import type { GeoJSON } from 'geojson';
|
||||
import type { GroupPolygonDto } from '../../services/vesselAnalysis';
|
||||
import type { HistoryFrame } from './fleetClusterTypes';
|
||||
import type { HistoryFrame, SubFrame } from './fleetClusterTypes';
|
||||
import { GEAR_BUFFER_DEG, CIRCLE_SEGMENTS } from './fleetClusterTypes';
|
||||
|
||||
/** 트랙 포인트에서 주어진 시각의 보간 위치 반환 */
|
||||
@ -129,13 +128,20 @@ export function bufferPolygon(hull: [number, number][], buf: number): GeoJSON.Po
|
||||
* 히스토리 데이터의 gap 구간에 보간 프레임을 삽입하여 완성 데이터셋 반환.
|
||||
* - gap <= 30분: 5분 간격 직선 보간 (이전 폴리곤 유지, 중심만 직선 이동)
|
||||
* - gap > 30분: 30분 간격으로 멤버 위치 보간 + 가상 폴리곤 생성
|
||||
*
|
||||
* subFrames 보간 규칙:
|
||||
* - prev/next 양쪽에 동일 subClusterId 존재: 멤버/center 보간
|
||||
* - prev에만 존재: 마지막 위치 그대로 frozen
|
||||
* - next에만 존재: 갭 프레임에서 생략
|
||||
*
|
||||
* top-level members/centerLon/Lat: 전체 subFrames의 union (하위 호환)
|
||||
*/
|
||||
export function fillGapFrames(snapshots: GroupPolygonDto[]): HistoryFrame[] {
|
||||
export function fillGapFrames(snapshots: HistoryFrame[]): HistoryFrame[] {
|
||||
if (snapshots.length < 2) return snapshots;
|
||||
const STEP_SHORT_MS = 300_000;
|
||||
const STEP_LONG_MS = 1_800_000;
|
||||
const THRESHOLD_MS = 1_800_000;
|
||||
const result: GroupPolygonDto[] = [];
|
||||
const result: HistoryFrame[] = [];
|
||||
|
||||
for (let i = 0; i < snapshots.length; i++) {
|
||||
result.push(snapshots[i]);
|
||||
@ -152,25 +158,46 @@ export function fillGapFrames(snapshots: GroupPolygonDto[]): HistoryFrame[] {
|
||||
const common = prev.members.filter(m => nextMap.has(m.mmsi));
|
||||
if (common.length === 0) continue;
|
||||
|
||||
const nextSubMap = new Map(next.subFrames.map(sf => [sf.subClusterId, sf]));
|
||||
|
||||
if (gap <= THRESHOLD_MS) {
|
||||
for (let t = t0 + STEP_SHORT_MS; t < t1; t += STEP_SHORT_MS) {
|
||||
const ratio = (t - t0) / gap;
|
||||
const cLon = prev.centerLon + (next.centerLon - prev.centerLon) * ratio;
|
||||
const cLat = prev.centerLat + (next.centerLat - prev.centerLat) * ratio;
|
||||
|
||||
// prev 기준으로 순회: prev에만 존재(frozen) + 양쪽 존재(center 보간)
|
||||
// next에만 존재하는 subClusterId는 prev.subFrames에 없으므로 자동 생략
|
||||
const subFrames: SubFrame[] = prev.subFrames.map(psf => {
|
||||
const nsf = nextSubMap.get(psf.subClusterId);
|
||||
if (!nsf) {
|
||||
// prev에만 존재 → frozen
|
||||
return { ...psf };
|
||||
}
|
||||
// 양쪽 존재 → center 보간
|
||||
return {
|
||||
...psf,
|
||||
centerLon: psf.centerLon + (nsf.centerLon - psf.centerLon) * ratio,
|
||||
centerLat: psf.centerLat + (nsf.centerLat - psf.centerLat) * ratio,
|
||||
};
|
||||
});
|
||||
|
||||
result.push({
|
||||
...prev,
|
||||
snapshotTime: new Date(t).toISOString(),
|
||||
centerLon: cLon,
|
||||
centerLat: cLat,
|
||||
subFrames,
|
||||
_interp: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (let t = t0 + STEP_LONG_MS; t < t1; t += STEP_LONG_MS) {
|
||||
const ratio = (t - t0) / gap;
|
||||
const positions: [number, number][] = [];
|
||||
const members: typeof prev.members = [];
|
||||
|
||||
// top-level members 보간 (하위 호환)
|
||||
const topPositions: [number, number][] = [];
|
||||
const topMembers: GroupPolygonDto['members'] = [];
|
||||
for (const pm of common) {
|
||||
const nm = nextMap.get(pm.mmsi)!;
|
||||
const lon = pm.lon + (nm.lon - pm.lon) * ratio;
|
||||
@ -178,13 +205,53 @@ export function fillGapFrames(snapshots: GroupPolygonDto[]): HistoryFrame[] {
|
||||
const dLon = nm.lon - pm.lon;
|
||||
const dLat = nm.lat - pm.lat;
|
||||
const cog = (dLon === 0 && dLat === 0) ? pm.cog : (Math.atan2(dLon, dLat) * 180 / Math.PI + 360) % 360;
|
||||
members.push({ mmsi: pm.mmsi, name: pm.name, lon, lat, sog: pm.sog, cog, role: pm.role, isParent: pm.isParent });
|
||||
positions.push([lon, lat]);
|
||||
topMembers.push({ mmsi: pm.mmsi, name: pm.name, lon, lat, sog: pm.sog, cog, role: pm.role, isParent: pm.isParent });
|
||||
topPositions.push([lon, lat]);
|
||||
}
|
||||
const cLon = topPositions.reduce((s, p) => s + p[0], 0) / topPositions.length;
|
||||
const cLat = topPositions.reduce((s, p) => s + p[1], 0) / topPositions.length;
|
||||
const polygon = buildInterpPolygon(topPositions);
|
||||
|
||||
const cLon = positions.reduce((s, p) => s + p[0], 0) / positions.length;
|
||||
const cLat = positions.reduce((s, p) => s + p[1], 0) / positions.length;
|
||||
const polygon = buildInterpPolygon(positions);
|
||||
// subFrames 보간
|
||||
const subFrames: SubFrame[] = prev.subFrames.map(psf => {
|
||||
const nsf = nextSubMap.get(psf.subClusterId);
|
||||
if (!nsf) {
|
||||
// prev에만 존재 → frozen
|
||||
return { ...psf };
|
||||
}
|
||||
// 양쪽 존재 → 멤버 위치 보간 + 폴리곤 재생성
|
||||
const nsfMemberMap = new Map(nsf.members.map(m => [m.mmsi, m]));
|
||||
const commonSfMembers = psf.members.filter(m => nsfMemberMap.has(m.mmsi));
|
||||
const sfPositions: [number, number][] = [];
|
||||
const sfMembers: SubFrame['members'] = [];
|
||||
|
||||
for (const pm of commonSfMembers) {
|
||||
const nm = nsfMemberMap.get(pm.mmsi)!;
|
||||
const lon = pm.lon + (nm.lon - pm.lon) * ratio;
|
||||
const lat = pm.lat + (nm.lat - pm.lat) * ratio;
|
||||
const dLon = nm.lon - pm.lon;
|
||||
const dLat = nm.lat - pm.lat;
|
||||
const cog = (dLon === 0 && dLat === 0) ? pm.cog : (Math.atan2(dLon, dLat) * 180 / Math.PI + 360) % 360;
|
||||
sfMembers.push({ mmsi: pm.mmsi, name: pm.name, lon, lat, sog: pm.sog, cog, role: pm.role, isParent: pm.isParent });
|
||||
sfPositions.push([lon, lat]);
|
||||
}
|
||||
|
||||
if (sfPositions.length === 0) {
|
||||
// 공통 멤버 없으면 frozen
|
||||
return { ...psf };
|
||||
}
|
||||
|
||||
const sfCLon = sfPositions.reduce((s, p) => s + p[0], 0) / sfPositions.length;
|
||||
const sfCLat = sfPositions.reduce((s, p) => s + p[1], 0) / sfPositions.length;
|
||||
|
||||
return {
|
||||
subClusterId: psf.subClusterId,
|
||||
centerLon: sfCLon,
|
||||
centerLat: sfCLat,
|
||||
members: sfMembers,
|
||||
memberCount: sfMembers.length,
|
||||
};
|
||||
});
|
||||
|
||||
result.push({
|
||||
...prev,
|
||||
@ -192,8 +259,9 @@ export function fillGapFrames(snapshots: GroupPolygonDto[]): HistoryFrame[] {
|
||||
polygon,
|
||||
centerLon: cLon,
|
||||
centerLat: cLat,
|
||||
memberCount: members.length,
|
||||
members,
|
||||
memberCount: topMembers.length,
|
||||
members: topMembers,
|
||||
subFrames,
|
||||
_interp: true,
|
||||
_longGap: true,
|
||||
});
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { GeoJSON } from 'geojson';
|
||||
import type { Ship, VesselAnalysisDto } from '../../types';
|
||||
import type { GearCorrelationItem, CorrelationVesselTrack } from '../../services/vesselAnalysis';
|
||||
import type { GearCorrelationItem, CorrelationVesselTrack, GroupPolygonDto } from '../../services/vesselAnalysis';
|
||||
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
|
||||
import type { FleetListItem } from './fleetClusterTypes';
|
||||
import { buildInterpPolygon } from './fleetClusterUtils';
|
||||
@ -142,34 +142,49 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
|
||||
return models;
|
||||
}, [correlationByModel]);
|
||||
|
||||
// 오퍼레이셔널 폴리곤 (비재생 정적 연산)
|
||||
// 오퍼레이셔널 폴리곤 (비재생 정적 연산 — 서브클러스터별 분리, subClusterId 기반)
|
||||
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]);
|
||||
// 서브클러스터별 개별 그룹 (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 extra: [number, number][] = [];
|
||||
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) extra.push([s.lng, s.lat]);
|
||||
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 } });
|
||||
}
|
||||
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]);
|
||||
|
||||
@ -371,8 +371,8 @@ export function useFleetClusterDeckLayers(
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Correlation layers (only when gear group selected) ────────────────────
|
||||
if (selectedGearGroup) {
|
||||
// ── Correlation layers (only when gear group selected, skip during replay) ─
|
||||
if (selectedGearGroup && !historyActive) {
|
||||
|
||||
// ── 8. Operational polygons (per model) ────────────────────────────────
|
||||
for (const op of geo.operationalPolygons) {
|
||||
|
||||
@ -3,7 +3,7 @@ import type { Layer } from '@deck.gl/core';
|
||||
import { TripsLayer } from '@deck.gl/geo-layers';
|
||||
import { ScatterplotLayer, IconLayer, PathLayer, PolygonLayer, TextLayer } from '@deck.gl/layers';
|
||||
import { useGearReplayStore } from '../stores/gearReplayStore';
|
||||
import { findFrameAtTime, interpolateMemberPositions } from '../stores/gearReplayPreprocess';
|
||||
import { findFrameAtTime, interpolateMemberPositions, interpolateSubFrameMembers } from '../stores/gearReplayPreprocess';
|
||||
import type { MemberPosition } from '../stores/gearReplayPreprocess';
|
||||
import { MODEL_ORDER, MODEL_COLORS } from '../components/korea/fleetClusterConstants';
|
||||
import { buildInterpPolygon } from '../components/korea/fleetClusterUtils';
|
||||
@ -103,54 +103,59 @@ export function useGearReplayLayers(
|
||||
|
||||
const layers: Layer[] = [];
|
||||
|
||||
// ── 항상 표시: 센터 트레일 + 도트 ──────────────────────────────────
|
||||
// ── 항상 표시: 센터 트레일 ──────────────────────────────────
|
||||
// 서브클러스터가 존재하면 서브클러스터별 독립 trail만 표시 (전체 trail 숨김)
|
||||
const hasSubClusters = subClusterCenters.length > 0 &&
|
||||
subClusterCenters.some(sc => sc.subClusterId > 0);
|
||||
|
||||
// Center trail segments (PathLayer) — 항상 ON
|
||||
for (let i = 0; i < centerTrailSegments.length; i++) {
|
||||
const seg = centerTrailSegments[i];
|
||||
if (seg.path.length < 2) continue;
|
||||
layers.push(new PathLayer({
|
||||
id: `replay-center-trail-${i}`,
|
||||
data: [{ path: seg.path }],
|
||||
getPath: (d: { path: [number, number][] }) => d.path,
|
||||
getColor: seg.isInterpolated
|
||||
? [249, 115, 22, 200]
|
||||
: [251, 191, 36, 180],
|
||||
widthMinPixels: 2,
|
||||
}));
|
||||
}
|
||||
|
||||
// Center dots (real data only) — 항상 ON
|
||||
if (centerDotsPositions.length > 0) {
|
||||
layers.push(new ScatterplotLayer({
|
||||
id: 'replay-center-dots',
|
||||
data: centerDotsPositions,
|
||||
getPosition: (d: [number, number]) => d,
|
||||
getFillColor: [251, 191, 36, 150],
|
||||
getRadius: 80,
|
||||
radiusUnits: 'meters',
|
||||
radiusMinPixels: 2.5,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── 서브클러스터별 독립 center trail (PathLayer) ─────────────────────
|
||||
const SUB_COLORS: [number, number, number, number][] = [
|
||||
[251, 191, 36, 200], // sub=0 (unified) — 기존 gold
|
||||
const SUB_TRAIL_COLORS: [number, number, number, number][] = [
|
||||
[251, 191, 36, 200], // sub=0 (unified) — gold
|
||||
[96, 165, 250, 200], // sub=1 — blue
|
||||
[74, 222, 128, 200], // sub=2 — green
|
||||
[251, 146, 60, 200], // sub=3 — orange
|
||||
[167, 139, 250, 200], // sub=4 — purple
|
||||
];
|
||||
for (const sc of subClusterCenters) {
|
||||
if (sc.path.length < 2) continue;
|
||||
const color = SUB_COLORS[sc.subClusterId % SUB_COLORS.length];
|
||||
layers.push(new PathLayer({
|
||||
id: `replay-sub-center-${sc.subClusterId}`,
|
||||
data: [{ path: sc.path }],
|
||||
getPath: (d: { path: [number, number][] }) => d.path,
|
||||
getColor: color,
|
||||
widthMinPixels: 2,
|
||||
}));
|
||||
|
||||
if (hasSubClusters) {
|
||||
// 서브클러스터별 독립 center trail (sub=0 합산 trail 제외)
|
||||
for (const sc of subClusterCenters) {
|
||||
if (sc.subClusterId === 0) continue; // 합산 center는 점프 유발 → 제외
|
||||
if (sc.path.length < 2) continue;
|
||||
const color = SUB_TRAIL_COLORS[sc.subClusterId % SUB_TRAIL_COLORS.length];
|
||||
layers.push(new PathLayer({
|
||||
id: `replay-sub-center-${sc.subClusterId}`,
|
||||
data: [{ path: sc.path }],
|
||||
getPath: (d: { path: [number, number][] }) => d.path,
|
||||
getColor: color,
|
||||
widthMinPixels: 2,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
// 서브클러스터 없음: 기존 전체 center trail + dots
|
||||
for (let i = 0; i < centerTrailSegments.length; i++) {
|
||||
const seg = centerTrailSegments[i];
|
||||
if (seg.path.length < 2) continue;
|
||||
layers.push(new PathLayer({
|
||||
id: `replay-center-trail-${i}`,
|
||||
data: [{ path: seg.path }],
|
||||
getPath: (d: { path: [number, number][] }) => d.path,
|
||||
getColor: seg.isInterpolated
|
||||
? [249, 115, 22, 200]
|
||||
: [251, 191, 36, 180],
|
||||
widthMinPixels: 2,
|
||||
}));
|
||||
}
|
||||
if (centerDotsPositions.length > 0) {
|
||||
layers.push(new ScatterplotLayer({
|
||||
id: 'replay-center-dots',
|
||||
data: centerDotsPositions,
|
||||
getPosition: (d: [number, number]) => d,
|
||||
getFillColor: [251, 191, 36, 150],
|
||||
getRadius: 80,
|
||||
radiusUnits: 'meters',
|
||||
radiusMinPixels: 2.5,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dynamic layers (depend on currentTime) ────────────────────────────
|
||||
@ -169,6 +174,9 @@ export function useGearReplayLayers(
|
||||
const members = interpolateMemberPositions(state.historyFrames, frameIdx, ct);
|
||||
const memberPts: [number, number][] = members.map(m => [m.lon, m.lat]);
|
||||
|
||||
// 서브클러스터 프레임 (identity 폴리곤 + operational 폴리곤에서 공유)
|
||||
const subFrames = frame.subFrames ?? [{ subClusterId: 0, centerLon: frame.centerLon, centerLat: frame.centerLat, members: frame.members, memberCount: frame.memberCount }];
|
||||
|
||||
// ── "항적" 토글: 전체 24h 경로 PathLayer (정적 배경) ─────────────
|
||||
if (showTrails) {
|
||||
// 멤버 전체 항적 (identity — 항상 ON)
|
||||
@ -476,39 +484,46 @@ export function useGearReplayLayers(
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Operational polygons (멤버 위치 + enabledVessels ON인 연관 선박으로 폴리곤 생성)
|
||||
// 8. Operational polygons (서브클러스터별 분리 — subClusterId 기반)
|
||||
for (const [mn, items] of correlationByModel) {
|
||||
if (!enabledModels.has(mn)) continue;
|
||||
const color = MODEL_COLORS[mn] ?? '#94a3b8';
|
||||
const [r, g, b] = hexToRgb(color);
|
||||
|
||||
const extraPts: [number, number][] = [];
|
||||
// 연관 선박을 subClusterId로 그룹핑
|
||||
const subExtras = new Map<number, [number, number][]>();
|
||||
for (const c of items as GearCorrelationItem[]) {
|
||||
// enabledVessels로 개별 on/off 제어 (토글 대응)
|
||||
if (!enabledVessels.has(c.targetMmsi)) continue;
|
||||
const cp = corrPositions.find(p => p.mmsi === c.targetMmsi);
|
||||
if (cp) extraPts.push([cp.lon, cp.lat]);
|
||||
if (!cp) continue;
|
||||
const sid = c.subClusterId ?? 0;
|
||||
const list = subExtras.get(sid) ?? [];
|
||||
list.push([cp.lon, cp.lat]);
|
||||
subExtras.set(sid, list);
|
||||
}
|
||||
if (extraPts.length === 0) continue;
|
||||
|
||||
const basePts = memberPts; // identity 항상 ON
|
||||
const opPolygon = buildInterpPolygon([...basePts, ...extraPts]);
|
||||
if (!opPolygon) continue;
|
||||
|
||||
layers.push(new PolygonLayer({
|
||||
id: `replay-op-${mn}`,
|
||||
data: [{ polygon: opPolygon.coordinates }],
|
||||
getPolygon: (d: { polygon: number[][][] }) => d.polygon,
|
||||
getFillColor: [r, g, b, 30],
|
||||
getLineColor: [r, g, b, 200],
|
||||
getLineWidth: 2,
|
||||
lineWidthMinPixels: 2,
|
||||
filled: true,
|
||||
stroked: true,
|
||||
}));
|
||||
for (const [sid, extraPts] of subExtras) {
|
||||
if (extraPts.length === 0) continue;
|
||||
// 해당 서브클러스터의 멤버 포인트
|
||||
const sf = subFrames.find(s => s.subClusterId === sid);
|
||||
const basePts: [number, number][] = sf
|
||||
? interpolateSubFrameMembers(state.historyFrames, frameIdx, ct, sid).map(m => [m.lon, m.lat])
|
||||
: memberPts; // fallback: 전체 멤버
|
||||
const opPolygon = buildInterpPolygon([...basePts, ...extraPts]);
|
||||
if (opPolygon) {
|
||||
layers.push(new PolygonLayer({
|
||||
id: `replay-op-${mn}-sub${sid}`,
|
||||
data: [{ polygon: opPolygon.coordinates }],
|
||||
getPolygon: (d: { polygon: number[][][] }) => d.polygon,
|
||||
getFillColor: [r, g, b, 30],
|
||||
getLineColor: [r, g, b, 200],
|
||||
getLineWidth: 2, lineWidthMinPixels: 2, filled: true, stroked: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 8.5. Model center trails + current center point (모델별 폴리곤 중심 경로)
|
||||
// 8.5. Model center trails + current center point (모델×서브클러스터별 중심 경로)
|
||||
for (const trail of modelCenterTrails) {
|
||||
if (!enabledModels.has(trail.modelName)) continue;
|
||||
if (trail.path.length < 2) continue;
|
||||
@ -517,7 +532,7 @@ export function useGearReplayLayers(
|
||||
|
||||
// 중심 경로 (PathLayer, 연한 모델 색상)
|
||||
layers.push(new PathLayer({
|
||||
id: `replay-model-trail-${trail.modelName}`,
|
||||
id: `replay-model-trail-${trail.modelName}-s${trail.subClusterId}`,
|
||||
data: [{ path: trail.path }],
|
||||
getPath: (d: { path: [number, number][] }) => d.path,
|
||||
getColor: [r, g, b, 100],
|
||||
@ -535,7 +550,7 @@ export function useGearReplayLayers(
|
||||
|
||||
const centerData = [{ position: [cx, cy] as [number, number] }];
|
||||
layers.push(new ScatterplotLayer({
|
||||
id: `replay-model-center-${trail.modelName}`,
|
||||
id: `replay-model-center-${trail.modelName}-s${trail.subClusterId}`,
|
||||
data: centerData,
|
||||
getPosition: (d: { position: [number, number] }) => d.position,
|
||||
getFillColor: [r, g, b, 255],
|
||||
@ -548,7 +563,7 @@ export function useGearReplayLayers(
|
||||
}));
|
||||
if (showLabels) {
|
||||
layers.push(new TextLayer({
|
||||
id: `replay-model-center-label-${trail.modelName}`,
|
||||
id: `replay-model-center-label-${trail.modelName}-s${trail.subClusterId}`,
|
||||
data: centerData,
|
||||
getPosition: (d: { position: [number, number] }) => d.position,
|
||||
getText: () => trail.modelName,
|
||||
@ -616,22 +631,49 @@ export function useGearReplayLayers(
|
||||
}
|
||||
}
|
||||
|
||||
// ══ Identity 레이어 (최상위 z-index — 항상 ON, 다른 모델 위에 표시) ══
|
||||
// 폴리곤
|
||||
const identityPolygon = buildInterpPolygon(memberPts);
|
||||
if (identityPolygon) {
|
||||
// ══ Identity 레이어 (최상위 z-index — 서브클러스터별 독립 폴리곤) ══
|
||||
const SUB_POLY_COLORS: [number, number, number, number][] = [
|
||||
[251, 191, 36, 40], // sub0 — gold
|
||||
[96, 165, 250, 30], // sub1 — blue
|
||||
[74, 222, 128, 30], // sub2 — green
|
||||
[251, 146, 60, 30], // sub3 — orange
|
||||
[167, 139, 250, 30], // sub4 — purple
|
||||
];
|
||||
const SUB_STROKE_COLORS: [number, number, number, number][] = [
|
||||
[251, 191, 36, 180],
|
||||
[96, 165, 250, 180],
|
||||
[74, 222, 128, 180],
|
||||
[251, 146, 60, 180],
|
||||
[167, 139, 250, 180],
|
||||
];
|
||||
const SUB_CENTER_COLORS: [number, number, number, number][] = [
|
||||
[239, 68, 68, 255],
|
||||
[96, 165, 250, 255],
|
||||
[74, 222, 128, 255],
|
||||
[251, 146, 60, 255],
|
||||
[167, 139, 250, 255],
|
||||
];
|
||||
|
||||
for (const sf of subFrames) {
|
||||
const sfMembers = interpolateSubFrameMembers(state.historyFrames, frameIdx, ct, sf.subClusterId);
|
||||
const sfPts: [number, number][] = sfMembers.map(m => [m.lon, m.lat]);
|
||||
const poly = buildInterpPolygon(sfPts);
|
||||
if (!poly) continue;
|
||||
|
||||
const ci = sf.subClusterId % SUB_POLY_COLORS.length;
|
||||
layers.push(new PolygonLayer({
|
||||
id: 'replay-identity-polygon',
|
||||
data: [{ polygon: identityPolygon.coordinates }],
|
||||
id: `replay-identity-polygon-sub${sf.subClusterId}`,
|
||||
data: [{ polygon: poly.coordinates }],
|
||||
getPolygon: (d: { polygon: number[][][] }) => d.polygon,
|
||||
getFillColor: isStale ? [148, 163, 184, 30] : [251, 191, 36, 40],
|
||||
getLineColor: isStale ? [148, 163, 184, 100] : [251, 191, 36, 180],
|
||||
getFillColor: isStale ? [148, 163, 184, 30] : SUB_POLY_COLORS[ci],
|
||||
getLineColor: isStale ? [148, 163, 184, 100] : SUB_STROKE_COLORS[ci],
|
||||
getLineWidth: isStale ? 1 : 2,
|
||||
lineWidthMinPixels: 1,
|
||||
filled: true,
|
||||
stroked: true,
|
||||
}));
|
||||
}
|
||||
|
||||
// TripsLayer (멤버 트레일)
|
||||
if (memberTripsData.length > 0) {
|
||||
layers.push(new TripsLayer({
|
||||
@ -646,19 +688,34 @@ export function useGearReplayLayers(
|
||||
currentTime: ct - st,
|
||||
}));
|
||||
}
|
||||
// 센터 포인트
|
||||
layers.push(new ScatterplotLayer({
|
||||
id: 'replay-identity-center',
|
||||
data: [{ position: [frame.centerLon, frame.centerLat] as [number, number] }],
|
||||
getPosition: (d: { position: [number, number] }) => d.position,
|
||||
getFillColor: isStale ? [249, 115, 22, 255] : [239, 68, 68, 255],
|
||||
getRadius: 200,
|
||||
radiusUnits: 'meters',
|
||||
radiusMinPixels: 7,
|
||||
stroked: true,
|
||||
getLineColor: [255, 255, 255, 255],
|
||||
lineWidthMinPixels: 2,
|
||||
}));
|
||||
|
||||
// 센터 포인트 (서브클러스터별 독립)
|
||||
for (const sf of subFrames) {
|
||||
// 다음 프레임의 같은 서브클러스터 센터와 보간
|
||||
const nextFrame = frameIdx < state.historyFrames.length - 1 ? state.historyFrames[frameIdx + 1] : null;
|
||||
const nextSf = nextFrame?.subFrames?.find(s => s.subClusterId === sf.subClusterId);
|
||||
let cx = sf.centerLon, cy = sf.centerLat;
|
||||
if (nextSf && nextFrame) {
|
||||
const t0 = new Date(frame.snapshotTime).getTime();
|
||||
const t1 = new Date(nextFrame.snapshotTime).getTime();
|
||||
const r = t1 > t0 ? Math.max(0, Math.min(1, (ct - t0) / (t1 - t0))) : 0;
|
||||
cx = sf.centerLon + (nextSf.centerLon - sf.centerLon) * r;
|
||||
cy = sf.centerLat + (nextSf.centerLat - sf.centerLat) * r;
|
||||
}
|
||||
const ci = sf.subClusterId % SUB_CENTER_COLORS.length;
|
||||
layers.push(new ScatterplotLayer({
|
||||
id: `replay-identity-center-sub${sf.subClusterId}`,
|
||||
data: [{ position: [cx, cy] as [number, number] }],
|
||||
getPosition: (d: { position: [number, number] }) => d.position,
|
||||
getFillColor: isStale ? [249, 115, 22, 255] : SUB_CENTER_COLORS[ci],
|
||||
getRadius: 200,
|
||||
radiusUnits: 'meters',
|
||||
radiusMinPixels: 7,
|
||||
stroked: true,
|
||||
getLineColor: [255, 255, 255, 255],
|
||||
lineWidthMinPixels: 2,
|
||||
}));
|
||||
}
|
||||
|
||||
replayLayerRef.current = layers;
|
||||
requestRender();
|
||||
|
||||
@ -106,6 +106,7 @@ export interface GearCorrelationItem {
|
||||
streak: number;
|
||||
observations: number;
|
||||
freezeState: string;
|
||||
subClusterId: number;
|
||||
proximityRatio: number | null;
|
||||
visitScore: number | null;
|
||||
headingCoherence: number | null;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { HistoryFrame } from '../components/korea/fleetClusterTypes';
|
||||
import type { HistoryFrame, SubFrame } from '../components/korea/fleetClusterTypes';
|
||||
import type { CorrelationVesselTrack, GearCorrelationItem } from '../services/vesselAnalysis';
|
||||
import { buildInterpPolygon } from '../components/korea/fleetClusterUtils';
|
||||
|
||||
@ -235,16 +235,104 @@ export function interpolateMemberPositions(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* interpolateMemberPositions와 동일한 보간 로직이지만,
|
||||
* 특정 subClusterId에 속한 멤버만 스코프한다.
|
||||
* subClusterId에 해당하는 SubFrame이 없으면 빈 배열을 반환한다.
|
||||
*/
|
||||
export function interpolateSubFrameMembers(
|
||||
frames: HistoryFrame[],
|
||||
frameIdx: number,
|
||||
timeMs: number,
|
||||
subClusterId: number,
|
||||
): MemberPosition[] {
|
||||
if (frameIdx < 0 || frameIdx >= frames.length) return [];
|
||||
|
||||
const frame = frames[frameIdx];
|
||||
const subFrame: SubFrame | undefined = frame.subFrames.find(sf => sf.subClusterId === subClusterId);
|
||||
if (!subFrame) return [];
|
||||
|
||||
const isStale = !!frame._longGap || !!frame._interp;
|
||||
|
||||
const toPosition = (
|
||||
m: { mmsi: string; name: string; lon: number; lat: number; cog: number; role: string; isParent: boolean },
|
||||
lon: number,
|
||||
lat: number,
|
||||
cog: number,
|
||||
): MemberPosition => ({
|
||||
mmsi: m.mmsi,
|
||||
name: m.name,
|
||||
lon,
|
||||
lat,
|
||||
cog,
|
||||
role: m.role,
|
||||
isParent: m.isParent,
|
||||
isGear: m.role === 'GEAR' || !m.isParent,
|
||||
stale: isStale,
|
||||
});
|
||||
|
||||
// 다음 프레임 없음 — 현재 subFrame 위치 그대로 반환
|
||||
if (frameIdx >= frames.length - 1) {
|
||||
return subFrame.members.map(m => toPosition(m, m.lon, m.lat, m.cog));
|
||||
}
|
||||
|
||||
const nextFrame = frames[frameIdx + 1];
|
||||
const nextSubFrame: SubFrame | undefined = nextFrame.subFrames.find(
|
||||
sf => sf.subClusterId === subClusterId,
|
||||
);
|
||||
|
||||
// 다음 프레임에 해당 subClusterId 없음 — 현재 위치 그대로 반환
|
||||
if (!nextSubFrame) {
|
||||
return subFrame.members.map(m => toPosition(m, m.lon, m.lat, m.cog));
|
||||
}
|
||||
|
||||
const t0 = new Date(frame.snapshotTime).getTime();
|
||||
const t1 = new Date(nextFrame.snapshotTime).getTime();
|
||||
const ratio = t1 > t0 ? Math.max(0, Math.min(1, (timeMs - t0) / (t1 - t0))) : 0;
|
||||
|
||||
const nextMap = new Map(nextSubFrame.members.map(m => [m.mmsi, m]));
|
||||
|
||||
return subFrame.members.map(m => {
|
||||
const nm = nextMap.get(m.mmsi);
|
||||
if (!nm) {
|
||||
return toPosition(m, m.lon, m.lat, m.cog);
|
||||
}
|
||||
return toPosition(
|
||||
m,
|
||||
m.lon + (nm.lon - m.lon) * ratio,
|
||||
m.lat + (nm.lat - m.lat) * ratio,
|
||||
nm.cog,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 모델별 오퍼레이셔널 폴리곤 중심 경로 사전 계산.
|
||||
* 각 프레임마다 멤버 위치 + 연관 선박 위치(트랙 보간)로 폴리곤을 만들고 중심점을 기록.
|
||||
*/
|
||||
export interface ModelCenterTrail {
|
||||
modelName: string;
|
||||
subClusterId: number; // 서브클러스터별 독립 trail
|
||||
path: [number, number][]; // [lon, lat][]
|
||||
timestamps: number[]; // relative ms
|
||||
}
|
||||
|
||||
/** 트랙 맵에서 특정 시점의 보간 위치 조회 */
|
||||
function _interpTrackPos(
|
||||
track: { ts: number[]; path: [number, number][] },
|
||||
t: number,
|
||||
): [number, number] {
|
||||
if (t <= track.ts[0]) return track.path[0];
|
||||
if (t >= track.ts[track.ts.length - 1]) return track.path[track.path.length - 1];
|
||||
let lo = 0, hi = track.ts.length - 1;
|
||||
while (lo < hi - 1) { const mid = (lo + hi) >> 1; if (track.ts[mid] <= t) lo = mid; else hi = mid; }
|
||||
const ratio = track.ts[hi] !== track.ts[lo] ? (t - track.ts[lo]) / (track.ts[hi] - track.ts[lo]) : 0;
|
||||
return [
|
||||
track.path[lo][0] + (track.path[hi][0] - track.path[lo][0]) * ratio,
|
||||
track.path[lo][1] + (track.path[hi][1] - track.path[lo][1]) * ratio,
|
||||
];
|
||||
}
|
||||
|
||||
export function buildModelCenterTrails(
|
||||
frames: HistoryFrame[],
|
||||
corrTracks: CorrelationVesselTrack[],
|
||||
@ -252,7 +340,6 @@ export function buildModelCenterTrails(
|
||||
enabledVessels: Set<string>,
|
||||
startTime: number,
|
||||
): ModelCenterTrail[] {
|
||||
// 연관 선박 트랙 맵: mmsi → {timestampsMs[], geometry[][]}
|
||||
const trackMap = new Map<string, { ts: number[]; path: [number, number][] }>();
|
||||
for (const vt of corrTracks) {
|
||||
if (vt.track.length < 1) continue;
|
||||
@ -268,51 +355,53 @@ export function buildModelCenterTrails(
|
||||
const enabledItems = items.filter(c => enabledVessels.has(c.targetMmsi));
|
||||
if (enabledItems.length === 0) continue;
|
||||
|
||||
const path: [number, number][] = [];
|
||||
const timestamps: number[] = [];
|
||||
|
||||
for (const frame of frames) {
|
||||
const t = new Date(frame.snapshotTime).getTime();
|
||||
const relT = t - startTime;
|
||||
|
||||
// 멤버 위치
|
||||
const allPts: [number, number][] = frame.members.map(m => [m.lon, m.lat]);
|
||||
|
||||
// 연관 선박 위치 (트랙 보간 or 마지막 점 clamp)
|
||||
for (const c of enabledItems) {
|
||||
const track = trackMap.get(c.targetMmsi);
|
||||
if (!track || track.path.length === 0) continue;
|
||||
|
||||
let lon: number, lat: number;
|
||||
if (t <= track.ts[0]) {
|
||||
lon = track.path[0][0]; lat = track.path[0][1];
|
||||
} else if (t >= track.ts[track.ts.length - 1]) {
|
||||
const last = track.path.length - 1;
|
||||
lon = track.path[last][0]; lat = track.path[last][1];
|
||||
} else {
|
||||
let lo = 0, hi = track.ts.length - 1;
|
||||
while (lo < hi - 1) { const mid = (lo + hi) >> 1; if (track.ts[mid] <= t) lo = mid; else hi = mid; }
|
||||
const ratio = track.ts[hi] !== track.ts[lo] ? (t - track.ts[lo]) / (track.ts[hi] - track.ts[lo]) : 0;
|
||||
lon = track.path[lo][0] + (track.path[hi][0] - track.path[lo][0]) * ratio;
|
||||
lat = track.path[lo][1] + (track.path[hi][1] - track.path[lo][1]) * ratio;
|
||||
}
|
||||
allPts.push([lon, lat]);
|
||||
}
|
||||
|
||||
// 폴리곤 중심 계산
|
||||
const poly = buildInterpPolygon(allPts);
|
||||
if (!poly) continue;
|
||||
const ring = poly.coordinates[0];
|
||||
let cx = 0, cy = 0;
|
||||
for (const pt of ring) { cx += pt[0]; cy += pt[1]; }
|
||||
cx /= ring.length; cy /= ring.length;
|
||||
|
||||
path.push([cx, cy]);
|
||||
timestamps.push(relT);
|
||||
// subClusterId별 연관 선박 그룹핑
|
||||
const subItemsMap = new Map<number, typeof enabledItems>();
|
||||
for (const c of enabledItems) {
|
||||
const sid = c.subClusterId ?? 0;
|
||||
const list = subItemsMap.get(sid) ?? [];
|
||||
list.push(c);
|
||||
subItemsMap.set(sid, list);
|
||||
}
|
||||
|
||||
if (path.length >= 2) {
|
||||
results.push({ modelName: mn, path, timestamps });
|
||||
// 서브클러스터별 독립 trail 생성
|
||||
for (const [sid, subItems] of subItemsMap) {
|
||||
const path: [number, number][] = [];
|
||||
const timestamps: number[] = [];
|
||||
|
||||
for (const frame of frames) {
|
||||
const t = new Date(frame.snapshotTime).getTime();
|
||||
const relT = t - startTime;
|
||||
|
||||
// 해당 서브클러스터의 멤버 위치
|
||||
const sf = frame.subFrames?.find(s => s.subClusterId === sid);
|
||||
const basePts: [number, number][] = sf
|
||||
? sf.members.map(m => [m.lon, m.lat])
|
||||
: frame.members.map(m => [m.lon, m.lat]); // fallback
|
||||
|
||||
const allPts: [number, number][] = [...basePts];
|
||||
|
||||
// 연관 선박 위치 (트랙 보간)
|
||||
for (const c of subItems) {
|
||||
const track = trackMap.get(c.targetMmsi);
|
||||
if (!track || track.path.length === 0) continue;
|
||||
allPts.push(_interpTrackPos(track, t));
|
||||
}
|
||||
|
||||
const poly = buildInterpPolygon(allPts);
|
||||
if (!poly) continue;
|
||||
const ring = poly.coordinates[0];
|
||||
let cx = 0, cy = 0;
|
||||
for (const pt of ring) { cx += pt[0]; cy += pt[1]; }
|
||||
cx /= ring.length; cy /= ring.length;
|
||||
|
||||
path.push([cx, cy]);
|
||||
timestamps.push(relT);
|
||||
}
|
||||
|
||||
if (path.length >= 2) {
|
||||
results.push({ modelName: mn, subClusterId: sid, path, timestamps });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -556,6 +556,7 @@ def run_gear_correlation(
|
||||
score_batch: list[tuple] = []
|
||||
total_updated = 0
|
||||
total_raw = 0
|
||||
processed_keys: set[tuple] = set() # (model_id, parent_name, sub_cluster_id, target_mmsi)
|
||||
|
||||
default_params = models[0]
|
||||
|
||||
@ -650,6 +651,8 @@ def run_gear_correlation(
|
||||
0.0, model,
|
||||
)
|
||||
|
||||
processed_keys.add(score_key)
|
||||
|
||||
if new_score >= model.track_threshold or prev is not None:
|
||||
score_batch.append((
|
||||
model.model_id, parent_name, sub_cluster_id, target_mmsi,
|
||||
@ -659,6 +662,28 @@ def run_gear_correlation(
|
||||
))
|
||||
total_updated += 1
|
||||
|
||||
# ── 반경 밖 이탈 선박 강제 감쇠 ──────────────────────────────────
|
||||
# all_scores에 기록이 있지만 이번 사이클 후보에서 빠진 항목:
|
||||
# 선박이 탐색 반경(group_radius × 3)을 완전히 벗어난 경우.
|
||||
# Freeze 조건 무시하고 decay_fast 적용 → 빠르게 0으로 수렴.
|
||||
for score_key, prev in all_scores.items():
|
||||
if score_key in processed_keys:
|
||||
continue
|
||||
prev_score = prev['current_score']
|
||||
if prev_score is None or prev_score <= 0:
|
||||
continue
|
||||
model_id, parent_name_s, sub_cluster_id_s, target_mmsi_s = score_key
|
||||
# 해당 모델의 decay_fast 파라미터 사용
|
||||
model_params = next((m for m in models if m.model_id == model_id), default_params)
|
||||
new_score = max(0.0, prev_score - model_params.decay_fast)
|
||||
score_batch.append((
|
||||
model_id, parent_name_s, sub_cluster_id_s, target_mmsi_s,
|
||||
prev.get('target_type', 'VESSEL'), prev.get('target_name', ''),
|
||||
round(new_score, 6), 0, 'OUT_OF_RANGE',
|
||||
prev.get('last_observed_at', now), now, now,
|
||||
))
|
||||
total_updated += 1
|
||||
|
||||
# 배치 DB 저장
|
||||
_batch_insert_raw(conn, raw_batch)
|
||||
_batch_upsert_scores(conn, score_batch)
|
||||
@ -709,7 +734,8 @@ def _load_all_scores(conn) -> dict[tuple, dict]:
|
||||
try:
|
||||
cur.execute(
|
||||
"SELECT model_id, group_key, sub_cluster_id, target_mmsi, "
|
||||
"current_score, streak_count, last_observed_at "
|
||||
"current_score, streak_count, last_observed_at, "
|
||||
"target_type, target_name "
|
||||
"FROM kcg.gear_correlation_scores"
|
||||
)
|
||||
result = {}
|
||||
@ -719,6 +745,8 @@ def _load_all_scores(conn) -> dict[tuple, dict]:
|
||||
'current_score': row[4],
|
||||
'streak_count': row[5],
|
||||
'last_observed_at': row[6],
|
||||
'target_type': row[7],
|
||||
'target_name': row[8],
|
||||
}
|
||||
return result
|
||||
except Exception as e:
|
||||
|
||||
@ -26,7 +26,9 @@ logger = logging.getLogger(__name__)
|
||||
# 어구 이름 패턴 — _ 필수 (공백만으로는 어구 미판정, fleet_tracker.py와 동일)
|
||||
GEAR_PATTERN = re.compile(r'^(.+?)_(?=\S*\d)\S+(?:[_ ]\S*)*[_ ]*$|^(\d+)$')
|
||||
MAX_DIST_DEG = 0.15 # ~10NM
|
||||
STALE_SEC = 21600 # 6시간 (어구 P75 갭 3.5h, P90 갭 8h 커버)
|
||||
STALE_SEC = 21600 # 6시간 (어구 P75 갭 3.5h, P90 갭 8h 커버) — 그룹 멤버 탐색용
|
||||
DISPLAY_STALE_SEC = 3600 # 1시간 — 폴리곤 스냅샷 노출 기준 (프론트엔드 초기 로드 minutes=60과 동기화)
|
||||
# time_bucket(적재시간) 기반 필터링 — AIS 원본 timestamp는 부표 시계 오류로 부정확할 수 있음
|
||||
FLEET_BUFFER_DEG = 0.02
|
||||
GEAR_BUFFER_DEG = 0.01
|
||||
MIN_GEAR_GROUP_SIZE = 2 # 최소 어구 수 (비허가 구역 외)
|
||||
@ -187,6 +189,7 @@ def detect_gear_groups(
|
||||
'lon': pos['lon'],
|
||||
'sog': pos.get('sog', 0),
|
||||
'cog': pos.get('cog', 0),
|
||||
'timestamp': ts,
|
||||
}
|
||||
raw_groups.setdefault(parent_key, []).append(entry)
|
||||
|
||||
@ -341,6 +344,7 @@ def build_all_group_snapshots(
|
||||
points: list[tuple[float, float]] = []
|
||||
members: list[dict] = []
|
||||
|
||||
newest_age = float('inf')
|
||||
for mmsi in mmsi_list:
|
||||
pos = all_positions.get(mmsi)
|
||||
if not pos:
|
||||
@ -360,9 +364,24 @@ def build_all_group_snapshots(
|
||||
'role': 'LEADER' if mmsi == mmsi_list[0] else 'MEMBER',
|
||||
'isParent': False,
|
||||
})
|
||||
# 멤버 중 가장 최근 적재시간(time_bucket) 추적
|
||||
tb = pos.get('time_bucket')
|
||||
if tb is not None:
|
||||
try:
|
||||
import pandas as pd
|
||||
tb_dt = pd.Timestamp(tb)
|
||||
if tb_dt.tzinfo is None:
|
||||
from zoneinfo import ZoneInfo
|
||||
tb_dt = tb_dt.tz_localize(ZoneInfo('Asia/Seoul')).tz_convert(timezone.utc)
|
||||
tb_dt = tb_dt.to_pydatetime()
|
||||
age = (now - tb_dt).total_seconds()
|
||||
if age < newest_age:
|
||||
newest_age = age
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2척 미만은 폴리곤 미생성
|
||||
if len(points) < 2:
|
||||
# 2척 미만 또는 최근 적재가 DISPLAY_STALE_SEC 초과 → 폴리곤 미생성
|
||||
if len(points) < 2 or newest_age > DISPLAY_STALE_SEC:
|
||||
continue
|
||||
|
||||
polygon_wkt, center_wkt, area_sq_nm, center_lat, center_lon = build_group_polygon(
|
||||
@ -392,6 +411,30 @@ def build_all_group_snapshots(
|
||||
parent_mmsi: Optional[str] = group['parent_mmsi']
|
||||
gear_members: list[dict] = group['members']
|
||||
|
||||
# 표시 기준: 그룹 멤버 중 가장 최근 적재(time_bucket)가 DISPLAY_STALE_SEC 이내여야 노출
|
||||
# time_bucket은 KST naive이므로 UTC로 변환 후 비교
|
||||
newest_age = float('inf')
|
||||
for gm in gear_members:
|
||||
gm_mmsi = gm.get('mmsi')
|
||||
gm_pos = all_positions.get(gm_mmsi) if gm_mmsi else None
|
||||
gm_tb = gm_pos.get('time_bucket') if gm_pos else None
|
||||
if gm_tb is not None:
|
||||
try:
|
||||
import pandas as pd
|
||||
tb_dt = pd.Timestamp(gm_tb)
|
||||
if tb_dt.tzinfo is None:
|
||||
# time_bucket은 KST (Asia/Seoul, UTC+9)
|
||||
from zoneinfo import ZoneInfo
|
||||
tb_dt = tb_dt.tz_localize(ZoneInfo('Asia/Seoul')).tz_convert(timezone.utc)
|
||||
tb_dt = tb_dt.to_pydatetime()
|
||||
except Exception:
|
||||
continue
|
||||
age = (now - tb_dt).total_seconds()
|
||||
if age < newest_age:
|
||||
newest_age = age
|
||||
if newest_age > DISPLAY_STALE_SEC:
|
||||
continue
|
||||
|
||||
# 수역 분류: anchor(모선 or 첫 어구) 위치 기준
|
||||
anchor_lat: Optional[float] = None
|
||||
anchor_lon: Optional[float] = None
|
||||
|
||||
1
prediction/cache/vessel_store.py
vendored
1
prediction/cache/vessel_store.py
vendored
@ -345,6 +345,7 @@ class VesselStore:
|
||||
'sog': float(last.get('sog', 0) or last.get('raw_sog', 0) or 0),
|
||||
'cog': cog,
|
||||
'timestamp': last.get('timestamp'),
|
||||
'time_bucket': last.get('time_bucket'),
|
||||
'name': info.get('name', ''),
|
||||
}
|
||||
return result
|
||||
|
||||
@ -60,12 +60,13 @@ def fetch_all_tracks(hours: int = 24) -> pd.DataFrame:
|
||||
"""한국 해역 전 선박의 궤적 포인트를 조회한다.
|
||||
|
||||
LineStringM 지오메트리에서 개별 포인트를 추출하며,
|
||||
한국 해역(124-132E, 32-39N) 내 최근 N시간 데이터를 반환한다.
|
||||
한국 해역(122-132E, 31-39N) 내 최근 N시간 데이터를 반환한다.
|
||||
"""
|
||||
query = f"""
|
||||
SELECT
|
||||
t.mmsi,
|
||||
to_timestamp(ST_M((dp).geom)) as timestamp,
|
||||
t.time_bucket,
|
||||
ST_Y((dp).geom) as lat,
|
||||
ST_X((dp).geom) as lon,
|
||||
CASE
|
||||
@ -75,7 +76,7 @@ def fetch_all_tracks(hours: int = 24) -> pd.DataFrame:
|
||||
FROM signal.t_vessel_tracks_5min t,
|
||||
LATERAL ST_DumpPoints(t.track_geom) dp
|
||||
WHERE t.time_bucket >= NOW() - INTERVAL '{hours} hours'
|
||||
AND t.track_geom && ST_MakeEnvelope(124, 32, 132, 39, 4326)
|
||||
AND t.track_geom && ST_MakeEnvelope(122, 31, 132, 39, 4326)
|
||||
ORDER BY t.mmsi, to_timestamp(ST_M((dp).geom))
|
||||
"""
|
||||
|
||||
@ -104,6 +105,7 @@ def fetch_incremental(last_bucket: datetime) -> pd.DataFrame:
|
||||
SELECT
|
||||
t.mmsi,
|
||||
to_timestamp(ST_M((dp).geom)) as timestamp,
|
||||
t.time_bucket,
|
||||
ST_Y((dp).geom) as lat,
|
||||
ST_X((dp).geom) as lon,
|
||||
CASE
|
||||
@ -113,7 +115,7 @@ def fetch_incremental(last_bucket: datetime) -> pd.DataFrame:
|
||||
FROM signal.t_vessel_tracks_5min t,
|
||||
LATERAL ST_DumpPoints(t.track_geom) dp
|
||||
WHERE t.time_bucket > %s
|
||||
AND t.track_geom && ST_MakeEnvelope(124, 32, 132, 39, 4326)
|
||||
AND t.track_geom && ST_MakeEnvelope(122, 31, 132, 39, 4326)
|
||||
ORDER BY t.mmsi, to_timestamp(ST_M((dp).geom))
|
||||
"""
|
||||
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user