From c97f964f93bad5fd06fe527b15e56298f636898f Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 31 Mar 2026 09:52:17 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=AA=A8=EB=8D=B8=EB=B3=84=20=ED=8F=B4?= =?UTF-8?q?=EB=A6=AC=EA=B3=A4=20=EC=A4=91=EC=8B=AC=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?+=20=ED=98=84=EC=9E=AC=20=EC=A4=91=EC=8B=AC=EC=A0=90=20?= =?UTF-8?q?=EB=A0=8C=EB=8D=94=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사전계산 (gearReplayPreprocess): - buildModelCenterTrails(): 각 프레임에서 멤버+연관선박 위치 → 폴리곤 → 중심점 - 모델별 path[]/timestamps[] (PathLayer + 보간용) 스토어 (gearReplayStore): - modelCenterTrails 필드 추가 (loadHistory/updateCorrelation에서 빌드) 렌더링 (useGearReplayLayers): - PathLayer: 모델별 폴리곤 중심 경로 (연한 모델 색상, alpha 100) - ScatterplotLayer: 현재 시간 중심점 (고채도 모델 색상, 흰 테두리) - 모델 ON 시에만 표시 (enabledModels 체크) Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/hooks/useGearReplayLayers.ts | 43 +++++++++- frontend/src/stores/gearReplayPreprocess.ts | 87 ++++++++++++++++++++- frontend/src/stores/gearReplayStore.ts | 11 ++- 3 files changed, 138 insertions(+), 3 deletions(-) diff --git a/frontend/src/hooks/useGearReplayLayers.ts b/frontend/src/hooks/useGearReplayLayers.ts index 42a6135..ea3c816 100644 --- a/frontend/src/hooks/useGearReplayLayers.ts +++ b/frontend/src/hooks/useGearReplayLayers.ts @@ -64,6 +64,7 @@ export function useGearReplayLayers( 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); @@ -517,6 +518,46 @@ export function useGearReplayLayers( })); } + // 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}`, + 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; + + layers.push(new ScatterplotLayer({ + id: `replay-model-center-${trail.modelName}`, + data: [{ position: [cx, cy] as [number, number] }], + 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, + })); + } + } + // 9. Model badges (small colored dots next to each vessel/gear per model) { const badgeTargets = new Map }>(); @@ -581,7 +622,7 @@ export function useGearReplayLayers( historyFrames, memberTripsData, correlationTripsData, centerTrailSegments, centerDotsPositions, enabledModels, enabledVessels, hoveredMmsi, correlationByModel, - showTrails, showLabels, + modelCenterTrails, showTrails, showLabels, replayLayerRef, requestRender, ]); diff --git a/frontend/src/stores/gearReplayPreprocess.ts b/frontend/src/stores/gearReplayPreprocess.ts index 9e17969..239c938 100644 --- a/frontend/src/stores/gearReplayPreprocess.ts +++ b/frontend/src/stores/gearReplayPreprocess.ts @@ -1,5 +1,6 @@ import type { HistoryFrame } from '../components/korea/fleetClusterTypes'; -import type { CorrelationVesselTrack } from '../services/vesselAnalysis'; +import type { CorrelationVesselTrack, GearCorrelationItem } from '../services/vesselAnalysis'; +import { buildInterpPolygon } from '../components/korea/fleetClusterUtils'; export interface TripsLayerDatum { id: string; @@ -233,3 +234,87 @@ export function interpolateMemberPositions( ); }); } + +/** + * 모델별 오퍼레이셔널 폴리곤 중심 경로 사전 계산. + * 각 프레임마다 멤버 위치 + 연관 선박 위치(트랙 보간)로 폴리곤을 만들고 중심점을 기록. + */ +export interface ModelCenterTrail { + modelName: string; + path: [number, number][]; // [lon, lat][] + timestamps: number[]; // relative ms +} + +export function buildModelCenterTrails( + frames: HistoryFrame[], + corrTracks: CorrelationVesselTrack[], + corrByModel: Map, + enabledVessels: Set, + startTime: number, +): ModelCenterTrail[] { + // 연관 선박 트랙 맵: mmsi → {timestampsMs[], geometry[][]} + const trackMap = new Map(); + for (const vt of corrTracks) { + if (vt.track.length < 1) continue; + trackMap.set(vt.mmsi, { + ts: vt.track.map(p => p.ts), + path: vt.track.map(p => [p.lon, p.lat]), + }); + } + + const results: ModelCenterTrail[] = []; + + for (const [mn, items] of corrByModel) { + const enabledItems = items.filter(c => enabledVessels.has(c.targetMmsi)); + if (enabledItems.length === 0) continue; + + const path: [number, number][] = []; + const timestamps: number[] = []; + + for (const frame of frames) { + const t = new Date(frame.snapshotTime).getTime(); + const relT = t - startTime; + + // 멤버 위치 + const allPts: [number, number][] = frame.members.map(m => [m.lon, m.lat]); + + // 연관 선박 위치 (트랙 보간 or 마지막 점 clamp) + for (const c of enabledItems) { + const track = trackMap.get(c.targetMmsi); + if (!track || track.path.length === 0) continue; + + let lon: number, lat: number; + if (t <= track.ts[0]) { + lon = track.path[0][0]; lat = track.path[0][1]; + } else if (t >= track.ts[track.ts.length - 1]) { + const last = track.path.length - 1; + lon = track.path[last][0]; lat = track.path[last][1]; + } else { + let lo = 0, hi = track.ts.length - 1; + while (lo < hi - 1) { const mid = (lo + hi) >> 1; if (track.ts[mid] <= t) lo = mid; else hi = mid; } + const ratio = track.ts[hi] !== track.ts[lo] ? (t - track.ts[lo]) / (track.ts[hi] - track.ts[lo]) : 0; + lon = track.path[lo][0] + (track.path[hi][0] - track.path[lo][0]) * ratio; + lat = track.path[lo][1] + (track.path[hi][1] - track.path[lo][1]) * ratio; + } + allPts.push([lon, lat]); + } + + // 폴리곤 중심 계산 + const poly = buildInterpPolygon(allPts); + if (!poly) continue; + const ring = poly.coordinates[0]; + let cx = 0, cy = 0; + for (const pt of ring) { cx += pt[0]; cy += pt[1]; } + cx /= ring.length; cy /= ring.length; + + path.push([cx, cy]); + timestamps.push(relT); + } + + if (path.length >= 2) { + results.push({ modelName: mn, path, timestamps }); + } + } + + return results; +} diff --git a/frontend/src/stores/gearReplayStore.ts b/frontend/src/stores/gearReplayStore.ts index 83ee7bd..04ade5c 100644 --- a/frontend/src/stores/gearReplayStore.ts +++ b/frontend/src/stores/gearReplayStore.ts @@ -7,7 +7,9 @@ import { buildCorrelationTripsData, buildCenterTrailData, buildSnapshotRanges, + buildModelCenterTrails, } from './gearReplayPreprocess'; +import type { ModelCenterTrail } from './gearReplayPreprocess'; // ── Pre-processed data types for deck.gl layers ────────────────── @@ -63,6 +65,7 @@ interface GearReplayState { centerTrailSegments: CenterTrailSegment[]; centerDotsPositions: [number, number][]; snapshotRanges: number[]; + modelCenterTrails: ModelCenterTrail[]; // Filter / display state enabledModels: Set; @@ -138,6 +141,7 @@ export const useGearReplayStore = create()( centerTrailSegments: [], centerDotsPositions: [], snapshotRanges: [], + modelCenterTrails: [], // Filter / display state enabledModels: new Set(), @@ -166,6 +170,8 @@ export const useGearReplayStore = create()( byModel.set(c.modelName, list); } + const modelTrails = buildModelCenterTrails(frames, corrTracks, byModel, enabledVessels, startTime); + set({ historyFrames: frames, frameTimes, @@ -177,6 +183,7 @@ export const useGearReplayStore = create()( centerTrailSegments: segments, centerDotsPositions: dots, snapshotRanges: ranges, + modelCenterTrails: modelTrails, enabledModels, enabledVessels, correlationByModel: byModel, @@ -242,7 +249,8 @@ export const useGearReplayStore = create()( corrTrips: corrTrips.length, corrTracks: corrTracks.length, }); - set({ correlationByModel: byModel, correlationTripsData: corrTrips }); + const modelTrails = buildModelCenterTrails(state.historyFrames, corrTracks, byModel, state.enabledVessels, state.startTime); + set({ correlationByModel: byModel, correlationTripsData: corrTrips, modelCenterTrails: modelTrails }); }, reset: () => { @@ -265,6 +273,7 @@ export const useGearReplayStore = create()( centerTrailSegments: [], centerDotsPositions: [], snapshotRanges: [], + modelCenterTrails: [], enabledModels: new Set(), enabledVessels: new Set(), hoveredMmsi: null,