kcg-monitoring/frontend/src/stores/gearReplayPreprocess.ts
htlee f09186a187 feat: 어구 리플레이 서브클러스터 분리 렌더링 + 일치율 감쇠 개선
- 서브클러스터별 독립 폴리곤/센터/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>
2026-04-01 09:01:03 +09:00

410 lines
12 KiB
TypeScript
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { HistoryFrame, SubFrame } from '../components/korea/fleetClusterTypes';
import type { CorrelationVesselTrack, GearCorrelationItem } from '../services/vesselAnalysis';
import { buildInterpPolygon } from '../components/korea/fleetClusterUtils';
export interface TripsLayerDatum {
id: string;
path: [number, number][]; // [lon, lat][]
timestamps: number[]; // relative ms from startTime (TripsLayer requirement)
color: [number, number, number, number];
}
export interface MemberPosition {
mmsi: string;
name: string;
lon: number;
lat: number;
cog: number;
role: string;
isParent: boolean;
isGear: boolean;
stale: boolean;
}
export interface CenterTrailSegment {
path: [number, number][];
isInterpolated: boolean;
}
/**
* Walk all frames and collect per-MMSI tracks for TripsLayer rendering.
* timestamps are relative ms from startTime (deck.gl TripsLayer requirement).
*/
export function buildMemberTripsData(frames: HistoryFrame[], startTime: number): TripsLayerDatum[] {
const memberMap = new Map<string, { path: [number, number][]; timestamps: number[] }>();
for (const frame of frames) {
const t = new Date(frame.snapshotTime).getTime() - startTime;
for (const member of frame.members) {
const entry = memberMap.get(member.mmsi) ?? { path: [], timestamps: [] };
entry.path.push([member.lon, member.lat]);
entry.timestamps.push(t);
memberMap.set(member.mmsi, entry);
}
}
const result: TripsLayerDatum[] = [];
for (const [mmsi, data] of memberMap) {
if (data.path.length >= 2) {
result.push({
id: mmsi,
path: data.path,
timestamps: data.timestamps,
color: [200, 200, 200, 180],
});
}
}
return result;
}
/**
* Convert correlation vessel tracks to TripsLayer format.
* timestamps are relative ms from startTime (deck.gl TripsLayer requirement).
*/
export function buildCorrelationTripsData(
tracks: CorrelationVesselTrack[],
startTime: number,
): TripsLayerDatum[] {
const result: TripsLayerDatum[] = [];
for (const vt of tracks) {
if (vt.track.length >= 2) {
result.push({
id: vt.mmsi,
path: vt.track.map(pt => [pt.lon, pt.lat]),
timestamps: vt.track.map(pt => pt.ts - startTime),
color: [96, 165, 250, 150],
});
}
}
return result;
}
/**
* Split center trail into real/interpolated segments and collect real-data dot positions.
* Consecutive frames with the same _longGap flag form one segment.
*/
export function buildCenterTrailData(
frames: HistoryFrame[],
): { segments: CenterTrailSegment[]; dots: [number, number][] } {
const segments: CenterTrailSegment[] = [];
const dots: [number, number][] = [];
if (frames.length === 0) return { segments, dots };
let segStart = 0;
for (let i = 1; i <= frames.length; i++) {
const curInterp = i < frames.length ? !!frames[i]._longGap : null;
const startInterp = !!frames[segStart]._longGap;
if (i < frames.length && curInterp === startInterp) continue;
const from = segStart > 0 ? segStart - 1 : segStart;
const seg = frames.slice(from, i);
if (seg.length >= 2) {
segments.push({
path: seg.map(s => [s.centerLon, s.centerLat]),
isInterpolated: startInterp,
});
}
segStart = i;
}
for (const frame of frames) {
if (!frame._longGap && !frame._interp) {
dots.push([frame.centerLon, frame.centerLat]);
}
}
return { segments, dots };
}
/**
* Map real (non-interpolated) frames to normalized [0, 1] positions
* along the timeline, for progress bar gap indicators.
*/
export function buildSnapshotRanges(
frames: HistoryFrame[],
startTime: number,
endTime: number,
): number[] {
const duration = endTime - startTime;
if (duration <= 0) return [];
return frames
.filter(h => !h._interp)
.map(h => (new Date(h.snapshotTime).getTime() - startTime) / duration);
}
/**
* Cursor-based frame index lookup.
* Uses forward linear scan from cursorHint during normal playback (O(12)),
* falls back to binary search when time goes backward or hint is invalid.
* Returns { index: -1 } when the closest frame is more than 30 minutes away.
*/
export function findFrameAtTime(
frameTimes: number[],
timeMs: number,
cursorHint: number,
): { index: number; cursor: number } {
if (frameTimes.length === 0) return { index: -1, cursor: 0 };
// Forward linear scan from cursor
if (cursorHint >= 0 && cursorHint < frameTimes.length) {
if (frameTimes[cursorHint] <= timeMs) {
let i = cursorHint;
while (i < frameTimes.length - 1 && frameTimes[i + 1] <= timeMs) {
i++;
}
return { index: i, cursor: i };
}
// Time went backward — fall through to binary search
}
// Binary search fallback
let lo = 0;
let hi = frameTimes.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (frameTimes[mid] <= timeMs) {
lo = mid;
} else {
hi = mid - 1;
}
}
if (Math.abs(frameTimes[lo] - timeMs) > 1_800_000) {
return { index: -1, cursor: lo };
}
return { index: lo, cursor: lo };
}
/**
* Interpolate member positions between frameIdx and frameIdx+1 at timeMs.
* Returns stale=true for frames marked as _longGap or _interp.
*/
export function interpolateMemberPositions(
frames: HistoryFrame[],
frameIdx: number,
timeMs: number,
): MemberPosition[] {
if (frameIdx < 0 || frameIdx >= frames.length) return [];
const frame = frames[frameIdx];
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,
});
// No next frame — return current positions as-is
if (frameIdx >= frames.length - 1) {
return frame.members.map(m => toPosition(m, m.lon, m.lat, m.cog));
}
const nextFrame = frames[frameIdx + 1];
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(nextFrame.members.map(m => [m.mmsi, m]));
return frame.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,
);
});
}
/**
* 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[],
corrByModel: Map<string, GearCorrelationItem[]>,
enabledVessels: Set<string>,
startTime: number,
): ModelCenterTrail[] {
const trackMap = new Map<string, { ts: number[]; path: [number, number][] }>();
for (const vt of corrTracks) {
if (vt.track.length < 1) continue;
trackMap.set(vt.mmsi, {
ts: vt.track.map(p => p.ts),
path: vt.track.map(p => [p.lon, p.lat]),
});
}
const results: ModelCenterTrail[] = [];
for (const [mn, items] of corrByModel) {
const enabledItems = items.filter(c => enabledVessels.has(c.targetMmsi));
if (enabledItems.length === 0) continue;
// 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);
}
// 서브클러스터별 독립 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 });
}
}
}
return results;
}