diff --git a/frontend/src/components/korea/CorrelationPanel.tsx b/frontend/src/components/korea/CorrelationPanel.tsx index fe0f296..b3be94e 100644 --- a/frontend/src/components/korea/CorrelationPanel.tsx +++ b/frontend/src/components/korea/CorrelationPanel.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import type { GearCorrelationItem, CorrelationVesselTrack } from '../../services/vesselAnalysis'; +import type { GearCorrelationItem } from '../../services/vesselAnalysis'; import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons'; import { FONT_MONO } from '../../styles/fonts'; import { MODEL_ORDER, MODEL_COLORS, MODEL_DESC } from './fleetClusterConstants'; @@ -11,7 +11,6 @@ interface CorrelationPanelProps { groupPolygons: UseGroupPolygonsResult | undefined; correlationByModel: Map; availableModels: { name: string; count: number; isDefault: boolean }[]; - correlationTracks: CorrelationVesselTrack[]; enabledModels: Set; enabledVessels: Set; correlationLoading: boolean; @@ -30,7 +29,6 @@ const CorrelationPanel = ({ groupPolygons, correlationByModel, availableModels, - correlationTracks, enabledModels, enabledVessels, correlationLoading, @@ -127,44 +125,36 @@ const CorrelationPanel = ({ }; // Common row renderer (correlation target — with score bar, model-independent hover) + const toggleVessel = (mmsi: string) => { + onEnabledVesselsChange(prev => { + const next = new Set(prev); + if (next.has(mmsi)) next.delete(mmsi); else next.add(mmsi); + return next; + }); + }; + const renderRow = (c: GearCorrelationItem, color: string, modelName: string) => { const pct = (c.score * 100).toFixed(0); const barW = Math.max(2, c.score * 30); const barColor = c.score >= 0.7 ? '#22c55e' : c.score >= 0.5 ? '#eab308' : '#94a3b8'; const isVessel = c.targetType === 'VESSEL'; - const hasTrack = correlationTracks.some(v => v.mmsi === c.targetMmsi); + const isEnabled = enabledVessels.has(c.targetMmsi); const isHovered = hoveredTarget?.mmsi === c.targetMmsi && hoveredTarget?.model === modelName; return (
toggleVessel(c.targetMmsi)} onMouseEnter={() => onHoveredTargetChange({ mmsi: c.targetMmsi, model: modelName })} onMouseLeave={() => onHoveredTargetChange(null)} > - {hasTrack && ( - onEnabledVesselsChange(prev => { - const next = new Set(prev); - if (next.has(c.targetMmsi)) next.delete(c.targetMmsi); - else next.add(c.targetMmsi); - return next; - })} - style={{ accentColor: color, width: 9, height: 9, flexShrink: 0 }} - title="맵 표시" - /> - )} + {isVessel ? '⛴' : '◆'} diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index b7598f0..f87590a 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -454,7 +454,6 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS groupPolygons={groupPolygons} correlationByModel={geo.correlationByModel} availableModels={geo.availableModels} - correlationTracks={correlationTracks} enabledModels={enabledModels} enabledVessels={enabledVessels} correlationLoading={correlationLoading} @@ -469,6 +468,20 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS {historyActive && ( { + if (minPct === null) { + // 전체: 모든 연관 선박 ON + setEnabledVessels(new Set(correlationTracks.map(v => v.mmsi))); + } else { + // 해당 퍼센트 이상만 ON + const threshold = minPct / 100; + const filtered = new Set(); + for (const c of correlationData) { + if (c.score >= threshold) filtered.add(c.targetMmsi); + } + setEnabledVessels(filtered); + } + }} /> )} diff --git a/frontend/src/components/korea/HistoryReplayController.tsx b/frontend/src/components/korea/HistoryReplayController.tsx index 6cdc819..51236cf 100644 --- a/frontend/src/components/korea/HistoryReplayController.tsx +++ b/frontend/src/components/korea/HistoryReplayController.tsx @@ -4,20 +4,20 @@ import { useGearReplayStore } from '../../stores/gearReplayStore'; interface HistoryReplayControllerProps { onClose: () => void; + onFilterByScore: (minPct: number | null) => void; } -const HistoryReplayController = ({ onClose }: HistoryReplayControllerProps) => { - // React selectors (infrequent changes) +const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayControllerProps) => { const isPlaying = useGearReplayStore(s => s.isPlaying); const snapshotRanges = useGearReplayStore(s => s.snapshotRanges); const frameCount = useGearReplayStore(s => s.historyFrames.length); + const showTrails = useGearReplayStore(s => s.showTrails); + const showLabels = useGearReplayStore(s => s.showLabels); - // DOM refs for imperative updates const progressBarRef = useRef(null); const progressIndicatorRef = useRef(null); const timeDisplayRef = useRef(null); - // Subscribe to currentTime for DOM updates (no React re-render) useEffect(() => { const unsub = useGearReplayStore.subscribe( s => s.currentTime, @@ -36,124 +36,91 @@ const HistoryReplayController = ({ onClose }: HistoryReplayControllerProps) => { }, []); const store = useGearReplayStore; + const btnStyle: React.CSSProperties = { + background: 'none', border: '1px solid rgba(99,179,237,0.3)', borderRadius: 4, + color: '#e2e8f0', cursor: 'pointer', padding: '2px 6px', fontSize: 10, fontFamily: FONT_MONO, + }; + const btnActiveStyle: React.CSSProperties = { + ...btnStyle, background: 'rgba(99,179,237,0.15)', color: '#93c5fd', + }; return (
- {/* 프로그레스 바 — 갭 표시 */} -
+ {/* 프로그레스 바 */} +
{snapshotRanges.map((pos, i) => (
))} - {/* 현재 위치 인디케이터 (DOM ref로 업데이트) */}
- {/* 컨트롤 행 */} -
- - - - --:-- - - - --:-- + { const { startTime, endTime } = store.getState(); const progress = Number(e.target.value) / 1000; - const seekTime = startTime + progress * (endTime - startTime); store.getState().pause(); - store.getState().seek(seekTime); + store.getState().seek(startTime + progress * (endTime - startTime)); }} style={{ flex: 1, cursor: 'pointer', accentColor: '#fbbf24' }} - title="히스토리 타임라인" - aria-label="히스토리 타임라인" - /> - - - {frameCount}건 - - -
+ + {/* 컨트롤 행 2: 표시 옵션 */} +
+ + + | + 일치율 + +
); }; diff --git a/frontend/src/hooks/useGearReplayLayers.ts b/frontend/src/hooks/useGearReplayLayers.ts index d7b0c82..2019bf0 100644 --- a/frontend/src/hooks/useGearReplayLayers.ts +++ b/frontend/src/hooks/useGearReplayLayers.ts @@ -64,6 +64,8 @@ export function useGearReplayLayers( const enabledVessels = useGearReplayStore(s => s.enabledVessels); const hoveredMmsi = useGearReplayStore(s => s.hoveredMmsi); const correlationByModel = useGearReplayStore(s => s.correlationByModel); + const showTrails = useGearReplayStore(s => s.showTrails); + const showLabels = useGearReplayStore(s => s.showLabels); // ── Refs ───────────────────────────────────────────────────────────────── const cursorRef = useRef(0); // frame cursor for O(1) forward lookup @@ -95,23 +97,25 @@ export function useGearReplayLayers( // ── Static layers (center trail + dots) ─────────────────────────────── - // Center trail segments (PathLayer) - 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, - })); + // Center trail segments (PathLayer) — showTrails 제어 + if (showTrails) { + 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, + })); + } } - // Center dots (real data only) - if (centerDotsPositions.length > 0) { + // Center dots (real data only) — showTrails 제어 + if (showTrails && centerDotsPositions.length > 0) { layers.push(new ScatterplotLayer({ id: 'replay-center-dots', data: centerDotsPositions, @@ -141,8 +145,8 @@ export function useGearReplayLayers( // 1. Identity 모델: 멤버 트레일 + 폴리곤 + 마커 (enabledModels 체크) if (enabledModels.has('identity')) { - // TripsLayer — member trails (GPU animated) - if (memberTripsData.length > 0) { + // TripsLayer — member trails (GPU animated, showTrails 제어) + if (showTrails && memberTripsData.length > 0) { layers.push(new TripsLayer({ id: 'replay-member-trails', data: memberTripsData, @@ -173,8 +177,8 @@ export function useGearReplayLayers( } } - // 2. Correlation trails (GPU animated, enabledModels 체크) - if (correlationTripsData.length > 0) { + // 2. Correlation trails (GPU animated, showTrails + enabledModels 체크) + if (showTrails && correlationTripsData.length > 0) { // 활성 모델에 속하는 선박의 트랙만 표시 const activeMmsis = new Set(); for (const [mn, items] of correlationByModel) { @@ -231,8 +235,8 @@ export function useGearReplayLayers( billboard: false, })); - // Member labels - layers.push(new TextLayer({ + // Member labels — showLabels 제어 + if (showLabels) layers.push(new TextLayer({ id: 'replay-member-labels', data: members, getPosition: d => [d.lon, d.lat], @@ -263,6 +267,7 @@ export function useGearReplayLayers( 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; @@ -373,7 +378,7 @@ export function useGearReplayLayers( billboard: false, })); - layers.push(new TextLayer({ + if (showLabels) layers.push(new TextLayer({ id: 'replay-corr-labels', data: corrPositions, getPosition: d => [d.lon, d.lat], @@ -541,6 +546,7 @@ export function useGearReplayLayers( historyFrames, memberTripsData, correlationTripsData, centerTrailSegments, centerDotsPositions, enabledModels, enabledVessels, hoveredMmsi, correlationByModel, + showTrails, showLabels, replayLayerRef, requestRender, ]); diff --git a/frontend/src/stores/gearReplayStore.ts b/frontend/src/stores/gearReplayStore.ts index b1e63c6..83ee7bd 100644 --- a/frontend/src/stores/gearReplayStore.ts +++ b/frontend/src/stores/gearReplayStore.ts @@ -64,11 +64,13 @@ interface GearReplayState { centerDotsPositions: [number, number][]; snapshotRanges: number[]; - // Filter state + // Filter / display state enabledModels: Set; enabledVessels: Set; hoveredMmsi: string | null; correlationByModel: Map; + showTrails: boolean; + showLabels: boolean; // Actions loadHistory: ( @@ -85,6 +87,8 @@ interface GearReplayState { setEnabledModels: (models: Set) => void; setEnabledVessels: (vessels: Set) => void; setHoveredMmsi: (mmsi: string | null) => void; + setShowTrails: (show: boolean) => void; + setShowLabels: (show: boolean) => void; updateCorrelation: (corrData: GearCorrelationItem[], corrTracks: CorrelationVesselTrack[]) => void; reset: () => void; } @@ -135,10 +139,12 @@ export const useGearReplayStore = create()( centerDotsPositions: [], snapshotRanges: [], - // Filter state + // Filter / display state enabledModels: new Set(), enabledVessels: new Set(), hoveredMmsi: null, + showTrails: true, + showLabels: true, correlationByModel: new Map(), // ── Actions ──────────────────────────────────────────────── @@ -214,6 +220,8 @@ export const useGearReplayStore = create()( setEnabledVessels: (vessels) => set({ enabledVessels: vessels }), setHoveredMmsi: (mmsi) => set({ hoveredMmsi: mmsi }), + setShowTrails: (show) => set({ showTrails: show }), + setShowLabels: (show) => set({ showLabels: show }), updateCorrelation: (corrData, corrTracks) => { const state = get(); @@ -260,6 +268,8 @@ export const useGearReplayStore = create()( enabledModels: new Set(), enabledVessels: new Set(), hoveredMmsi: null, + showTrails: true, + showLabels: true, correlationByModel: new Map(), }); },