feat: 재생 컨트롤 확장 — 항적/이름 토글 + 일치율 필터 + 개별 on/off

재생 컨트롤러:
- 항적 on/off → showTrails (TripsLayer + PathLayer + 센터도트)
- 이름 on/off → showLabels (TextLayer)
- 일치율 드롭다운 (50~90%) → enabledVessels 일괄 필터

패널 토글:
- 행 전체 클릭으로 체크박스 토글 (cursor: pointer)
- 체크박스 항상 표시, OFF 시 opacity 0.5
- correlationTracks prop 제거 (미사용)

enabledVessels OFF 효과:
- corrPositions 제외 → 아이콘/라벨/트레일/폴리곤 모두 제외

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-03-31 09:26:41 +09:00
부모 21df325010
커밋 5002105d18
5개의 변경된 파일135개의 추가작업 그리고 149개의 파일을 삭제

파일 보기

@ -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<string, GearCorrelationItem[]>;
availableModels: { name: string; count: number; isDefault: boolean }[];
correlationTracks: CorrelationVesselTrack[];
enabledModels: Set<string>;
enabledVessels: Set<string>;
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 (
<div
key={c.targetMmsi}
style={{
fontSize: 9,
marginBottom: 1,
display: 'flex',
alignItems: 'center',
gap: 3,
padding: '1px 2px',
borderRadius: 2,
cursor: 'default',
fontSize: 9, marginBottom: 1, display: 'flex', alignItems: 'center', gap: 3,
padding: '1px 2px', borderRadius: 2, cursor: 'pointer',
background: isHovered ? `${color}22` : 'transparent',
opacity: isEnabled ? 1 : 0.5,
}}
onClick={() => toggleVessel(c.targetMmsi)}
onMouseEnter={() => onHoveredTargetChange({ mmsi: c.targetMmsi, model: modelName })}
onMouseLeave={() => onHoveredTargetChange(null)}
>
{hasTrack && (
<input
type="checkbox"
checked={enabledVessels.has(c.targetMmsi)}
onChange={() => 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="맵 표시"
/>
)}
<input type="checkbox" checked={isEnabled} readOnly title="맵 표시"
style={{ accentColor: color, width: 9, height: 9, flexShrink: 0, pointerEvents: 'none' }} />
<span style={{ color: isVessel ? '#60a5fa' : '#f97316', width: 10, textAlign: 'center', flexShrink: 0 }}>
{isVessel ? '⛴' : '◆'}
</span>

파일 보기

@ -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 && (
<HistoryReplayController
onClose={closeHistory}
onFilterByScore={(minPct) => {
if (minPct === null) {
// 전체: 모든 연관 선박 ON
setEnabledVessels(new Set(correlationTracks.map(v => v.mmsi)));
} else {
// 해당 퍼센트 이상만 ON
const threshold = minPct / 100;
const filtered = new Set<string>();
for (const c of correlationData) {
if (c.score >= threshold) filtered.add(c.targetMmsi);
}
setEnabledVessels(filtered);
}
}}
/>
)}

파일 보기

@ -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<HTMLInputElement>(null);
const progressIndicatorRef = useRef<HTMLDivElement>(null);
const timeDisplayRef = useRef<HTMLSpanElement>(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 (
<div style={{
position: 'absolute',
bottom: 20,
left: '50%',
transform: 'translateX(-50%)',
background: 'rgba(12,24,37,0.95)',
border: '1px solid rgba(99,179,237,0.25)',
borderRadius: 8,
padding: '8px 14px',
display: 'flex',
flexDirection: 'column',
gap: 4,
zIndex: 20,
fontFamily: FONT_MONO,
fontSize: 10,
color: '#e2e8f0',
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
pointerEvents: 'auto',
minWidth: 360,
position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)',
background: 'rgba(12,24,37,0.95)', border: '1px solid rgba(99,179,237,0.25)',
borderRadius: 8, padding: '8px 14px', display: 'flex', flexDirection: 'column', gap: 4,
zIndex: 20, fontFamily: FONT_MONO, fontSize: 10, color: '#e2e8f0',
boxShadow: '0 4px 16px rgba(0,0,0,0.5)', pointerEvents: 'auto', minWidth: 420,
}}>
{/* 프로그레스 바 — 갭 표시 */}
<div style={{
position: 'relative',
height: 8,
background: 'rgba(255,255,255,0.05)',
borderRadius: 4,
overflow: 'hidden',
}}>
{/* 프로그레스 바 */}
<div style={{ position: 'relative', height: 8, background: 'rgba(255,255,255,0.05)', borderRadius: 4, overflow: 'hidden' }}>
{snapshotRanges.map((pos, i) => (
<div key={i} style={{
position: 'absolute',
left: `${pos * 100}%`,
top: 0,
width: 2,
height: '100%',
position: 'absolute', left: `${pos * 100}%`, top: 0, width: 2, height: '100%',
background: 'rgba(251,191,36,0.4)',
}} />
))}
{/* 현재 위치 인디케이터 (DOM ref로 업데이트) */}
<div ref={progressIndicatorRef} style={{
position: 'absolute',
left: '0%',
top: -1,
width: 3,
height: 10,
background: '#fbbf24',
borderRadius: 1,
transform: 'translateX(-50%)',
position: 'absolute', left: '0%', top: -1, width: 3, height: 10,
background: '#fbbf24', borderRadius: 1, transform: 'translateX(-50%)',
}} />
</div>
{/* 컨트롤 행 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
type="button"
onClick={() => { if (isPlaying) store.getState().pause(); else store.getState().play(); }}
style={{
background: 'none',
border: '1px solid rgba(99,179,237,0.3)',
borderRadius: 4,
color: '#e2e8f0',
cursor: 'pointer',
padding: '2px 6px',
fontSize: 12,
fontFamily: FONT_MONO,
}}
>
{/* 컨트롤 행 1: 재생 + 타임라인 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button type="button" onClick={() => { if (isPlaying) store.getState().pause(); else store.getState().play(); }}
style={{ ...btnStyle, fontSize: 12 }}>
{isPlaying ? '⏸' : '▶'}
</button>
<span
ref={timeDisplayRef}
style={{ color: '#fbbf24', minWidth: 40, textAlign: 'center' }}
>
--:--
</span>
<input
ref={progressBarRef}
type="range"
min={0}
max={1000}
defaultValue={0}
<span ref={timeDisplayRef} style={{ color: '#fbbf24', minWidth: 40, textAlign: 'center' }}>--:--</span>
<input ref={progressBarRef} type="range" min={0} max={1000} defaultValue={0}
onChange={e => {
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="히스토리 타임라인"
/>
<span style={{ color: '#64748b', fontSize: 9 }}>
{frameCount}
</span>
<button
type="button"
onClick={onClose}
style={{
background: 'none',
border: '1px solid rgba(239,68,68,0.3)',
borderRadius: 4,
color: '#ef4444',
cursor: 'pointer',
padding: '2px 6px',
fontSize: 11,
fontFamily: FONT_MONO,
}}
>
title="히스토리 타임라인" aria-label="히스토리 타임라인" />
<span style={{ color: '#64748b', fontSize: 9 }}>{frameCount}</span>
<button type="button" onClick={onClose}
style={{ background: 'none', border: '1px solid rgba(239,68,68,0.3)', borderRadius: 4, color: '#ef4444', cursor: 'pointer', padding: '2px 6px', fontSize: 11, fontFamily: FONT_MONO }}>
</button>
</div>
{/* 컨트롤 행 2: 표시 옵션 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: 4 }}>
<button type="button" onClick={() => store.getState().setShowTrails(!showTrails)}
style={showTrails ? btnActiveStyle : btnStyle} title="전체 항적 표시">
</button>
<button type="button" onClick={() => store.getState().setShowLabels(!showLabels)}
style={showLabels ? btnActiveStyle : btnStyle} title="이름 표시">
</button>
<span style={{ color: '#475569', margin: '0 2px' }}>|</span>
<span style={{ color: '#64748b', fontSize: 9 }}></span>
<select
onChange={e => {
const val = e.target.value;
onFilterByScore(val === '' ? null : Number(val));
}}
style={{
background: 'rgba(15,23,42,0.9)', border: '1px solid rgba(99,179,237,0.3)',
borderRadius: 4, color: '#e2e8f0', fontSize: 9, fontFamily: FONT_MONO,
padding: '1px 4px', cursor: 'pointer',
}}
title="일치율 이상만 표시" aria-label="일치율 필터"
>
<option value=""></option>
<option value="50">50%+</option>
<option value="60">60%+</option>
<option value="70">70%+</option>
<option value="80">80%+</option>
<option value="90">90%+</option>
</select>
</div>
</div>
);
};

파일 보기

@ -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<string>();
for (const [mn, items] of correlationByModel) {
@ -231,8 +235,8 @@ export function useGearReplayLayers(
billboard: false,
}));
// Member labels
layers.push(new TextLayer<MemberPosition>({
// Member labels — showLabels 제어
if (showLabels) layers.push(new TextLayer<MemberPosition>({
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<CorrPosition>({
if (showLabels) layers.push(new TextLayer<CorrPosition>({
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,
]);

파일 보기

@ -64,11 +64,13 @@ interface GearReplayState {
centerDotsPositions: [number, number][];
snapshotRanges: number[];
// Filter state
// Filter / display state
enabledModels: Set<string>;
enabledVessels: Set<string>;
hoveredMmsi: string | null;
correlationByModel: Map<string, GearCorrelationItem[]>;
showTrails: boolean;
showLabels: boolean;
// Actions
loadHistory: (
@ -85,6 +87,8 @@ interface GearReplayState {
setEnabledModels: (models: Set<string>) => void;
setEnabledVessels: (vessels: Set<string>) => 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<GearReplayState>()(
centerDotsPositions: [],
snapshotRanges: [],
// Filter state
// Filter / display state
enabledModels: new Set<string>(),
enabledVessels: new Set<string>(),
hoveredMmsi: null,
showTrails: true,
showLabels: true,
correlationByModel: new Map<string, GearCorrelationItem[]>(),
// ── Actions ────────────────────────────────────────────────
@ -214,6 +220,8 @@ export const useGearReplayStore = create<GearReplayState>()(
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<GearReplayState>()(
enabledModels: new Set<string>(),
enabledVessels: new Set<string>(),
hoveredMmsi: null,
showTrails: true,
showLabels: true,
correlationByModel: new Map<string, GearCorrelationItem[]>(),
});
},