kcg-monitoring/frontend/src/hooks/useGearReplayLayers.ts
htlee 8362bc5b6c feat: 어구 모선 추론 UI 통합 — FleetClusterLayer + 리플레이 컴포넌트 이식
ParentReviewPanel 마운트 + 관련 상태 관리를 FleetClusterLayer에 통합.
리플레이 컨트롤러, 어구 그룹 섹션, 일치율 패널 등 11개 컴포넌트
codex Lab 환경에서 검증된 버전으로 교체.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 00:48:48 +09:00

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
}, []);
}