iran 백엔드 프록시 잔재 제거: - IranBackendClient dead class 삭제, AppProperties/application.yml iran-backend 블록 제거 - Frontend UI 라벨/주석/system-flow manifest deprecated 마킹 - CLAUDE.md 시스템 구성 다이어그램 최신화 백엔드 계층 분리: - AlertController/MasterDataController/AdminStatsController 에서 repository/JdbcTemplate 직접 주입 제거 - AlertService/MasterDataService/AdminStatsService 신규 계층 도입 + @Transactional(readOnly=true) - Proxy controller 의 @PostConstruct RestClient 생성 → RestClientConfig @Bean 으로 통합 감사 로그 보강: - EnforcementService createRecord/updateRecord/createPlan 에 @Auditable 추가 - VesselAnalysisGroupService.resolveParent 에 PARENT_RESOLVE 액션 기록 카탈로그 정합성: - performanceStatus 를 catalogRegistry 에 등록 (쇼케이스 자동 노출) - alertLevels 확장: isValidAlertLevel / isHighSeverity / getAlertLevelOrder - LiveMapView/DarkVesselDetection 시각 매핑(opacity/radius/tier score) 상수로 추출 - GearIdentification/vesselAnomaly 직접 분기를 타입 가드/헬퍼로 치환
418 lines
13 KiB
TypeScript
418 lines
13 KiB
TypeScript
/**
|
|
* 어구 그룹 궤적 리플레이 전처리 모듈
|
|
*
|
|
* API 데이터(history frames) → deck.gl TripsLayer 포맷으로 변환.
|
|
*/
|
|
|
|
// ── 타입 ──────────────────────────────────────────────────────────────
|
|
|
|
export interface HistoryFrame {
|
|
snapshotTime: string;
|
|
centerLat: number;
|
|
centerLon: number;
|
|
memberCount: number;
|
|
polygon: unknown;
|
|
members: FrameMember[];
|
|
subClusterId?: number;
|
|
areaSqNm?: number;
|
|
}
|
|
|
|
export interface FrameMember {
|
|
mmsi: string;
|
|
name?: string;
|
|
lat: number;
|
|
lon: number;
|
|
cog?: number;
|
|
sog?: number;
|
|
role?: string;
|
|
isParent?: boolean;
|
|
}
|
|
|
|
/** TripsLayer 데이터 항목 — MMSI별 궤적 */
|
|
export interface TripsLayerDatum {
|
|
id: string;
|
|
path: [number, number][];
|
|
timestamps: number[];
|
|
color: [number, number, number, number];
|
|
}
|
|
|
|
/** 중심 궤적 세그먼트 (PathLayer용) */
|
|
export interface CenterTrailSegment {
|
|
path: [number, number][];
|
|
isInterpolated: boolean;
|
|
}
|
|
|
|
/** 멤버 보간 위치 (프레임 간 보간) */
|
|
export interface MemberPosition {
|
|
mmsi: string;
|
|
name: string;
|
|
lon: number;
|
|
lat: number;
|
|
cog: number;
|
|
role: string;
|
|
isParent: boolean;
|
|
isGear: boolean;
|
|
stale: boolean;
|
|
}
|
|
|
|
// ── 색상 상수 ──────────────────────────────────────────────────────────
|
|
|
|
const PARENT_COLOR: [number, number, number, number] = [6, 182, 212, 220]; // cyan
|
|
const GEAR_COLOR: [number, number, number, number] = [245, 158, 11, 200]; // amber
|
|
const OTHER_COLOR: [number, number, number, number] = [148, 163, 184, 180]; // slate
|
|
|
|
function memberColor(role: string, isParent: boolean): [number, number, number, number] {
|
|
if (isParent || role === 'PARENT') return PARENT_COLOR;
|
|
if (role === 'GEAR') return GEAR_COLOR;
|
|
return OTHER_COLOR;
|
|
}
|
|
|
|
// ── 1. buildMemberTripsData ────────────────────────────────────────────
|
|
|
|
/**
|
|
* 프레임별 멤버를 순회하여 MMSI별 TripsLayer 데이터를 생성.
|
|
* timestamps는 startTime 기준 상대값 (TripsLayer 요구사항).
|
|
*/
|
|
export function buildMemberTripsData(
|
|
frames: HistoryFrame[],
|
|
startTime: number,
|
|
): TripsLayerDatum[] {
|
|
const byMmsi = new Map<string, {
|
|
path: [number, number][];
|
|
timestamps: number[];
|
|
role: string;
|
|
isParent: boolean;
|
|
}>();
|
|
|
|
for (const frame of frames) {
|
|
const frameTime = new Date(frame.snapshotTime).getTime();
|
|
const relativeTime = frameTime - startTime;
|
|
|
|
for (const m of frame.members) {
|
|
if (m.lat == null || m.lon == null) continue;
|
|
|
|
let entry = byMmsi.get(m.mmsi);
|
|
if (!entry) {
|
|
entry = {
|
|
path: [],
|
|
timestamps: [],
|
|
role: m.role ?? 'UNKNOWN',
|
|
isParent: m.isParent ?? false,
|
|
};
|
|
byMmsi.set(m.mmsi, entry);
|
|
}
|
|
entry.path.push([m.lon, m.lat]);
|
|
entry.timestamps.push(relativeTime);
|
|
}
|
|
}
|
|
|
|
const result: TripsLayerDatum[] = [];
|
|
for (const [mmsi, entry] of byMmsi) {
|
|
if (entry.path.length < 2) continue; // 궤적이 되려면 최소 2점
|
|
result.push({
|
|
id: mmsi,
|
|
path: entry.path,
|
|
timestamps: entry.timestamps,
|
|
color: memberColor(entry.role, entry.isParent),
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// ── 2. buildCenterTrailData ────────────────────────────────────────────
|
|
|
|
/**
|
|
* 어구 그룹 중심 이동 궤적 (PathLayer용).
|
|
* 연속 프레임의 중심을 이어서 세그먼트 생성.
|
|
*/
|
|
export function buildCenterTrailData(frames: HistoryFrame[]): {
|
|
segments: CenterTrailSegment[];
|
|
dots: [number, number][];
|
|
} {
|
|
const dots: [number, number][] = [];
|
|
const path: [number, number][] = [];
|
|
|
|
for (const f of frames) {
|
|
if (f.centerLon == null || f.centerLat == null) continue;
|
|
const pos: [number, number] = [f.centerLon, f.centerLat];
|
|
dots.push(pos);
|
|
path.push(pos);
|
|
}
|
|
|
|
// 단일 연속 세그먼트 (끊김 없음)
|
|
const segments: CenterTrailSegment[] = path.length >= 2
|
|
? [{ path, isInterpolated: false }]
|
|
: [];
|
|
|
|
return { segments, dots };
|
|
}
|
|
|
|
// ── 3. buildSnapshotRanges ─────────────────────────────────────────────
|
|
|
|
/**
|
|
* 프로그레스바 틱마크용 — 각 스냅샷 시점을 [0, 1] 범위로 정규화.
|
|
*/
|
|
export function buildSnapshotRanges(
|
|
frames: HistoryFrame[],
|
|
startTime: number,
|
|
endTime: number,
|
|
): number[] {
|
|
const duration = endTime - startTime;
|
|
if (duration <= 0) return [];
|
|
|
|
return frames.map(f => {
|
|
const t = new Date(f.snapshotTime).getTime();
|
|
return (t - startTime) / duration;
|
|
});
|
|
}
|
|
|
|
// ── 4. findFrameAtTime ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* 현재 시점에 해당하는 프레임 인덱스를 찾는다.
|
|
* 커서 기반 전진 스캔(O(1) amortized) + 이진탐색 fallback.
|
|
*
|
|
* @returns { index, cursor } — index=-1이면 stale (30분 이상 이전)
|
|
*/
|
|
export function findFrameAtTime(
|
|
frameTimes: number[],
|
|
timeMs: number,
|
|
cursor: number,
|
|
): { index: number; cursor: number } {
|
|
const len = frameTimes.length;
|
|
if (len === 0) return { index: -1, cursor: 0 };
|
|
|
|
// 범위 밖
|
|
if (timeMs <= frameTimes[0]) return { index: 0, cursor: 0 };
|
|
if (timeMs >= frameTimes[len - 1]) return { index: len - 1, cursor: len - 1 };
|
|
|
|
// 커서 전진 스캔 (재생 중 대부분 여기서 해결)
|
|
let c = Math.min(Math.max(0, cursor), len - 1);
|
|
if (frameTimes[c] <= timeMs) {
|
|
while (c < len - 1 && frameTimes[c + 1] <= timeMs) c++;
|
|
return { index: c, cursor: c };
|
|
}
|
|
|
|
// fallback: 이진탐색 (seek 시)
|
|
let lo = 0;
|
|
let hi = len - 1;
|
|
while (lo < hi) {
|
|
const mid = (lo + hi + 1) >> 1;
|
|
if (frameTimes[mid] <= timeMs) lo = mid;
|
|
else hi = mid - 1;
|
|
}
|
|
return { index: lo, cursor: lo };
|
|
}
|
|
|
|
// ── 5. interpolateMemberPositions ──────────────────────────────────────
|
|
|
|
// ── 7. computeConvexHull ───────────────────────────────────────────────
|
|
|
|
/**
|
|
* 멤버 위치 기반 Convex Hull 계산 (Graham scan) + 패딩.
|
|
*
|
|
* 리플레이 시 매 프레임 보간된 멤버 위치로 폴리곤을 직접 생성.
|
|
* API 폴리곤(프레임별 스냅샷)이 아닌 실시간 멤버 위치 반영.
|
|
*
|
|
* @param paddingDeg — 외곽 패딩 (도 단위, 기본 0.005° ≈ 약 500m)
|
|
*/
|
|
export function computeConvexHull(
|
|
positions: MemberPosition[],
|
|
paddingDeg = 0.005,
|
|
): [number, number][] | null {
|
|
const points: [number, number][] = positions.map(p => [p.lon, p.lat]);
|
|
if (points.length < 3) return null;
|
|
|
|
// 가장 아래(lat 최소), 같으면 왼쪽(lon 최소) 점 찾기
|
|
let pivot = 0;
|
|
for (let i = 1; i < points.length; i++) {
|
|
if (
|
|
points[i][1] < points[pivot][1] ||
|
|
(points[i][1] === points[pivot][1] && points[i][0] < points[pivot][0])
|
|
) {
|
|
pivot = i;
|
|
}
|
|
}
|
|
[points[0], points[pivot]] = [points[pivot], points[0]];
|
|
const origin = points[0];
|
|
|
|
// 극각 기준 정렬
|
|
const rest = points.slice(1).sort((a, b) => {
|
|
const angleA = Math.atan2(a[1] - origin[1], a[0] - origin[0]);
|
|
const angleB = Math.atan2(b[1] - origin[1], b[0] - origin[0]);
|
|
if (angleA !== angleB) return angleA - angleB;
|
|
// 같은 각도면 거리순
|
|
const distA = (a[0] - origin[0]) ** 2 + (a[1] - origin[1]) ** 2;
|
|
const distB = (b[0] - origin[0]) ** 2 + (b[1] - origin[1]) ** 2;
|
|
return distA - distB;
|
|
});
|
|
|
|
const stack: [number, number][] = [origin];
|
|
for (const p of rest) {
|
|
while (stack.length >= 2) {
|
|
const a = stack[stack.length - 2];
|
|
const b = stack[stack.length - 1];
|
|
// 외적: 반시계 방향이 아니면 제거
|
|
const cross = (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]);
|
|
if (cross > 0) break;
|
|
stack.pop();
|
|
}
|
|
stack.push(p);
|
|
}
|
|
|
|
if (stack.length < 3) return null;
|
|
|
|
// 중심 계산
|
|
let cx = 0, cy = 0;
|
|
for (const p of stack) { cx += p[0]; cy += p[1]; }
|
|
cx /= stack.length;
|
|
cy /= stack.length;
|
|
|
|
// 패딩 적용: 각 꼭짓점을 중심에서 바깥으로 paddingDeg만큼 밀어냄
|
|
const padded: [number, number][] = stack.map(p => {
|
|
const dx = p[0] - cx;
|
|
const dy = p[1] - cy;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
if (dist === 0) return p;
|
|
const scale = (dist + paddingDeg) / dist;
|
|
return [cx + dx * scale, cy + dy * scale];
|
|
});
|
|
|
|
// GeoJSON Polygon은 첫 점 = 끝 점 (닫힌 링)
|
|
padded.push(padded[0]);
|
|
return padded;
|
|
}
|
|
|
|
// ── 5. 멤버 메타데이터 ─────────────────────────────────────────────────
|
|
|
|
/** 멤버 메타 정보 (name, role, isParent) — 전체 프레임에서 추출 */
|
|
export interface MemberMeta {
|
|
name: string;
|
|
role: string;
|
|
isParent: boolean;
|
|
}
|
|
|
|
/**
|
|
* 전체 프레임을 순회하여 멤버별 메타 정보 수집.
|
|
* 나중 프레임이 이전 프레임을 덮어씀 (최신 정보 우선).
|
|
*/
|
|
export function buildMemberMetadata(
|
|
frames: HistoryFrame[],
|
|
): Map<string, MemberMeta> {
|
|
const meta = new Map<string, MemberMeta>();
|
|
for (const frame of frames) {
|
|
for (const m of frame.members) {
|
|
meta.set(m.mmsi, {
|
|
name: m.name ?? '',
|
|
role: m.role ?? 'UNKNOWN',
|
|
isParent: m.isParent ?? false,
|
|
});
|
|
}
|
|
}
|
|
return meta;
|
|
}
|
|
|
|
// ── 6. interpolateFromTripsData ────────────────────────────────────────
|
|
|
|
/**
|
|
* 두 좌표 사이의 heading(침로) 계산.
|
|
*/
|
|
function calcHeading(p1: [number, number], p2: [number, number]): number {
|
|
const dx = p2[0] - p1[0];
|
|
const dy = p2[1] - p1[1];
|
|
let angle = (Math.atan2(dx, dy) * 180) / Math.PI;
|
|
if (angle < 0) angle += 360;
|
|
return angle;
|
|
}
|
|
|
|
/**
|
|
* 멤버별 개별 타임라인에서 보간하여 현재 위치를 반환.
|
|
*
|
|
* 프레임 기반(frameA/frameB) 대신 멤버별 경로(memberTripsData)를 사용하여
|
|
* 각 멤버가 24시간 내내 연속 경로로 유지.
|
|
* 커서 기반 전진 스캔(O(1)) + 이진탐색 fallback (seek 대비).
|
|
*
|
|
* @param memberTripsData — 전처리된 멤버별 경로 (timestamps는 startTime 기준 상대값)
|
|
* @param memberMeta — 멤버 메타 정보 (name, role, isParent)
|
|
* @param relativeTimeMs — startTime 기준 상대 시간 (ms)
|
|
* @param cursors — 멤버별 커서 Map (호출 간 유지, 재생 시 O(1))
|
|
*/
|
|
export function interpolateFromTripsData(
|
|
memberTripsData: TripsLayerDatum[],
|
|
memberMeta: Map<string, MemberMeta>,
|
|
relativeTimeMs: number,
|
|
cursors: Map<string, number>,
|
|
): MemberPosition[] {
|
|
const positions: MemberPosition[] = [];
|
|
|
|
for (const trip of memberTripsData) {
|
|
const { id: mmsi, path, timestamps } = trip;
|
|
if (path.length === 0) continue;
|
|
|
|
const meta = memberMeta.get(mmsi);
|
|
const role = meta?.role ?? 'UNKNOWN';
|
|
const base = {
|
|
mmsi,
|
|
name: meta?.name ?? '',
|
|
role,
|
|
isParent: meta?.isParent ?? false,
|
|
isGear: role === 'GEAR',
|
|
stale: false,
|
|
};
|
|
|
|
// 범위 밖: 처음/마지막 위치 고정
|
|
if (relativeTimeMs <= timestamps[0]) {
|
|
positions.push({ ...base, lon: path[0][0], lat: path[0][1], cog: 0 });
|
|
continue;
|
|
}
|
|
if (relativeTimeMs >= timestamps[timestamps.length - 1]) {
|
|
const last = path.length - 1;
|
|
const cog = last > 0 ? calcHeading(path[last - 1], path[last]) : 0;
|
|
positions.push({ ...base, lon: path[last][0], lat: path[last][1], cog });
|
|
cursors.set(mmsi, last);
|
|
continue;
|
|
}
|
|
|
|
// 커서 기반 탐색 (positionCursors 패턴)
|
|
let cursor = cursors.get(mmsi) ?? 0;
|
|
|
|
if (
|
|
cursor >= timestamps.length ||
|
|
(cursor > 0 && timestamps[cursor - 1] > relativeTimeMs)
|
|
) {
|
|
// 커서 무효 or 시간 역행 → 이진탐색 fallback (seek 시)
|
|
let lo = 0;
|
|
let hi = timestamps.length - 1;
|
|
while (lo < hi) {
|
|
const mid = (lo + hi + 1) >> 1;
|
|
if (timestamps[mid] <= relativeTimeMs) lo = mid;
|
|
else hi = mid - 1;
|
|
}
|
|
cursor = lo;
|
|
} else {
|
|
// 선형 전진 (재생 중 1~2칸, O(1))
|
|
while (cursor < timestamps.length - 1 && timestamps[cursor + 1] <= relativeTimeMs) {
|
|
cursor++;
|
|
}
|
|
}
|
|
cursors.set(mmsi, cursor);
|
|
|
|
const idx1 = cursor;
|
|
const idx2 = Math.min(cursor + 1, timestamps.length - 1);
|
|
|
|
if (idx1 === idx2 || timestamps[idx1] === timestamps[idx2]) {
|
|
const cog = idx1 > 0 ? calcHeading(path[idx1 - 1], path[idx1]) : 0;
|
|
positions.push({ ...base, lon: path[idx1][0], lat: path[idx1][1], cog });
|
|
} else {
|
|
// 선형 보간
|
|
const ratio = (relativeTimeMs - timestamps[idx1]) / (timestamps[idx2] - timestamps[idx1]);
|
|
const lon = path[idx1][0] + (path[idx2][0] - path[idx1][0]) * ratio;
|
|
const lat = path[idx1][1] + (path[idx2][1] - path[idx1][1]) * ratio;
|
|
const cog = calcHeading(path[idx1], path[idx2]);
|
|
positions.push({ ...base, lon, lat, cog });
|
|
}
|
|
}
|
|
|
|
return positions;
|
|
}
|