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 { 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; } // ── 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, requestRender: () => void, shipsRef: React.MutableRefObject>, ): 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 correlationByModel = useGearReplayStore(s => s.correlationByModel); const modelCenterTrails = useGearReplayStore(s => s.modelCenterTrails); 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]); // 서브클러스터 프레임 (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 (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(); 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) || 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, })); } } } // 1. Correlation TripsLayer (GPU animated, 항상 ON, 고채도) if (correlationTripsData.length > 0) { const activeMmsis = new Set(); 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); } } const enabledTrips = correlationTripsData.filter(d => activeMmsis.has(d.id)); if (enabledTrips.length > 0) { layers.push(new TripsLayer({ id: 'replay-corr-trails', data: enabledTrips, 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, })); } } // (identity 레이어는 최하단 — 최상위 z-index로 이동됨) // 3. Member position markers (IconLayer, identity — 항상 ON, placeholder) if (members.length > 0) { layers.push(new IconLayer({ 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({ 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 (트랙 보간 → 끝점 clamp → live fallback) const corrPositions: CorrPosition[] = []; const corrTrackMap = new Map(correlationTripsData.map(d => [d.id, d])); const liveShips = shipsRef.current; const relTime = ct - st; 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 (corrPositions.some(p => p.mmsi === c.targetMmsi)) continue; let lon: number | undefined; let lat: number | undefined; let cog = 0; // 방법 1: 트랙 데이터 (보간 + 범위 밖은 끝점 clamp) const tripData = corrTrackMap.get(c.targetMmsi); if (tripData && tripData.path.length > 0) { const ts = tripData.timestamps; const path = tripData.path; if (relTime <= ts[0]) { // 트랙 시작 전 → 첫 점 사용 lon = path[0][0]; lat = path[0][1]; if (path.length > 1) { const dx = path[1][0] - path[0][0]; const dy = path[1][1] - path[0][1]; cog = (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360; } } else if (relTime >= ts[ts.length - 1]) { // 트랙 종료 후 → 마지막 점 사용 const last = path.length - 1; lon = path[last][0]; lat = path[last][1]; if (last > 0) { const dx = path[last][0] - path[last - 1][0]; const dy = path[last][1] - path[last - 1][1]; cog = (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360; } } else { // 범위 내 → 보간 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; lon = path[lo][0] + (path[hi][0] - path[lo][0]) * ratio; lat = path[lo][1] + (path[hi][1] - path[lo][1]) * ratio; const dx = path[hi][0] - path[lo][0]; const dy = path[hi][1] - path[lo][1]; cog = (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360; } } // 방법 2: live 선박 위치 fallback if (lon === undefined) { const ship = liveShips.get(c.targetMmsi); if (ship) { lon = ship.lng; lat = ship.lat; cog = ship.course ?? 0; } } if (lon === undefined || lat === undefined) continue; corrPositions.push({ mmsi: c.targetMmsi, name: c.targetName || c.targetMmsi, lon, lat, cog, color: [r, g, b, 230], isVessel: c.targetType === 'VESSEL', }); } } // 디버그: 첫 프레임에서 전체 상태 출력 if (shouldLog) { const trackHit = corrPositions.filter(p => corrTrackMap.has(p.mmsi)).length; const liveHit = corrPositions.length - trackHit; 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, posSource: `track:${trackHit} live:${liveHit}`, 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}`); } } if (corrPositions.length > 0) { layers.push(new IconLayer({ 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({ 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], })); } } // 7. Hover highlight if (hoveredMmsi) { const hoveredMember = members.find(m => m.mmsi === hoveredMmsi); const hoveredCorr = corrPositions.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) { const relTime = ct - st; let clipIdx = hoveredTrack.timestamps.length; for (let i = 0; i < hoveredTrack.timestamps.length; i++) { if (hoveredTrack.timestamps[i] > relTime) { clipIdx = i; break; } } const clippedPath = hoveredTrack.path.slice(0, clipIdx); 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] }); } 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)) continue; let clipIdx = trip.timestamps.length; for (let i = 0; i < trip.timestamps.length; i++) { if (trip.timestamps[i] > relTime) { clipIdx = i; break; } } const clippedPath = trip.path.slice(0, clipIdx); 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, })); } } // pinned member trails (identity tracks) for (const trip of memberTripsData) { if (!state.pinnedMmsis.has(trip.id)) continue; let clipIdx = trip.timestamps.length; for (let i = 0; i < trip.timestamps.length; i++) { if (trip.timestamps[i] > relTime) { clipIdx = i; break; } } const clippedPath = trip.path.slice(0, clipIdx); 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(); 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, })); } } } // 8.5. Model center trails + current center point (모델×서브클러스터별 중심 경로) for (const trail of modelCenterTrails) { if (!enabledModels.has(trail.modelName)) continue; if (trail.path.length < 2) continue; const color = MODEL_COLORS[trail.modelName] ?? '#94a3b8'; const [r, g, b] = hexToRgb(color); // 중심 경로 (PathLayer, 연한 모델 색상) layers.push(new PathLayer({ id: `replay-model-trail-${trail.modelName}-s${trail.subClusterId}`, data: [{ path: trail.path }], getPath: (d: { path: [number, number][] }) => d.path, getColor: [r, g, b, 100], widthMinPixels: 1.5, })); // 현재 중심점 (보간) const ts = trail.timestamps; if (ts.length > 0 && relTime >= ts[0] && relTime <= ts[ts.length - 1]) { let lo = 0, 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 cx = trail.path[lo][0] + (trail.path[hi][0] - trail.path[lo][0]) * ratio; const cy = trail.path[lo][1] + (trail.path[hi][1] - trail.path[lo][1]) * ratio; const centerData = [{ position: [cx, cy] as [number, number] }]; layers.push(new ScatterplotLayer({ id: `replay-model-center-${trail.modelName}-s${trail.subClusterId}`, data: centerData, getPosition: (d: { position: [number, number] }) => d.position, getFillColor: [r, g, b, 255], getRadius: 150, radiusUnits: 'meters', radiusMinPixels: 5, stroked: true, getLineColor: [255, 255, 255, 200], lineWidthMinPixels: 1.5, })); if (showLabels) { layers.push(new TextLayer({ id: `replay-model-center-label-${trail.modelName}-s${trail.subClusterId}`, data: centerData, getPosition: (d: { position: [number, number] }) => d.position, getText: () => trail.modelName, getColor: [r, g, b, 255], getSize: 9 * fs, getPixelOffset: [0, -12], background: true, getBackgroundColor: [0, 0, 0, 200], backgroundPadding: [3, 1], fontFamily: '"Fira Code Variable", monospace', })); } } } // 9. Model badges (small colored dots next to each vessel/gear per model) { const badgeTargets = new Map }>(); // 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() }; 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() }; 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 (memberTripsData.length > 0) { layers.push(new TripsLayer({ id: 'replay-identity-trails', data: memberTripsData, 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); // 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({ 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({ 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 (memberTripsData6h.length > 0) { layers.push(new TripsLayer({ id: 'replay-6h-identity-trails', data: memberTripsData6h, 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, modelCenterTrails, 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 }, []); }