kcg-ai-monitoring/frontend/src/stores/gearReplayPreprocess.ts
htlee 9251d7593c refactor: 프로젝트 뼈대 정리 — iran 잔재 제거 + 백엔드 계층 분리 + 카탈로그 등록
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 직접 분기를 타입 가드/헬퍼로 치환
2026-04-16 16:18:18 +09:00

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;
}