ParentReviewPanel 마운트 + 관련 상태 관리를 FleetClusterLayer에 통합. 리플레이 컨트롤러, 어구 그룹 섹션, 일치율 패널 등 11개 컴포넌트 codex Lab 환경에서 검증된 버전으로 교체. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1148 lines
46 KiB
TypeScript
1148 lines
46 KiB
TypeScript
import { useEffect, useRef, useCallback } from 'react';
|
|
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, interpolateSubFrameMembers } from '../stores/gearReplayPreprocess';
|
|
import type { MemberPosition } from '../stores/gearReplayPreprocess';
|
|
import { MODEL_ORDER, MODEL_COLORS } from '../components/korea/fleetClusterConstants';
|
|
import { getParentReviewCandidateColor } from '../components/korea/parentReviewCandidateColors';
|
|
import { buildInterpPolygon } from '../components/korea/fleetClusterUtils';
|
|
import type { GearCorrelationItem } from '../services/vesselAnalysis';
|
|
import { SHIP_ICON_MAPPING } from '../utils/shipIconSvg';
|
|
import { useFontScale } from './useFontScale';
|
|
import { useShipDeckStore } from '../stores/shipDeckStore';
|
|
import { clusterLabels } from '../utils/labelCluster';
|
|
|
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
|
|
const TRAIL_LENGTH_MS = 3_600_000; // 1 hour trail
|
|
const RENDER_INTERVAL_MS = 100; // 10fps throttle during playback
|
|
|
|
// ── Helper ───────────────────────────────────────────────────────────────────
|
|
|
|
function hexToRgb(hex: string): [number, number, number] {
|
|
const h = hex.replace('#', '');
|
|
return [
|
|
parseInt(h.substring(0, 2), 16),
|
|
parseInt(h.substring(2, 4), 16),
|
|
parseInt(h.substring(4, 6), 16),
|
|
];
|
|
}
|
|
|
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
|
|
interface CorrPosition {
|
|
mmsi: string;
|
|
name: string;
|
|
lon: number;
|
|
lat: number;
|
|
cog: number;
|
|
color: [number, number, number, number];
|
|
isVessel: boolean;
|
|
}
|
|
|
|
interface TripDatumLike {
|
|
id: string;
|
|
path: [number, number][];
|
|
timestamps: number[];
|
|
color: [number, number, number, number];
|
|
}
|
|
|
|
function interpolateTripPosition(
|
|
trip: TripDatumLike,
|
|
relTime: number,
|
|
): { lon: number; lat: number; cog: number } | null {
|
|
const ts = trip.timestamps;
|
|
const path = trip.path;
|
|
if (path.length === 0 || ts.length === 0) return null;
|
|
if (relTime < ts[0] || relTime > ts[ts.length - 1]) return null;
|
|
|
|
if (path.length === 1 || ts.length === 1) {
|
|
return { lon: path[0][0], lat: path[0][1], cog: 0 };
|
|
}
|
|
|
|
if (relTime <= ts[0]) {
|
|
const dx = path[1][0] - path[0][0];
|
|
const dy = path[1][1] - path[0][1];
|
|
return {
|
|
lon: path[0][0],
|
|
lat: path[0][1],
|
|
cog: (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360,
|
|
};
|
|
}
|
|
|
|
if (relTime >= ts[ts.length - 1]) {
|
|
const last = path.length - 1;
|
|
const dx = path[last][0] - path[last - 1][0];
|
|
const dy = path[last][1] - path[last - 1][1];
|
|
return {
|
|
lon: path[last][0],
|
|
lat: path[last][1],
|
|
cog: (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360,
|
|
};
|
|
}
|
|
|
|
let lo = 0;
|
|
let hi = ts.length - 1;
|
|
while (lo < hi - 1) {
|
|
const mid = (lo + hi) >> 1;
|
|
if (ts[mid] <= relTime) lo = mid;
|
|
else hi = mid;
|
|
}
|
|
const ratio = ts[hi] !== ts[lo] ? (relTime - ts[lo]) / (ts[hi] - ts[lo]) : 0;
|
|
const dx = path[hi][0] - path[lo][0];
|
|
const dy = path[hi][1] - path[lo][1];
|
|
return {
|
|
lon: path[lo][0] + dx * ratio,
|
|
lat: path[lo][1] + (path[hi][1] - path[lo][1]) * ratio,
|
|
cog: (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360,
|
|
};
|
|
}
|
|
|
|
function clipTripPathToTime(trip: TripDatumLike, relTime: number): [number, number][] {
|
|
const ts = trip.timestamps;
|
|
if (trip.path.length < 2 || ts.length < 2) return [];
|
|
if (relTime < ts[0]) return [];
|
|
if (relTime >= ts[ts.length - 1]) return trip.path;
|
|
|
|
let hi = ts.findIndex(value => value > relTime);
|
|
if (hi <= 0) hi = 1;
|
|
const clipped = trip.path.slice(0, hi);
|
|
const interpolated = interpolateTripPosition(trip, relTime);
|
|
if (interpolated) {
|
|
clipped.push([interpolated.lon, interpolated.lat]);
|
|
}
|
|
return clipped;
|
|
}
|
|
|
|
// ── Hook ──────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Gear group replay animation layers for deck.gl.
|
|
*
|
|
* Performance:
|
|
* - currentTime changes are subscribed via zustand.subscribe (NOT React selectors).
|
|
* React never re-renders during playback.
|
|
* - Layer objects are built imperatively and written to replayLayerRef.
|
|
* - The parent calls overlay.setProps() to push layers to WebGL.
|
|
*/
|
|
export function useGearReplayLayers(
|
|
replayLayerRef: React.MutableRefObject<Layer[]>,
|
|
requestRender: () => void,
|
|
shipsRef: React.MutableRefObject<Map<string, { lng: number; lat: number; course?: number }>>,
|
|
): void {
|
|
// ── React selectors (infrequent changes only) ────────────────────────────
|
|
const historyFrames = useGearReplayStore(s => s.historyFrames);
|
|
const memberTripsData = useGearReplayStore(s => s.memberTripsData);
|
|
const correlationTripsData = useGearReplayStore(s => s.correlationTripsData);
|
|
const centerTrailSegments = useGearReplayStore(s => s.centerTrailSegments);
|
|
const centerDotsPositions = useGearReplayStore(s => s.centerDotsPositions);
|
|
const subClusterCenters = useGearReplayStore(s => s.subClusterCenters);
|
|
const enabledModels = useGearReplayStore(s => s.enabledModels);
|
|
const enabledVessels = useGearReplayStore(s => s.enabledVessels);
|
|
const hoveredMmsi = useGearReplayStore(s => s.hoveredMmsi);
|
|
const reviewCandidates = useGearReplayStore(s => s.reviewCandidates);
|
|
const correlationByModel = useGearReplayStore(s => s.correlationByModel);
|
|
const showTrails = useGearReplayStore(s => s.showTrails);
|
|
const showLabels = useGearReplayStore(s => s.showLabels);
|
|
const show1hPolygon = useGearReplayStore(s => s.show1hPolygon);
|
|
const show6hPolygon = useGearReplayStore(s => s.show6hPolygon);
|
|
const historyFrames6h = useGearReplayStore(s => s.historyFrames6h);
|
|
const memberTripsData6h = useGearReplayStore(s => s.memberTripsData6h);
|
|
const centerTrailSegments6h = useGearReplayStore(s => s.centerTrailSegments6h);
|
|
const centerDotsPositions6h = useGearReplayStore(s => s.centerDotsPositions6h);
|
|
const subClusterCenters6h = useGearReplayStore(s => s.subClusterCenters6h);
|
|
const pinnedMmsis = useGearReplayStore(s => s.pinnedMmsis);
|
|
const { fontScale } = useFontScale();
|
|
const fs = fontScale.analysis;
|
|
const zoomLevel = useShipDeckStore(s => s.zoomLevel);
|
|
|
|
// ── Refs ─────────────────────────────────────────────────────────────────
|
|
const cursorRef = useRef(0); // frame cursor for O(1) forward lookup
|
|
|
|
// ── renderFrame ──────────────────────────────────────────────────────────
|
|
|
|
// 디버그 로그 (첫 프레임에서만 출력)
|
|
const debugLoggedRef = useRef(false);
|
|
|
|
const renderFrame = useCallback(() => {
|
|
if (historyFrames.length === 0) {
|
|
replayLayerRef.current = [];
|
|
requestRender();
|
|
return;
|
|
}
|
|
|
|
const state = useGearReplayStore.getState();
|
|
const ct = state.currentTime;
|
|
const st = state.startTime;
|
|
|
|
const shouldLog = !debugLoggedRef.current;
|
|
if (shouldLog) debugLoggedRef.current = true;
|
|
|
|
// Find current frame
|
|
const { index: frameIdx, cursor } = findFrameAtTime(state.frameTimes, ct, cursorRef.current);
|
|
cursorRef.current = cursor;
|
|
|
|
const layers: Layer[] = [];
|
|
|
|
// ── 항상 표시: 센터 트레일 ──────────────────────────────────
|
|
// 서브클러스터가 존재하면 서브클러스터별 독립 trail만 표시 (전체 trail 숨김)
|
|
const hasSubClusters = subClusterCenters.length > 0 &&
|
|
subClusterCenters.some(sc => sc.subClusterId > 0);
|
|
|
|
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
|
|
];
|
|
|
|
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,
|
|
}));
|
|
}
|
|
}
|
|
|
|
// ── 6h 센터 트레일 (정적, frameIdx와 무관) ───────────────────────────
|
|
if (state.show6hPolygon) {
|
|
const hasSub6h = subClusterCenters6h.length > 0 && subClusterCenters6h.some(sc => sc.subClusterId > 0);
|
|
if (hasSub6h) {
|
|
for (const sc of subClusterCenters6h) {
|
|
if (sc.subClusterId === 0) continue;
|
|
if (sc.path.length < 2) continue;
|
|
layers.push(new PathLayer({
|
|
id: `replay-6h-sub-center-${sc.subClusterId}`,
|
|
data: [{ path: sc.path }],
|
|
getPath: (d: { path: [number, number][] }) => d.path,
|
|
getColor: [147, 197, 253, 120] as [number, number, number, number],
|
|
widthMinPixels: 1.5,
|
|
}));
|
|
}
|
|
} else {
|
|
for (let i = 0; i < centerTrailSegments6h.length; i++) {
|
|
const seg = centerTrailSegments6h[i];
|
|
if (seg.path.length < 2) continue;
|
|
layers.push(new PathLayer({
|
|
id: `replay-6h-center-trail-${i}`,
|
|
data: [{ path: seg.path }],
|
|
getPath: (d: { path: [number, number][] }) => d.path,
|
|
getColor: [147, 197, 253, seg.isInterpolated ? 80 : 120] as [number, number, number, number],
|
|
widthMinPixels: 1.5,
|
|
}));
|
|
}
|
|
if (centerDotsPositions6h.length > 0) {
|
|
layers.push(new ScatterplotLayer({
|
|
id: 'replay-6h-center-dots',
|
|
data: centerDotsPositions6h,
|
|
getPosition: (d: [number, number]) => d,
|
|
getFillColor: [147, 197, 253, 120] as [number, number, number, number],
|
|
getRadius: 80,
|
|
radiusUnits: 'meters',
|
|
radiusMinPixels: 2,
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Dynamic layers (depend on currentTime) ────────────────────────────
|
|
|
|
if (frameIdx >= 0) {
|
|
|
|
const frame = state.historyFrames[frameIdx];
|
|
const isStale = !!frame._longGap || !!frame._interp;
|
|
|
|
// Member positions (interpolated) — 항상 계산 (배지/오퍼레이셔널에서 사용)
|
|
const members = interpolateMemberPositions(state.historyFrames, frameIdx, ct);
|
|
const memberPts: [number, number][] = members.map(m => [m.lon, m.lat]);
|
|
const relTime = ct - st;
|
|
const visibleMemberMmsis = new Set(members.map(m => m.mmsi));
|
|
const reviewCandidateMap = new Map(reviewCandidates.map(candidate => [candidate.mmsi, candidate]));
|
|
const reviewCandidateSet = new Set(reviewCandidateMap.keys());
|
|
const corrTrackMap = new Map(correlationTripsData.map(d => [d.id, d]));
|
|
|
|
// 서브클러스터 프레임 (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)
|
|
if (memberTripsData.length > 0) {
|
|
for (const trip of memberTripsData) {
|
|
if (!visibleMemberMmsis.has(trip.id) || trip.path.length < 2) continue;
|
|
layers.push(new PathLayer({
|
|
id: `replay-member-path-${trip.id}`,
|
|
data: [{ path: trip.path }],
|
|
getPath: (d: { path: [number, number][] }) => d.path,
|
|
getColor: [180, 180, 180, 80], // 낮은 채도 — TripsLayer보다 연하게
|
|
widthMinPixels: 1,
|
|
}));
|
|
}
|
|
}
|
|
|
|
// 연관 선박 전체 항적 (correlation)
|
|
if (correlationTripsData.length > 0) {
|
|
const activeMmsis = new Set<string>();
|
|
for (const [mn, items] of correlationByModel) {
|
|
if (!enabledModels.has(mn)) continue;
|
|
for (const c of items as GearCorrelationItem[]) {
|
|
if (enabledVessels.has(c.targetMmsi)) activeMmsis.add(c.targetMmsi);
|
|
}
|
|
}
|
|
for (const trip of correlationTripsData) {
|
|
if (!activeMmsis.has(trip.id) || reviewCandidateSet.has(trip.id) || trip.path.length < 2) continue;
|
|
layers.push(new PathLayer({
|
|
id: `replay-corr-path-${trip.id}`,
|
|
data: [{ path: trip.path }],
|
|
getPath: (d: { path: [number, number][] }) => d.path,
|
|
getColor: [100, 140, 200, 60], // 연한 파랑
|
|
widthMinPixels: 1,
|
|
}));
|
|
}
|
|
}
|
|
|
|
if (reviewCandidates.length > 0) {
|
|
for (const candidate of reviewCandidates) {
|
|
const trip = corrTrackMap.get(candidate.mmsi);
|
|
if (!trip || trip.path.length < 2) continue;
|
|
const [r, g, b] = hexToRgb(getParentReviewCandidateColor(candidate.rank));
|
|
const hovered = hoveredMmsi === candidate.mmsi;
|
|
layers.push(new PathLayer({
|
|
id: `replay-review-path-glow-${candidate.mmsi}`,
|
|
data: [{ path: trip.path }],
|
|
getPath: (d: { path: [number, number][] }) => d.path,
|
|
getColor: hovered ? [255, 255, 255, 110] : [255, 255, 255, 45],
|
|
widthMinPixels: hovered ? 7 : 4,
|
|
}));
|
|
layers.push(new PathLayer({
|
|
id: `replay-review-path-${candidate.mmsi}`,
|
|
data: [{ path: trip.path }],
|
|
getPath: (d: { path: [number, number][] }) => d.path,
|
|
getColor: [r, g, b, hovered ? 230 : 160],
|
|
widthMinPixels: hovered ? 4.5 : 2.5,
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
// (identity 레이어는 최하단 — 최상위 z-index로 이동됨)
|
|
|
|
// 3. Member position markers (IconLayer, identity — 항상 ON, placeholder)
|
|
if (members.length > 0) {
|
|
layers.push(new IconLayer<MemberPosition>({
|
|
id: 'replay-members',
|
|
data: members,
|
|
getPosition: d => [d.lon, d.lat],
|
|
getIcon: d => d.isGear ? SHIP_ICON_MAPPING['gear-diamond'] : SHIP_ICON_MAPPING['ship-triangle'],
|
|
getSize: d => d.isParent ? 24 : d.isGear ? 14 : 18,
|
|
getAngle: d => d.isGear ? 0 : -(d.cog || 0),
|
|
getColor: d => {
|
|
if (d.stale) return [100, 116, 139, 180];
|
|
if (d.isGear) return [168, 184, 200, 230];
|
|
return [251, 191, 36, 230];
|
|
},
|
|
sizeUnits: 'pixels',
|
|
billboard: false,
|
|
}));
|
|
|
|
// Member labels — showLabels 제어 + 줌 레벨별 클러스터
|
|
if (showLabels) {
|
|
const clusteredMembers = clusterLabels(members, d => [d.lon, d.lat], zoomLevel);
|
|
layers.push(new TextLayer<MemberPosition>({
|
|
id: 'replay-member-labels',
|
|
data: clusteredMembers,
|
|
getPosition: d => [d.lon, d.lat],
|
|
getText: d => {
|
|
const prefix = d.isParent ? '\u2605 ' : '';
|
|
return prefix + (d.name || d.mmsi);
|
|
},
|
|
getColor: d => d.stale
|
|
? [148, 163, 184, 200]
|
|
: d.isGear
|
|
? [226, 232, 240, 255]
|
|
: [251, 191, 36, 255],
|
|
getSize: 10 * fs,
|
|
getPixelOffset: [0, 14],
|
|
background: true,
|
|
getBackgroundColor: [0, 0, 0, 200],
|
|
backgroundPadding: [2, 1],
|
|
fontFamily: '"Fira Code Variable", monospace',
|
|
}));
|
|
}
|
|
}
|
|
|
|
// 6. Correlation vessel positions (현재 리플레이 시점에 실제로 보이는 대상만)
|
|
const corrPositions: CorrPosition[] = [];
|
|
const reviewPositions: CorrPosition[] = [];
|
|
void shipsRef;
|
|
|
|
for (const [mn, items] of correlationByModel) {
|
|
if (!enabledModels.has(mn)) continue;
|
|
const color = MODEL_COLORS[mn] ?? '#94a3b8';
|
|
const [r, g, b] = hexToRgb(color);
|
|
|
|
for (const c of items as GearCorrelationItem[]) {
|
|
if (!enabledVessels.has(c.targetMmsi)) continue; // OFF → 아이콘+트레일+폴리곤 모두 제외
|
|
if (reviewCandidateSet.has(c.targetMmsi)) continue;
|
|
if (corrPositions.some(p => p.mmsi === c.targetMmsi)) continue;
|
|
|
|
const tripData = corrTrackMap.get(c.targetMmsi);
|
|
const position = tripData ? interpolateTripPosition(tripData, relTime) : null;
|
|
if (!position) continue;
|
|
|
|
corrPositions.push({
|
|
mmsi: c.targetMmsi,
|
|
name: c.targetName || c.targetMmsi,
|
|
lon: position.lon,
|
|
lat: position.lat,
|
|
cog: position.cog,
|
|
color: [r, g, b, 230],
|
|
isVessel: c.targetType === 'VESSEL',
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const candidate of reviewCandidates) {
|
|
const tripData = corrTrackMap.get(candidate.mmsi);
|
|
const position = tripData ? interpolateTripPosition(tripData, relTime) : null;
|
|
if (!position) continue;
|
|
const [r, g, b] = hexToRgb(getParentReviewCandidateColor(candidate.rank));
|
|
reviewPositions.push({
|
|
mmsi: candidate.mmsi,
|
|
name: candidate.name,
|
|
lon: position.lon,
|
|
lat: position.lat,
|
|
cog: position.cog,
|
|
color: [r, g, b, hoveredMmsi === candidate.mmsi ? 255 : 235],
|
|
isVessel: true,
|
|
});
|
|
}
|
|
|
|
// 디버그: 첫 프레임에서 전체 상태 출력
|
|
if (shouldLog) {
|
|
const trackHit = corrPositions.filter(p => corrTrackMap.has(p.mmsi)).length;
|
|
const sampleTrip = memberTripsData[0];
|
|
console.log('[GearReplay] renderFrame:', {
|
|
historyFrames: state.historyFrames.length,
|
|
memberTripsData: memberTripsData.length,
|
|
corrTripsData: correlationTripsData.length,
|
|
corrTrackMap: corrTrackMap.size,
|
|
showTrails, showLabels,
|
|
relTime: Math.round(relTime / 60000) + 'min',
|
|
currentTime: Math.round((ct - st) / 60000) + 'min (rel)',
|
|
members: members.length,
|
|
corrPositions: corrPositions.length,
|
|
reviewPositions: reviewPositions.length,
|
|
posSource: `track:${trackHit}`,
|
|
memberTrip0: sampleTrip ? { id: sampleTrip.id, pts: sampleTrip.path.length, tsRange: `${Math.round(sampleTrip.timestamps[0]/60000)}~${Math.round(sampleTrip.timestamps[sampleTrip.timestamps.length-1]/60000)}min` } : 'none',
|
|
});
|
|
// 모델별 상세
|
|
for (const [mn, items] of state.correlationByModel) {
|
|
const modEnabled = enabledModels.has(mn);
|
|
const modPositions = corrPositions.filter(p => {
|
|
return items.some(c => c.targetMmsi === p.mmsi);
|
|
}).length;
|
|
console.log(` [${mn}] ${modEnabled ? 'ON' : 'OFF'} ${items.length}건 → 위치확인 ${modPositions}`);
|
|
}
|
|
}
|
|
|
|
const visibleCorrMmsis = new Set(corrPositions.map(position => position.mmsi));
|
|
const visibleReviewMmsis = new Set(reviewPositions.map(position => position.mmsi));
|
|
const visibleMemberTrips = memberTripsData.filter(d => visibleMemberMmsis.has(d.id));
|
|
const enabledCorrTrips = correlationTripsData.filter(d => visibleCorrMmsis.has(d.id) && !reviewCandidateSet.has(d.id));
|
|
const reviewVisibleTrips = correlationTripsData
|
|
.filter(d => visibleReviewMmsis.has(d.id))
|
|
.map(d => {
|
|
const candidate = reviewCandidateMap.get(d.id);
|
|
const [r, g, b] = hexToRgb(getParentReviewCandidateColor(candidate?.rank ?? 1));
|
|
return { ...d, color: [r, g, b, hoveredMmsi === d.id ? 255 : 230] as [number, number, number, number] };
|
|
});
|
|
const hoveredReviewTrips = reviewVisibleTrips.filter(d => d.id === hoveredMmsi);
|
|
const defaultReviewTrips = reviewVisibleTrips.filter(d => d.id !== hoveredMmsi);
|
|
|
|
if (enabledCorrTrips.length > 0) {
|
|
layers.push(new TripsLayer({
|
|
id: 'replay-corr-trails',
|
|
data: enabledCorrTrips,
|
|
getPath: d => d.path,
|
|
getTimestamps: d => d.timestamps,
|
|
getColor: [100, 180, 255, 220],
|
|
widthMinPixels: 2.5,
|
|
fadeTrail: true,
|
|
trailLength: TRAIL_LENGTH_MS,
|
|
currentTime: ct - st,
|
|
}));
|
|
}
|
|
|
|
if (defaultReviewTrips.length > 0) {
|
|
layers.push(new TripsLayer({
|
|
id: 'replay-review-trails',
|
|
data: defaultReviewTrips,
|
|
getPath: d => d.path,
|
|
getTimestamps: d => d.timestamps,
|
|
getColor: d => d.color,
|
|
widthMinPixels: 4,
|
|
fadeTrail: true,
|
|
trailLength: TRAIL_LENGTH_MS,
|
|
currentTime: ct - st,
|
|
}));
|
|
}
|
|
|
|
if (hoveredReviewTrips.length > 0) {
|
|
layers.push(new TripsLayer({
|
|
id: 'replay-review-hover-trails-glow',
|
|
data: hoveredReviewTrips,
|
|
getPath: d => d.path,
|
|
getTimestamps: d => d.timestamps,
|
|
getColor: [255, 255, 255, 190],
|
|
widthMinPixels: 7,
|
|
fadeTrail: true,
|
|
trailLength: TRAIL_LENGTH_MS,
|
|
currentTime: ct - st,
|
|
}));
|
|
layers.push(new TripsLayer({
|
|
id: 'replay-review-hover-trails',
|
|
data: hoveredReviewTrips,
|
|
getPath: d => d.path,
|
|
getTimestamps: d => d.timestamps,
|
|
getColor: d => d.color,
|
|
widthMinPixels: 5,
|
|
fadeTrail: true,
|
|
trailLength: TRAIL_LENGTH_MS,
|
|
currentTime: ct - st,
|
|
}));
|
|
}
|
|
|
|
if (corrPositions.length > 0) {
|
|
layers.push(new IconLayer<CorrPosition>({
|
|
id: 'replay-corr-vessels',
|
|
data: corrPositions,
|
|
getPosition: d => [d.lon, d.lat],
|
|
getIcon: d => d.isVessel ? SHIP_ICON_MAPPING['ship-triangle'] : SHIP_ICON_MAPPING['gear-diamond'],
|
|
getSize: d => d.isVessel ? 18 : 12,
|
|
getAngle: d => d.isVessel ? -(d.cog || 0) : 0,
|
|
getColor: d => d.color,
|
|
sizeUnits: 'pixels',
|
|
billboard: false,
|
|
}));
|
|
|
|
if (showLabels) {
|
|
const clusteredCorr = clusterLabels(corrPositions, d => [d.lon, d.lat], zoomLevel);
|
|
layers.push(new TextLayer<CorrPosition>({
|
|
id: 'replay-corr-labels',
|
|
data: clusteredCorr,
|
|
getPosition: d => [d.lon, d.lat],
|
|
getText: d => d.name,
|
|
getColor: d => d.color,
|
|
getSize: 8 * fs,
|
|
getPixelOffset: [0, 15],
|
|
background: true,
|
|
getBackgroundColor: [0, 0, 0, 200],
|
|
backgroundPadding: [2, 1],
|
|
}));
|
|
}
|
|
}
|
|
|
|
if (reviewPositions.length > 0) {
|
|
layers.push(new ScatterplotLayer({
|
|
id: 'replay-review-vessel-glow',
|
|
data: reviewPositions,
|
|
getPosition: d => [d.lon, d.lat],
|
|
getFillColor: d => {
|
|
const alpha = hoveredMmsi === d.mmsi ? 90 : 40;
|
|
return [255, 255, 255, alpha] as [number, number, number, number];
|
|
},
|
|
getRadius: d => hoveredMmsi === d.mmsi ? 420 : 260,
|
|
radiusUnits: 'meters',
|
|
radiusMinPixels: 10,
|
|
}));
|
|
|
|
layers.push(new IconLayer<CorrPosition>({
|
|
id: 'replay-review-vessels',
|
|
data: reviewPositions,
|
|
getPosition: d => [d.lon, d.lat],
|
|
getIcon: () => SHIP_ICON_MAPPING['ship-triangle'],
|
|
getSize: d => hoveredMmsi === d.mmsi ? 24 : 20,
|
|
getAngle: d => -(d.cog || 0),
|
|
getColor: d => d.color,
|
|
sizeUnits: 'pixels',
|
|
billboard: false,
|
|
}));
|
|
|
|
if (showLabels) {
|
|
const clusteredReview = clusterLabels(reviewPositions, d => [d.lon, d.lat], zoomLevel);
|
|
layers.push(new TextLayer<CorrPosition>({
|
|
id: 'replay-review-labels',
|
|
data: clusteredReview,
|
|
getPosition: d => [d.lon, d.lat],
|
|
getText: d => {
|
|
const candidate = reviewCandidateMap.get(d.mmsi);
|
|
return candidate ? `#${candidate.rank} ${d.name}` : d.name;
|
|
},
|
|
getColor: d => d.color,
|
|
getSize: d => hoveredMmsi === d.mmsi ? 10 * fs : 9 * fs,
|
|
getPixelOffset: [0, 17],
|
|
background: true,
|
|
getBackgroundColor: [0, 0, 0, 215],
|
|
backgroundPadding: [3, 2],
|
|
}));
|
|
}
|
|
}
|
|
|
|
// 7. Hover highlight
|
|
if (hoveredMmsi) {
|
|
const hoveredMember = members.find(m => m.mmsi === hoveredMmsi);
|
|
const hoveredCorr = corrPositions.find(c => c.mmsi === hoveredMmsi)
|
|
?? reviewPositions.find(c => c.mmsi === hoveredMmsi);
|
|
const hoveredPos: [number, number] | null = hoveredMember
|
|
? [hoveredMember.lon, hoveredMember.lat]
|
|
: hoveredCorr
|
|
? [hoveredCorr.lon, hoveredCorr.lat]
|
|
: null;
|
|
|
|
if (hoveredPos) {
|
|
layers.push(new ScatterplotLayer({
|
|
id: 'replay-hover-glow',
|
|
data: [{ position: hoveredPos }],
|
|
getPosition: (d: { position: [number, number] }) => d.position,
|
|
getFillColor: [255, 255, 255, 60],
|
|
getRadius: 400,
|
|
radiusUnits: 'meters',
|
|
radiusMinPixels: 14,
|
|
}));
|
|
layers.push(new ScatterplotLayer({
|
|
id: 'replay-hover-ring',
|
|
data: [{ position: hoveredPos }],
|
|
getPosition: (d: { position: [number, number] }) => d.position,
|
|
getFillColor: [0, 0, 0, 0],
|
|
getRadius: 250,
|
|
radiusUnits: 'meters',
|
|
radiusMinPixels: 8,
|
|
stroked: true,
|
|
getLineColor: [255, 255, 255, 255],
|
|
lineWidthMinPixels: 2,
|
|
}));
|
|
}
|
|
|
|
// Hover trail (from correlation track)
|
|
const hoveredTrack = correlationTripsData.find(d => d.id === hoveredMmsi);
|
|
if (hoveredTrack && !reviewCandidateSet.has(hoveredMmsi) && (visibleCorrMmsis.has(hoveredMmsi) || visibleMemberMmsis.has(hoveredMmsi))) {
|
|
const clippedPath = clipTripPathToTime(hoveredTrack, relTime);
|
|
if (clippedPath.length >= 2) {
|
|
layers.push(new PathLayer({
|
|
id: 'replay-hover-trail',
|
|
data: [{ path: clippedPath }],
|
|
getPath: (d: { path: [number, number][] }) => d.path,
|
|
getColor: [255, 255, 255, 180],
|
|
widthMinPixels: 3,
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
// 7b. Pinned highlight (툴팁 고정 시 해당 MMSI 강조)
|
|
if (state.pinnedMmsis.size > 0) {
|
|
const pinnedPositions: { position: [number, number] }[] = [];
|
|
for (const m of members) {
|
|
if (state.pinnedMmsis.has(m.mmsi)) pinnedPositions.push({ position: [m.lon, m.lat] });
|
|
}
|
|
for (const c of corrPositions) {
|
|
if (state.pinnedMmsis.has(c.mmsi)) pinnedPositions.push({ position: [c.lon, c.lat] });
|
|
}
|
|
for (const c of reviewPositions) {
|
|
if (state.pinnedMmsis.has(c.mmsi)) pinnedPositions.push({ position: [c.lon, c.lat] });
|
|
}
|
|
if (pinnedPositions.length > 0) {
|
|
// glow
|
|
layers.push(new ScatterplotLayer({
|
|
id: 'replay-pinned-glow',
|
|
data: pinnedPositions,
|
|
getPosition: (d: { position: [number, number] }) => d.position,
|
|
getFillColor: [255, 255, 255, 40],
|
|
getRadius: 350,
|
|
radiusUnits: 'meters',
|
|
radiusMinPixels: 12,
|
|
}));
|
|
// ring
|
|
layers.push(new ScatterplotLayer({
|
|
id: 'replay-pinned-ring',
|
|
data: pinnedPositions,
|
|
getPosition: (d: { position: [number, number] }) => d.position,
|
|
getFillColor: [0, 0, 0, 0],
|
|
getRadius: 200,
|
|
radiusUnits: 'meters',
|
|
radiusMinPixels: 6,
|
|
stroked: true,
|
|
getLineColor: [255, 255, 255, 200],
|
|
lineWidthMinPixels: 1.5,
|
|
}));
|
|
}
|
|
|
|
// pinned trails (correlation tracks)
|
|
const relTime = ct - st;
|
|
for (const trip of correlationTripsData) {
|
|
if (!state.pinnedMmsis.has(trip.id) || !visibleCorrMmsis.has(trip.id)) continue;
|
|
const clippedPath = clipTripPathToTime(trip, relTime);
|
|
if (clippedPath.length >= 2) {
|
|
layers.push(new PathLayer({
|
|
id: `replay-pinned-trail-${trip.id}`,
|
|
data: [{ path: clippedPath }],
|
|
getPath: (d: { path: [number, number][] }) => d.path,
|
|
getColor: [255, 255, 255, 150],
|
|
widthMinPixels: 2.5,
|
|
}));
|
|
}
|
|
}
|
|
|
|
for (const trip of reviewVisibleTrips) {
|
|
if (!state.pinnedMmsis.has(trip.id)) continue;
|
|
const clippedPath = clipTripPathToTime(trip, relTime);
|
|
if (clippedPath.length >= 2) {
|
|
layers.push(new PathLayer({
|
|
id: `replay-pinned-review-trail-${trip.id}`,
|
|
data: [{ path: clippedPath }],
|
|
getPath: (d: { path: [number, number][] }) => d.path,
|
|
getColor: trip.color,
|
|
widthMinPixels: 3.5,
|
|
}));
|
|
}
|
|
}
|
|
|
|
// pinned member trails (identity tracks)
|
|
for (const trip of memberTripsData) {
|
|
if (!state.pinnedMmsis.has(trip.id) || !visibleMemberMmsis.has(trip.id)) continue;
|
|
const clippedPath = clipTripPathToTime(trip, relTime);
|
|
if (clippedPath.length >= 2) {
|
|
layers.push(new PathLayer({
|
|
id: `replay-pinned-mtrail-${trip.id}`,
|
|
data: [{ path: clippedPath }],
|
|
getPath: (d: { path: [number, number][] }) => d.path,
|
|
getColor: [255, 200, 60, 180],
|
|
widthMinPixels: 2.5,
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
|
|
// 연관 선박을 subClusterId로 그룹핑
|
|
const subExtras = new Map<number, [number, number][]>();
|
|
for (const c of items as GearCorrelationItem[]) {
|
|
if (!enabledVessels.has(c.targetMmsi)) continue;
|
|
const cp = corrPositions.find(p => p.mmsi === c.targetMmsi);
|
|
if (!cp) continue;
|
|
const sid = c.subClusterId ?? 0;
|
|
const list = subExtras.get(sid) ?? [];
|
|
list.push([cp.lon, cp.lat]);
|
|
subExtras.set(sid, list);
|
|
}
|
|
|
|
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,
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
// 9. Model badges (small colored dots next to each vessel/gear per model)
|
|
{
|
|
const badgeTargets = new Map<string, { lon: number; lat: number; models: Set<string> }>();
|
|
|
|
// Identity model: group members
|
|
// Identity — 항상 ON
|
|
for (const m of members) {
|
|
const e = badgeTargets.get(m.mmsi) ?? { lon: m.lon, lat: m.lat, models: new Set<string>() };
|
|
e.lon = m.lon; e.lat = m.lat; e.models.add('identity');
|
|
badgeTargets.set(m.mmsi, e);
|
|
}
|
|
|
|
// Correlation models
|
|
for (const [mn, items] of correlationByModel) {
|
|
if (!enabledModels.has(mn)) continue;
|
|
for (const c of items as GearCorrelationItem[]) {
|
|
if (c.score < 0.3) continue;
|
|
const cp = corrPositions.find(p => p.mmsi === c.targetMmsi);
|
|
if (!cp) continue;
|
|
const e = badgeTargets.get(c.targetMmsi) ?? { lon: cp.lon, lat: cp.lat, models: new Set<string>() };
|
|
e.lon = cp.lon; e.lat = cp.lat; e.models.add(mn);
|
|
badgeTargets.set(c.targetMmsi, e);
|
|
}
|
|
}
|
|
|
|
// Render one ScatterplotLayer per model (offset by index)
|
|
for (let mi = 0; mi < MODEL_ORDER.length; mi++) {
|
|
const model = MODEL_ORDER[mi];
|
|
if (!enabledModels.has(model)) continue;
|
|
const color = MODEL_COLORS[model] ?? '#94a3b8';
|
|
const [r, g, b] = hexToRgb(color);
|
|
const badgeData: { position: [number, number] }[] = [];
|
|
for (const [, t] of badgeTargets) {
|
|
if (t.models.has(model)) badgeData.push({ position: [t.lon, t.lat] });
|
|
}
|
|
if (badgeData.length === 0) continue;
|
|
layers.push(new ScatterplotLayer({
|
|
id: `replay-badge-${model}`,
|
|
data: badgeData,
|
|
getPosition: (d: { position: [number, number] }) => d.position,
|
|
getFillColor: [r, g, b, 255],
|
|
getRadius: 3,
|
|
radiusUnits: 'pixels',
|
|
stroked: true,
|
|
getLineColor: [0, 0, 0, 150],
|
|
lineWidthMinPixels: 0.5,
|
|
// Offset each model's badges to the right
|
|
getPixelOffset: [10 + mi * 7, -6] as [number, number],
|
|
}));
|
|
}
|
|
}
|
|
|
|
// ══ 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],
|
|
];
|
|
|
|
// ── 1h 폴리곤 (진한색, 실선) ──
|
|
if (state.show1hPolygon) {
|
|
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-1h-sub${sf.subClusterId}`,
|
|
data: [{ polygon: poly.coordinates }],
|
|
getPolygon: (d: { polygon: number[][][] }) => d.polygon,
|
|
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 (visibleMemberTrips.length > 0) {
|
|
layers.push(new TripsLayer({
|
|
id: 'replay-identity-trails',
|
|
data: visibleMemberTrips,
|
|
getPath: d => d.path,
|
|
getTimestamps: d => d.timestamps,
|
|
getColor: [255, 200, 60, 220],
|
|
widthMinPixels: 2,
|
|
fadeTrail: true,
|
|
trailLength: TRAIL_LENGTH_MS,
|
|
currentTime: ct - st,
|
|
}));
|
|
}
|
|
|
|
// 센터 포인트 (서브클러스터별 독립)
|
|
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,
|
|
}));
|
|
}
|
|
|
|
} // end if (frameIdx >= 0)
|
|
|
|
// ══ 6h Identity 레이어 (독립 — 1h/모델과 무관) ══
|
|
if (state.show6hPolygon && state.historyFrames6h.length > 0) {
|
|
const { index: frameIdx6h } = findFrameAtTime(state.frameTimes6h, ct, 0);
|
|
if (frameIdx6h >= 0) {
|
|
const frame6h = state.historyFrames6h[frameIdx6h];
|
|
const subFrames6h = frame6h.subFrames ?? [{ subClusterId: 0, centerLon: frame6h.centerLon, centerLat: frame6h.centerLat, members: frame6h.members, memberCount: frame6h.memberCount }];
|
|
const members6h = interpolateMemberPositions(state.historyFrames6h, frameIdx6h, ct);
|
|
const visibleMemberMmsis6h = new Set(members6h.map(member => member.mmsi));
|
|
const visibleMemberTrips6h = memberTripsData6h.filter(trip => visibleMemberMmsis6h.has(trip.id));
|
|
|
|
// 6h 폴리곤
|
|
for (const sf of subFrames6h) {
|
|
const sfMembers = interpolateSubFrameMembers(state.historyFrames6h, frameIdx6h, ct, sf.subClusterId);
|
|
const sfPts: [number, number][] = sfMembers.map(m => [m.lon, m.lat]);
|
|
const poly = buildInterpPolygon(sfPts);
|
|
if (!poly) continue;
|
|
layers.push(new PolygonLayer({
|
|
id: `replay-6h-polygon-sub${sf.subClusterId}`,
|
|
data: [{ polygon: poly.coordinates }],
|
|
getPolygon: (d: { polygon: number[][][] }) => d.polygon,
|
|
getFillColor: [147, 197, 253, 25] as [number, number, number, number],
|
|
getLineColor: [147, 197, 253, 160] as [number, number, number, number],
|
|
getLineWidth: 1,
|
|
lineWidthMinPixels: 1,
|
|
filled: true,
|
|
stroked: true,
|
|
}));
|
|
}
|
|
|
|
// 6h 멤버 아이콘
|
|
if (members6h.length > 0) {
|
|
layers.push(new IconLayer<MemberPosition>({
|
|
id: 'replay-6h-members',
|
|
data: members6h,
|
|
getPosition: d => [d.lon, d.lat],
|
|
getIcon: d => d.isGear ? SHIP_ICON_MAPPING['gear-diamond'] : SHIP_ICON_MAPPING['ship-triangle'],
|
|
getSize: d => d.isParent ? 24 : d.isGear ? 14 : 18,
|
|
getAngle: d => d.isGear ? 0 : -(d.cog || 0),
|
|
getColor: d => {
|
|
if (d.stale) return [100, 116, 139, 150];
|
|
return [147, 197, 253, 200];
|
|
},
|
|
sizeUnits: 'pixels',
|
|
billboard: false,
|
|
}));
|
|
|
|
// 6h 멤버 라벨
|
|
if (showLabels) {
|
|
const clustered6h = clusterLabels(members6h, d => [d.lon, d.lat], zoomLevel);
|
|
layers.push(new TextLayer<MemberPosition>({
|
|
id: 'replay-6h-member-labels',
|
|
data: clustered6h,
|
|
getPosition: d => [d.lon, d.lat],
|
|
getText: d => {
|
|
const prefix = d.isParent ? '\u2605 ' : '';
|
|
return prefix + (d.name || d.mmsi);
|
|
},
|
|
getColor: [147, 197, 253, 230] as [number, number, number, number],
|
|
getSize: 10 * fs,
|
|
getPixelOffset: [0, 14],
|
|
background: true,
|
|
getBackgroundColor: [0, 0, 0, 200] as [number, number, number, number],
|
|
backgroundPadding: [2, 1],
|
|
fontFamily: '"Fira Code Variable", monospace',
|
|
}));
|
|
}
|
|
}
|
|
|
|
// 6h TripsLayer (항적 애니메이션)
|
|
if (visibleMemberTrips6h.length > 0) {
|
|
layers.push(new TripsLayer({
|
|
id: 'replay-6h-identity-trails',
|
|
data: visibleMemberTrips6h,
|
|
getPath: d => d.path,
|
|
getTimestamps: d => d.timestamps,
|
|
getColor: [147, 197, 253, 180] as [number, number, number, number],
|
|
widthMinPixels: 2,
|
|
fadeTrail: true,
|
|
trailLength: TRAIL_LENGTH_MS,
|
|
currentTime: ct - st,
|
|
}));
|
|
}
|
|
|
|
// 6h 센터 포인트 (서브클러스터별 보간)
|
|
for (const sf of subFrames6h) {
|
|
const nextFrame6h = frameIdx6h < state.historyFrames6h.length - 1 ? state.historyFrames6h[frameIdx6h + 1] : null;
|
|
const nextSf = nextFrame6h?.subFrames?.find(s => s.subClusterId === sf.subClusterId);
|
|
let cx = sf.centerLon, cy = sf.centerLat;
|
|
if (nextSf && nextFrame6h) {
|
|
const t0 = new Date(frame6h.snapshotTime).getTime();
|
|
const t1 = new Date(nextFrame6h.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;
|
|
}
|
|
layers.push(new ScatterplotLayer({
|
|
id: `replay-6h-center-sub${sf.subClusterId}`,
|
|
data: [{ position: [cx, cy] as [number, number] }],
|
|
getPosition: (d: { position: [number, number] }) => d.position,
|
|
getFillColor: [147, 197, 253, 200] as [number, number, number, number],
|
|
getRadius: 150,
|
|
radiusUnits: 'meters',
|
|
radiusMinPixels: 5,
|
|
stroked: true,
|
|
getLineColor: [255, 255, 255, 200] as [number, number, number, number],
|
|
lineWidthMinPixels: 1.5,
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
replayLayerRef.current = layers;
|
|
requestRender();
|
|
}, [
|
|
historyFrames, historyFrames6h, memberTripsData, memberTripsData6h, correlationTripsData,
|
|
centerTrailSegments, centerDotsPositions,
|
|
centerTrailSegments6h, centerDotsPositions6h, subClusterCenters6h,
|
|
enabledModels, enabledVessels, hoveredMmsi, correlationByModel,
|
|
reviewCandidates, subClusterCenters, showTrails, showLabels,
|
|
show1hPolygon, show6hPolygon, pinnedMmsis, fs, zoomLevel,
|
|
replayLayerRef, requestRender,
|
|
]);
|
|
|
|
// 데이터/필터 변경 시 디버그 로그 리셋
|
|
useEffect(() => {
|
|
debugLoggedRef.current = false;
|
|
if (correlationByModel.size > 0) {
|
|
console.log('[GearReplay] 데이터 갱신:', {
|
|
models: [...correlationByModel.keys()],
|
|
enabledModels: [...enabledModels],
|
|
corrTrips: correlationTripsData.length,
|
|
});
|
|
}
|
|
}, [correlationByModel, enabledModels, correlationTripsData]);
|
|
|
|
// ── zustand.subscribe effect (currentTime → renderFrame) ─────────────────
|
|
|
|
useEffect(() => {
|
|
if (historyFrames.length === 0) {
|
|
// Reset 시 레이어 클리어
|
|
replayLayerRef.current = [];
|
|
requestRender();
|
|
return;
|
|
}
|
|
|
|
// Initial render
|
|
renderFrame();
|
|
|
|
let lastRenderTime = 0;
|
|
let pendingRafId: number | null = null;
|
|
|
|
const unsub = useGearReplayStore.subscribe(
|
|
s => s.currentTime,
|
|
() => {
|
|
const isPlaying = useGearReplayStore.getState().isPlaying;
|
|
if (!isPlaying) {
|
|
// Seek/pause — immediate render for responsiveness
|
|
renderFrame();
|
|
return;
|
|
}
|
|
const now = performance.now();
|
|
if (now - lastRenderTime >= RENDER_INTERVAL_MS) {
|
|
lastRenderTime = now;
|
|
renderFrame();
|
|
} else if (!pendingRafId) {
|
|
pendingRafId = requestAnimationFrame(() => {
|
|
pendingRafId = null;
|
|
lastRenderTime = performance.now();
|
|
renderFrame();
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
// 1h/6h 토글 + pinnedMmsis 변경 시 즉시 렌더
|
|
const unsubPolygonToggle = useGearReplayStore.subscribe(
|
|
s => [s.show1hPolygon, s.show6hPolygon] as const,
|
|
() => { debugLoggedRef.current = false; renderFrame(); },
|
|
);
|
|
const unsubPinned = useGearReplayStore.subscribe(
|
|
s => s.pinnedMmsis,
|
|
() => renderFrame(),
|
|
);
|
|
|
|
return () => {
|
|
unsub();
|
|
unsubPolygonToggle();
|
|
unsubPinned();
|
|
if (pendingRafId) cancelAnimationFrame(pendingRafId);
|
|
};
|
|
}, [historyFrames, renderFrame]);
|
|
|
|
// ── Cleanup on unmount ────────────────────────────────────────────────────
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
replayLayerRef.current = [];
|
|
requestRender();
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: run only on unmount
|
|
}, []);
|
|
}
|