- 서브클러스터별 독립 폴리곤/센터/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>
410 lines
12 KiB
TypeScript
410 lines
12 KiB
TypeScript
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(1–2)),
|
||
* 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;
|
||
}
|