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:
부모
21df325010
커밋
5002105d18
@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { GearCorrelationItem, CorrelationVesselTrack } from '../../services/vesselAnalysis';
|
import type { GearCorrelationItem } from '../../services/vesselAnalysis';
|
||||||
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
|
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
|
||||||
import { FONT_MONO } from '../../styles/fonts';
|
import { FONT_MONO } from '../../styles/fonts';
|
||||||
import { MODEL_ORDER, MODEL_COLORS, MODEL_DESC } from './fleetClusterConstants';
|
import { MODEL_ORDER, MODEL_COLORS, MODEL_DESC } from './fleetClusterConstants';
|
||||||
@ -11,7 +11,6 @@ interface CorrelationPanelProps {
|
|||||||
groupPolygons: UseGroupPolygonsResult | undefined;
|
groupPolygons: UseGroupPolygonsResult | undefined;
|
||||||
correlationByModel: Map<string, GearCorrelationItem[]>;
|
correlationByModel: Map<string, GearCorrelationItem[]>;
|
||||||
availableModels: { name: string; count: number; isDefault: boolean }[];
|
availableModels: { name: string; count: number; isDefault: boolean }[];
|
||||||
correlationTracks: CorrelationVesselTrack[];
|
|
||||||
enabledModels: Set<string>;
|
enabledModels: Set<string>;
|
||||||
enabledVessels: Set<string>;
|
enabledVessels: Set<string>;
|
||||||
correlationLoading: boolean;
|
correlationLoading: boolean;
|
||||||
@ -30,7 +29,6 @@ const CorrelationPanel = ({
|
|||||||
groupPolygons,
|
groupPolygons,
|
||||||
correlationByModel,
|
correlationByModel,
|
||||||
availableModels,
|
availableModels,
|
||||||
correlationTracks,
|
|
||||||
enabledModels,
|
enabledModels,
|
||||||
enabledVessels,
|
enabledVessels,
|
||||||
correlationLoading,
|
correlationLoading,
|
||||||
@ -127,44 +125,36 @@ const CorrelationPanel = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Common row renderer (correlation target — with score bar, model-independent hover)
|
// 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 renderRow = (c: GearCorrelationItem, color: string, modelName: string) => {
|
||||||
const pct = (c.score * 100).toFixed(0);
|
const pct = (c.score * 100).toFixed(0);
|
||||||
const barW = Math.max(2, c.score * 30);
|
const barW = Math.max(2, c.score * 30);
|
||||||
const barColor = c.score >= 0.7 ? '#22c55e' : c.score >= 0.5 ? '#eab308' : '#94a3b8';
|
const barColor = c.score >= 0.7 ? '#22c55e' : c.score >= 0.5 ? '#eab308' : '#94a3b8';
|
||||||
const isVessel = c.targetType === 'VESSEL';
|
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;
|
const isHovered = hoveredTarget?.mmsi === c.targetMmsi && hoveredTarget?.model === modelName;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={c.targetMmsi}
|
key={c.targetMmsi}
|
||||||
style={{
|
style={{
|
||||||
fontSize: 9,
|
fontSize: 9, marginBottom: 1, display: 'flex', alignItems: 'center', gap: 3,
|
||||||
marginBottom: 1,
|
padding: '1px 2px', borderRadius: 2, cursor: 'pointer',
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 3,
|
|
||||||
padding: '1px 2px',
|
|
||||||
borderRadius: 2,
|
|
||||||
cursor: 'default',
|
|
||||||
background: isHovered ? `${color}22` : 'transparent',
|
background: isHovered ? `${color}22` : 'transparent',
|
||||||
|
opacity: isEnabled ? 1 : 0.5,
|
||||||
}}
|
}}
|
||||||
|
onClick={() => toggleVessel(c.targetMmsi)}
|
||||||
onMouseEnter={() => onHoveredTargetChange({ mmsi: c.targetMmsi, model: modelName })}
|
onMouseEnter={() => onHoveredTargetChange({ mmsi: c.targetMmsi, model: modelName })}
|
||||||
onMouseLeave={() => onHoveredTargetChange(null)}
|
onMouseLeave={() => onHoveredTargetChange(null)}
|
||||||
>
|
>
|
||||||
{hasTrack && (
|
<input type="checkbox" checked={isEnabled} readOnly title="맵 표시"
|
||||||
<input
|
style={{ accentColor: color, width: 9, height: 9, flexShrink: 0, pointerEvents: 'none' }} />
|
||||||
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="맵 표시"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span style={{ color: isVessel ? '#60a5fa' : '#f97316', width: 10, textAlign: 'center', flexShrink: 0 }}>
|
<span style={{ color: isVessel ? '#60a5fa' : '#f97316', width: 10, textAlign: 'center', flexShrink: 0 }}>
|
||||||
{isVessel ? '⛴' : '◆'}
|
{isVessel ? '⛴' : '◆'}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -454,7 +454,6 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
|||||||
groupPolygons={groupPolygons}
|
groupPolygons={groupPolygons}
|
||||||
correlationByModel={geo.correlationByModel}
|
correlationByModel={geo.correlationByModel}
|
||||||
availableModels={geo.availableModels}
|
availableModels={geo.availableModels}
|
||||||
correlationTracks={correlationTracks}
|
|
||||||
enabledModels={enabledModels}
|
enabledModels={enabledModels}
|
||||||
enabledVessels={enabledVessels}
|
enabledVessels={enabledVessels}
|
||||||
correlationLoading={correlationLoading}
|
correlationLoading={correlationLoading}
|
||||||
@ -469,6 +468,20 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
|
|||||||
{historyActive && (
|
{historyActive && (
|
||||||
<HistoryReplayController
|
<HistoryReplayController
|
||||||
onClose={closeHistory}
|
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 {
|
interface HistoryReplayControllerProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onFilterByScore: (minPct: number | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HistoryReplayController = ({ onClose }: HistoryReplayControllerProps) => {
|
const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayControllerProps) => {
|
||||||
// React selectors (infrequent changes)
|
|
||||||
const isPlaying = useGearReplayStore(s => s.isPlaying);
|
const isPlaying = useGearReplayStore(s => s.isPlaying);
|
||||||
const snapshotRanges = useGearReplayStore(s => s.snapshotRanges);
|
const snapshotRanges = useGearReplayStore(s => s.snapshotRanges);
|
||||||
const frameCount = useGearReplayStore(s => s.historyFrames.length);
|
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 progressBarRef = useRef<HTMLInputElement>(null);
|
||||||
const progressIndicatorRef = useRef<HTMLDivElement>(null);
|
const progressIndicatorRef = useRef<HTMLDivElement>(null);
|
||||||
const timeDisplayRef = useRef<HTMLSpanElement>(null);
|
const timeDisplayRef = useRef<HTMLSpanElement>(null);
|
||||||
|
|
||||||
// Subscribe to currentTime for DOM updates (no React re-render)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = useGearReplayStore.subscribe(
|
const unsub = useGearReplayStore.subscribe(
|
||||||
s => s.currentTime,
|
s => s.currentTime,
|
||||||
@ -36,124 +36,91 @@ const HistoryReplayController = ({ onClose }: HistoryReplayControllerProps) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const store = useGearReplayStore;
|
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 (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute',
|
position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)',
|
||||||
bottom: 20,
|
background: 'rgba(12,24,37,0.95)', border: '1px solid rgba(99,179,237,0.25)',
|
||||||
left: '50%',
|
borderRadius: 8, padding: '8px 14px', display: 'flex', flexDirection: 'column', gap: 4,
|
||||||
transform: 'translateX(-50%)',
|
zIndex: 20, fontFamily: FONT_MONO, fontSize: 10, color: '#e2e8f0',
|
||||||
background: 'rgba(12,24,37,0.95)',
|
boxShadow: '0 4px 16px rgba(0,0,0,0.5)', pointerEvents: 'auto', minWidth: 420,
|
||||||
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,
|
|
||||||
}}>
|
}}>
|
||||||
{/* 프로그레스 바 — 갭 표시 */}
|
{/* 프로그레스 바 */}
|
||||||
<div style={{
|
<div style={{ position: 'relative', height: 8, background: 'rgba(255,255,255,0.05)', borderRadius: 4, overflow: 'hidden' }}>
|
||||||
position: 'relative',
|
|
||||||
height: 8,
|
|
||||||
background: 'rgba(255,255,255,0.05)',
|
|
||||||
borderRadius: 4,
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}>
|
|
||||||
{snapshotRanges.map((pos, i) => (
|
{snapshotRanges.map((pos, i) => (
|
||||||
<div key={i} style={{
|
<div key={i} style={{
|
||||||
position: 'absolute',
|
position: 'absolute', left: `${pos * 100}%`, top: 0, width: 2, height: '100%',
|
||||||
left: `${pos * 100}%`,
|
|
||||||
top: 0,
|
|
||||||
width: 2,
|
|
||||||
height: '100%',
|
|
||||||
background: 'rgba(251,191,36,0.4)',
|
background: 'rgba(251,191,36,0.4)',
|
||||||
}} />
|
}} />
|
||||||
))}
|
))}
|
||||||
{/* 현재 위치 인디케이터 (DOM ref로 업데이트) */}
|
|
||||||
<div ref={progressIndicatorRef} style={{
|
<div ref={progressIndicatorRef} style={{
|
||||||
position: 'absolute',
|
position: 'absolute', left: '0%', top: -1, width: 3, height: 10,
|
||||||
left: '0%',
|
background: '#fbbf24', borderRadius: 1, transform: 'translateX(-50%)',
|
||||||
top: -1,
|
|
||||||
width: 3,
|
|
||||||
height: 10,
|
|
||||||
background: '#fbbf24',
|
|
||||||
borderRadius: 1,
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 컨트롤 행 */}
|
{/* 컨트롤 행 1: 재생 + 타임라인 */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
<button
|
<button type="button" onClick={() => { if (isPlaying) store.getState().pause(); else store.getState().play(); }}
|
||||||
type="button"
|
style={{ ...btnStyle, fontSize: 12 }}>
|
||||||
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,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isPlaying ? '⏸' : '▶'}
|
{isPlaying ? '⏸' : '▶'}
|
||||||
</button>
|
</button>
|
||||||
|
<span ref={timeDisplayRef} style={{ color: '#fbbf24', minWidth: 40, textAlign: 'center' }}>--:--</span>
|
||||||
<span
|
<input ref={progressBarRef} type="range" min={0} max={1000} defaultValue={0}
|
||||||
ref={timeDisplayRef}
|
|
||||||
style={{ color: '#fbbf24', minWidth: 40, textAlign: 'center' }}
|
|
||||||
>
|
|
||||||
--:--
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<input
|
|
||||||
ref={progressBarRef}
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={1000}
|
|
||||||
defaultValue={0}
|
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
const { startTime, endTime } = store.getState();
|
const { startTime, endTime } = store.getState();
|
||||||
const progress = Number(e.target.value) / 1000;
|
const progress = Number(e.target.value) / 1000;
|
||||||
const seekTime = startTime + progress * (endTime - startTime);
|
|
||||||
store.getState().pause();
|
store.getState().pause();
|
||||||
store.getState().seek(seekTime);
|
store.getState().seek(startTime + progress * (endTime - startTime));
|
||||||
}}
|
}}
|
||||||
style={{ flex: 1, cursor: 'pointer', accentColor: '#fbbf24' }}
|
style={{ flex: 1, cursor: 'pointer', accentColor: '#fbbf24' }}
|
||||||
title="히스토리 타임라인"
|
title="히스토리 타임라인" aria-label="히스토리 타임라인" />
|
||||||
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 }}>
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -64,6 +64,8 @@ export function useGearReplayLayers(
|
|||||||
const enabledVessels = useGearReplayStore(s => s.enabledVessels);
|
const enabledVessels = useGearReplayStore(s => s.enabledVessels);
|
||||||
const hoveredMmsi = useGearReplayStore(s => s.hoveredMmsi);
|
const hoveredMmsi = useGearReplayStore(s => s.hoveredMmsi);
|
||||||
const correlationByModel = useGearReplayStore(s => s.correlationByModel);
|
const correlationByModel = useGearReplayStore(s => s.correlationByModel);
|
||||||
|
const showTrails = useGearReplayStore(s => s.showTrails);
|
||||||
|
const showLabels = useGearReplayStore(s => s.showLabels);
|
||||||
|
|
||||||
// ── Refs ─────────────────────────────────────────────────────────────────
|
// ── Refs ─────────────────────────────────────────────────────────────────
|
||||||
const cursorRef = useRef(0); // frame cursor for O(1) forward lookup
|
const cursorRef = useRef(0); // frame cursor for O(1) forward lookup
|
||||||
@ -95,23 +97,25 @@ export function useGearReplayLayers(
|
|||||||
|
|
||||||
// ── Static layers (center trail + dots) ───────────────────────────────
|
// ── Static layers (center trail + dots) ───────────────────────────────
|
||||||
|
|
||||||
// Center trail segments (PathLayer)
|
// Center trail segments (PathLayer) — showTrails 제어
|
||||||
for (let i = 0; i < centerTrailSegments.length; i++) {
|
if (showTrails) {
|
||||||
const seg = centerTrailSegments[i];
|
for (let i = 0; i < centerTrailSegments.length; i++) {
|
||||||
if (seg.path.length < 2) continue;
|
const seg = centerTrailSegments[i];
|
||||||
layers.push(new PathLayer({
|
if (seg.path.length < 2) continue;
|
||||||
id: `replay-center-trail-${i}`,
|
layers.push(new PathLayer({
|
||||||
data: [{ path: seg.path }],
|
id: `replay-center-trail-${i}`,
|
||||||
getPath: (d: { path: [number, number][] }) => d.path,
|
data: [{ path: seg.path }],
|
||||||
getColor: seg.isInterpolated
|
getPath: (d: { path: [number, number][] }) => d.path,
|
||||||
? [249, 115, 22, 200]
|
getColor: seg.isInterpolated
|
||||||
: [251, 191, 36, 180],
|
? [249, 115, 22, 200]
|
||||||
widthMinPixels: 2,
|
: [251, 191, 36, 180],
|
||||||
}));
|
widthMinPixels: 2,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Center dots (real data only)
|
// Center dots (real data only) — showTrails 제어
|
||||||
if (centerDotsPositions.length > 0) {
|
if (showTrails && centerDotsPositions.length > 0) {
|
||||||
layers.push(new ScatterplotLayer({
|
layers.push(new ScatterplotLayer({
|
||||||
id: 'replay-center-dots',
|
id: 'replay-center-dots',
|
||||||
data: centerDotsPositions,
|
data: centerDotsPositions,
|
||||||
@ -141,8 +145,8 @@ export function useGearReplayLayers(
|
|||||||
|
|
||||||
// 1. Identity 모델: 멤버 트레일 + 폴리곤 + 마커 (enabledModels 체크)
|
// 1. Identity 모델: 멤버 트레일 + 폴리곤 + 마커 (enabledModels 체크)
|
||||||
if (enabledModels.has('identity')) {
|
if (enabledModels.has('identity')) {
|
||||||
// TripsLayer — member trails (GPU animated)
|
// TripsLayer — member trails (GPU animated, showTrails 제어)
|
||||||
if (memberTripsData.length > 0) {
|
if (showTrails && memberTripsData.length > 0) {
|
||||||
layers.push(new TripsLayer({
|
layers.push(new TripsLayer({
|
||||||
id: 'replay-member-trails',
|
id: 'replay-member-trails',
|
||||||
data: memberTripsData,
|
data: memberTripsData,
|
||||||
@ -173,8 +177,8 @@ export function useGearReplayLayers(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Correlation trails (GPU animated, enabledModels 체크)
|
// 2. Correlation trails (GPU animated, showTrails + enabledModels 체크)
|
||||||
if (correlationTripsData.length > 0) {
|
if (showTrails && correlationTripsData.length > 0) {
|
||||||
// 활성 모델에 속하는 선박의 트랙만 표시
|
// 활성 모델에 속하는 선박의 트랙만 표시
|
||||||
const activeMmsis = new Set<string>();
|
const activeMmsis = new Set<string>();
|
||||||
for (const [mn, items] of correlationByModel) {
|
for (const [mn, items] of correlationByModel) {
|
||||||
@ -231,8 +235,8 @@ export function useGearReplayLayers(
|
|||||||
billboard: false,
|
billboard: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Member labels
|
// Member labels — showLabels 제어
|
||||||
layers.push(new TextLayer<MemberPosition>({
|
if (showLabels) layers.push(new TextLayer<MemberPosition>({
|
||||||
id: 'replay-member-labels',
|
id: 'replay-member-labels',
|
||||||
data: members,
|
data: members,
|
||||||
getPosition: d => [d.lon, d.lat],
|
getPosition: d => [d.lon, d.lat],
|
||||||
@ -263,6 +267,7 @@ export function useGearReplayLayers(
|
|||||||
const [r, g, b] = hexToRgb(color);
|
const [r, g, b] = hexToRgb(color);
|
||||||
|
|
||||||
for (const c of items as GearCorrelationItem[]) {
|
for (const c of items as GearCorrelationItem[]) {
|
||||||
|
if (!enabledVessels.has(c.targetMmsi)) continue; // OFF → 아이콘+트레일+폴리곤 모두 제외
|
||||||
if (corrPositions.some(p => p.mmsi === c.targetMmsi)) continue;
|
if (corrPositions.some(p => p.mmsi === c.targetMmsi)) continue;
|
||||||
|
|
||||||
let lon: number | undefined;
|
let lon: number | undefined;
|
||||||
@ -373,7 +378,7 @@ export function useGearReplayLayers(
|
|||||||
billboard: false,
|
billboard: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
layers.push(new TextLayer<CorrPosition>({
|
if (showLabels) layers.push(new TextLayer<CorrPosition>({
|
||||||
id: 'replay-corr-labels',
|
id: 'replay-corr-labels',
|
||||||
data: corrPositions,
|
data: corrPositions,
|
||||||
getPosition: d => [d.lon, d.lat],
|
getPosition: d => [d.lon, d.lat],
|
||||||
@ -541,6 +546,7 @@ export function useGearReplayLayers(
|
|||||||
historyFrames, memberTripsData, correlationTripsData,
|
historyFrames, memberTripsData, correlationTripsData,
|
||||||
centerTrailSegments, centerDotsPositions,
|
centerTrailSegments, centerDotsPositions,
|
||||||
enabledModels, enabledVessels, hoveredMmsi, correlationByModel,
|
enabledModels, enabledVessels, hoveredMmsi, correlationByModel,
|
||||||
|
showTrails, showLabels,
|
||||||
replayLayerRef, requestRender,
|
replayLayerRef, requestRender,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@ -64,11 +64,13 @@ interface GearReplayState {
|
|||||||
centerDotsPositions: [number, number][];
|
centerDotsPositions: [number, number][];
|
||||||
snapshotRanges: number[];
|
snapshotRanges: number[];
|
||||||
|
|
||||||
// Filter state
|
// Filter / display state
|
||||||
enabledModels: Set<string>;
|
enabledModels: Set<string>;
|
||||||
enabledVessels: Set<string>;
|
enabledVessels: Set<string>;
|
||||||
hoveredMmsi: string | null;
|
hoveredMmsi: string | null;
|
||||||
correlationByModel: Map<string, GearCorrelationItem[]>;
|
correlationByModel: Map<string, GearCorrelationItem[]>;
|
||||||
|
showTrails: boolean;
|
||||||
|
showLabels: boolean;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
loadHistory: (
|
loadHistory: (
|
||||||
@ -85,6 +87,8 @@ interface GearReplayState {
|
|||||||
setEnabledModels: (models: Set<string>) => void;
|
setEnabledModels: (models: Set<string>) => void;
|
||||||
setEnabledVessels: (vessels: Set<string>) => void;
|
setEnabledVessels: (vessels: Set<string>) => void;
|
||||||
setHoveredMmsi: (mmsi: string | null) => void;
|
setHoveredMmsi: (mmsi: string | null) => void;
|
||||||
|
setShowTrails: (show: boolean) => void;
|
||||||
|
setShowLabels: (show: boolean) => void;
|
||||||
updateCorrelation: (corrData: GearCorrelationItem[], corrTracks: CorrelationVesselTrack[]) => void;
|
updateCorrelation: (corrData: GearCorrelationItem[], corrTracks: CorrelationVesselTrack[]) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
@ -135,10 +139,12 @@ export const useGearReplayStore = create<GearReplayState>()(
|
|||||||
centerDotsPositions: [],
|
centerDotsPositions: [],
|
||||||
snapshotRanges: [],
|
snapshotRanges: [],
|
||||||
|
|
||||||
// Filter state
|
// Filter / display state
|
||||||
enabledModels: new Set<string>(),
|
enabledModels: new Set<string>(),
|
||||||
enabledVessels: new Set<string>(),
|
enabledVessels: new Set<string>(),
|
||||||
hoveredMmsi: null,
|
hoveredMmsi: null,
|
||||||
|
showTrails: true,
|
||||||
|
showLabels: true,
|
||||||
correlationByModel: new Map<string, GearCorrelationItem[]>(),
|
correlationByModel: new Map<string, GearCorrelationItem[]>(),
|
||||||
|
|
||||||
// ── Actions ────────────────────────────────────────────────
|
// ── Actions ────────────────────────────────────────────────
|
||||||
@ -214,6 +220,8 @@ export const useGearReplayStore = create<GearReplayState>()(
|
|||||||
setEnabledVessels: (vessels) => set({ enabledVessels: vessels }),
|
setEnabledVessels: (vessels) => set({ enabledVessels: vessels }),
|
||||||
|
|
||||||
setHoveredMmsi: (mmsi) => set({ hoveredMmsi: mmsi }),
|
setHoveredMmsi: (mmsi) => set({ hoveredMmsi: mmsi }),
|
||||||
|
setShowTrails: (show) => set({ showTrails: show }),
|
||||||
|
setShowLabels: (show) => set({ showLabels: show }),
|
||||||
|
|
||||||
updateCorrelation: (corrData, corrTracks) => {
|
updateCorrelation: (corrData, corrTracks) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
@ -260,6 +268,8 @@ export const useGearReplayStore = create<GearReplayState>()(
|
|||||||
enabledModels: new Set<string>(),
|
enabledModels: new Set<string>(),
|
||||||
enabledVessels: new Set<string>(),
|
enabledVessels: new Set<string>(),
|
||||||
hoveredMmsi: null,
|
hoveredMmsi: null,
|
||||||
|
showTrails: true,
|
||||||
|
showLabels: true,
|
||||||
correlationByModel: new Map<string, GearCorrelationItem[]>(),
|
correlationByModel: new Map<string, GearCorrelationItem[]>(),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user