feat: 리플레이 범례/궤적 최적화/로딩 프로그레스/UI 개선

- 리플레이 전용 범례(ReplayLegend): 재생 시점 선종별 카운트, 필터 동기화
- 궤적 표시 성능 최적화: 거리 필터(100m), 역비례 프레임 계산, 배속별 동일 시각적 길이
- 궤적 필터 동기화: 선종 OFF 즉시 제거, 프로그레스 드래그 시 클리어, 배속 변경 시 리셋
- 리플레이 로딩 프로그레스: 화면 중앙 원형 오버레이, 머지 타임스탬프 기반 진행률
- 선박 메뉴에서 필터 패널 직접 열기, 좌측 하단 필터/레이어 버튼 비활성화

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
HeungTak Lee 2026-02-05 09:59:30 +09:00
부모 1e317c1cbe
커밋 19b2cff39e
11개의 변경된 파일528개의 추가작업 그리고 102개의 파일을 삭제

파일 보기

@ -14,9 +14,10 @@ const gnbList = [
// { key: 'gnb8', className: 'gnb8', label: '', path: 'tracking' },
];
// / (gnb1) DisplayComponent
const sideList = [
{ key: 'filter', className: 'filter', label: '필터', path: 'filter' },
{ key: 'layer', className: 'layer', label: '레이어', path: 'layer' },
// { key: 'filter', className: 'filter', label: '', path: 'filter' },
// { key: 'layer', className: 'layer', label: '', path: 'layer' },
];
export default function SideNav({ activeKey, onChange }) {

파일 보기

@ -59,7 +59,7 @@ export default function Sidebar() {
const renderPanel = () => {
switch (activeKey) {
case 'gnb1':
return <Panel1Component {...panelProps} />;
return <DisplayComponent {...panelProps} initialTab="filter" />;
case 'gnb2':
return <Panel2Component {...panelProps} />;
case 'gnb3':

파일 보기

@ -218,7 +218,7 @@ export default function ToolBar() {
범례
</button>
</li>
<li><button type="button" className="minimap">미니맵</button></li>
{/*<li><button type="button" className="minimap">미니맵</button></li>*/}
</ul>
</div>
</section>

파일 보기

@ -0,0 +1,130 @@
/**
* 리플레이 전용 범례 컴포넌트
* - 리플레이 모드에서 선종별 카운트 표시
* - ShipLegend 디자인 재사용, replayStore 연동
*/
import { memo } from 'react';
import { shallow } from 'zustand/shallow';
import useReplayStore from '../../replay/stores/replayStore';
import {
SIGNAL_KIND_CODE_FISHING,
SIGNAL_KIND_CODE_KCGV,
SIGNAL_KIND_CODE_PASSENGER,
SIGNAL_KIND_CODE_CARGO,
SIGNAL_KIND_CODE_TANKER,
SIGNAL_KIND_CODE_GOV,
SIGNAL_KIND_CODE_NORMAL,
SIGNAL_KIND_CODE_BUOY,
} from '../../types/constants';
import './ShipLegend.scss';
// SVG
import fishingIcon from '../../assets/img/shipKindIcons/fishing.svg';
import passIcon from '../../assets/img/shipKindIcons/pass.svg';
import cargoIcon from '../../assets/img/shipKindIcons/cargo.svg';
import hazardIcon from '../../assets/img/shipKindIcons/hazard.svg';
import govIcon from '../../assets/img/shipKindIcons/gov.svg';
import kcgvIcon from '../../assets/img/shipKindIcons/kcgv.svg';
import bouyIcon from '../../assets/img/shipKindIcons/bouy.svg';
import etcIcon from '../../assets/img/shipKindIcons/etc.svg';
/**
* 선박 종류 코드 아이콘 매핑
*/
const SHIP_KIND_ICONS = {
[SIGNAL_KIND_CODE_FISHING]: fishingIcon,
[SIGNAL_KIND_CODE_KCGV]: kcgvIcon,
[SIGNAL_KIND_CODE_PASSENGER]: passIcon,
[SIGNAL_KIND_CODE_CARGO]: cargoIcon,
[SIGNAL_KIND_CODE_TANKER]: hazardIcon,
[SIGNAL_KIND_CODE_GOV]: govIcon,
[SIGNAL_KIND_CODE_NORMAL]: etcIcon,
[SIGNAL_KIND_CODE_BUOY]: bouyIcon,
};
/**
* 범례 항목 설정
*/
const LEGEND_ITEMS = [
{ code: SIGNAL_KIND_CODE_FISHING, label: '어선' },
{ code: SIGNAL_KIND_CODE_PASSENGER, label: '여객선' },
{ code: SIGNAL_KIND_CODE_CARGO, label: '화물선' },
{ code: SIGNAL_KIND_CODE_TANKER, label: '유조선' },
{ code: SIGNAL_KIND_CODE_GOV, label: '관공선' },
{ code: SIGNAL_KIND_CODE_KCGV, label: '경비함정' },
{ code: SIGNAL_KIND_CODE_BUOY, label: '어망/부이' },
{ code: SIGNAL_KIND_CODE_NORMAL, label: '기타' },
];
/**
* 리플레이 범례 항목
*/
const ReplayLegendItem = memo(({ code, label, count, icon, isVisible, onToggle }) => {
const isBuoy = code === SIGNAL_KIND_CODE_BUOY;
return (
<li className={`legend-item ${!isVisible ? 'disabled' : ''}`}>
<div className="legend-info" onClick={() => onToggle(code)}>
<span className="legend-icon">
<img
src={icon}
alt={label}
style={{ transform: isBuoy ? 'rotate(0deg)' : 'rotate(45deg)' }}
/>
</span>
<span className="legend-label">{label}</span>
</div>
<span className="legend-count">{count}</span>
</li>
);
});
/**
* 리플레이 전용 범례 컴포넌트
*/
const ReplayLegend = memo(() => {
const { replayShipCounts, replayTotalCount, shipKindCodeFilter } =
useReplayStore(
(state) => ({
replayShipCounts: state.replayShipCounts,
replayTotalCount: state.replayTotalCount,
shipKindCodeFilter: state.shipKindCodeFilter,
}),
shallow
);
const toggleShipKindCode = useReplayStore((state) => state.toggleShipKindCode);
return (
<article className="ship-legend">
{/* 헤더 */}
<div className="legend-header">
<div className="legend-title">
<span>리플레이 현황</span>
</div>
</div>
{/* 선박 종류별 목록 */}
<ul className="legend-list">
{LEGEND_ITEMS.map((item) => (
<ReplayLegendItem
key={item.code}
code={item.code}
label={item.label}
count={replayShipCounts[item.code] || 0}
icon={SHIP_KIND_ICONS[item.code]}
isVisible={shipKindCodeFilter.has(item.code)}
onToggle={toggleShipKindCode}
/>
))}
</ul>
{/* 푸터 - 전체 카운트 */}
<div className="legend-footer">
<span>전체</span>
<span className="total-count">{replayTotalCount}</span>
</div>
</article>
);
});
export default ReplayLegend;

파일 보기

@ -12,6 +12,7 @@ import useShipStore from '../stores/shipStore';
import useShipData from '../hooks/useShipData';
import useShipLayer from '../hooks/useShipLayer';
import ShipLegend from '../components/ship/ShipLegend';
import ReplayLegend from '../components/ship/ReplayLegend';
import ShipTooltip from '../components/ship/ShipTooltip';
import ShipDetailModal from '../components/ship/ShipDetailModal';
import ShipContextMenu from '../components/ship/ShipContextMenu';
@ -22,6 +23,7 @@ import { shipBatchRenderer } from './ShipBatchRenderer';
import useReplayStore from '../replay/stores/replayStore';
import useAnimationStore from '../replay/stores/animationStore';
import ReplayTimeline from '../replay/components/ReplayTimeline';
import ReplayLoadingOverlay from '../replay/components/ReplayLoadingOverlay';
import { unregisterReplayLayers } from '../replay/utils/replayLayerRegistry';
import { showLiveShips } from '../utils/liveControl';
import { useTrackQueryStore } from '../tracking/stores/trackQueryStore';
@ -440,7 +442,7 @@ export default function MapContainer() {
<>
<div id="map" ref={mapRef} className="map-container" />
<TopBar />
{showLegend && <ShipLegend />}
{showLegend && (replayCompleted ? <ReplayLegend /> : <ShipLegend />)}
{hoverInfo && (
<ShipTooltip ship={hoverInfo.ship} x={hoverInfo.x} y={hoverInfo.y} />
)}
@ -449,6 +451,7 @@ export default function MapContainer() {
))}
<ShipContextMenu />
<GlobalTrackQueryViewer />
<ReplayLoadingOverlay />
{replayCompleted && (
<ReplayTimeline
fromDate={replayQuery?.startTime}

파일 보기

@ -0,0 +1,81 @@
/**
* 리플레이 데이터 로딩 프로그레스 오버레이
* - 브라우저 중앙에 표시
* - mergedTrackStore.timeRange 기반 진행률 계산 (청크 머지 완료 시점 기준)
*/
import { memo } from 'react';
import useReplayStore from '../stores/replayStore';
import useMergedTrackStore from '../stores/mergedTrackStore';
import { ConnectionState } from '../types/replay.types';
import './ReplayLoadingOverlay.scss';
/** 원형 프로그레스 설정 */
const CIRCLE_RADIUS = 54;
const CIRCLE_CIRCUMFERENCE = 2 * Math.PI * CIRCLE_RADIUS;
const ReplayLoadingOverlay = memo(() => {
const currentQuery = useReplayStore((s) => s.currentQuery);
const queryCompleted = useReplayStore((s) => s.queryCompleted);
const connectionState = useReplayStore((s) => s.connectionState);
const timeRange = useMergedTrackStore((s) => s.timeRange);
// : +
const isLoading = currentQuery !== null
&& !queryCompleted
&& (connectionState === ConnectionState.CONNECTING || connectionState === ConnectionState.CONNECTED);
if (!isLoading) return null;
// :
let progress = 0;
let statusText = '서버 연결 중...';
if (connectionState === ConnectionState.CONNECTED) {
if (timeRange && currentQuery.startTime && currentQuery.endTime) {
const queryStart = new Date(currentQuery.startTime).getTime();
const queryEnd = new Date(currentQuery.endTime).getTime();
const totalDuration = queryEnd - queryStart;
if (totalDuration > 0 && timeRange.end > queryStart) {
progress = Math.min(((timeRange.end - queryStart) / totalDuration) * 100, 99);
}
}
statusText = '데이터 수신 중...';
}
const progressInt = Math.round(progress);
const strokeDashoffset = CIRCLE_CIRCUMFERENCE - (progress / 100) * CIRCLE_CIRCUMFERENCE;
return (
<div className="replay-loading-overlay">
<div className="loading-content">
{/* 원형 프로그레스 */}
<div className="progress-circle-wrapper">
<svg className="progress-circle" viewBox="0 0 120 120">
{/* 배경 원 */}
<circle
className="progress-bg"
cx="60" cy="60" r={CIRCLE_RADIUS}
fill="none"
strokeWidth="6"
/>
{/* 진행 원 */}
<circle
className="progress-bar"
cx="60" cy="60" r={CIRCLE_RADIUS}
fill="none"
strokeWidth="6"
strokeDasharray={CIRCLE_CIRCUMFERENCE}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
/>
</svg>
<span className="progress-text">{progressInt}%</span>
</div>
<p className="status-text">{statusText}</p>
</div>
</div>
);
});
export default ReplayLoadingOverlay;

파일 보기

@ -0,0 +1,66 @@
/**
* 리플레이 로딩 오버레이 스타일
*/
.replay-loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.45);
pointer-events: none;
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.6rem;
padding: 3rem 4rem;
background: rgba(20, 25, 35, 0.92);
border-radius: 1.2rem;
border: 1px solid rgba(74, 158, 255, 0.25);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.progress-circle-wrapper {
position: relative;
width: 120px;
height: 120px;
}
.progress-circle {
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.progress-bg {
stroke: rgba(255, 255, 255, 0.1);
}
.progress-bar {
stroke: #4a9eff;
transition: stroke-dashoffset 0.4s ease;
}
.progress-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 2.4rem;
font-weight: 700;
color: #fff;
font-family: 'Segoe UI', sans-serif;
}
.status-text {
font-size: 1.3rem;
color: rgba(255, 255, 255, 0.7);
margin: 0;
}
}

파일 보기

@ -78,6 +78,7 @@ export default function ReplayTimeline({ fromDate, toDate, onClose }) {
//
const [isDragging, setIsDragging] = useState(false);
const [hasDragged, setHasDragged] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const containerRef = useRef(null);
@ -104,22 +105,31 @@ export default function ReplayTimeline({ fromDate, toDate, onClose }) {
}, [showSpeedMenu]);
//
// CSS (left:50% + translateX(-50%)) (left/top)
// transform /
const handleMouseDown = useCallback((e) => {
if (containerRef.current) {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
setDragOffset({
x: e.clientX - rect.left - position.x,
y: e.clientY - rect.top - position.y,
});
setIsDragging(true);
const parent = containerRef.current.parentElement;
if (!parent) return;
const parentRect = parent.getBoundingClientRect();
//
setDragOffset({ x: e.clientX - rect.left, y: e.clientY - rect.top });
if (!hasDragged) {
// : CSS
setPosition({ x: rect.left - parentRect.left, y: rect.top - parentRect.top });
setHasDragged(true);
}
}, [position]);
setIsDragging(true);
}, [hasDragged]);
useEffect(() => {
const handleMouseMove = (e) => {
if (isDragging && containerRef.current) {
if (!isDragging || !containerRef.current) return;
const parent = containerRef.current.parentElement;
if (parent) {
if (!parent) return;
const parentRect = parent.getBoundingClientRect();
const containerRect = containerRef.current.getBoundingClientRect();
@ -131,8 +141,6 @@ export default function ReplayTimeline({ fromDate, toDate, onClose }) {
newY = Math.max(0, Math.min(newY, parentRect.height - containerRect.height));
setPosition({ x: newX, y: newY });
}
}
};
const handleMouseUp = () => {
@ -172,6 +180,23 @@ export default function ReplayTimeline({ fromDate, toDate, onClose }) {
setTrailEnabled(!isTrailEnabled);
}, [setTrailEnabled, isTrailEnabled]);
// ( + )
const handleSliderPointerDown = useCallback(() => {
const trailStore = usePlaybackTrailStore.getState();
if (trailStore.isEnabled) {
trailStore.setScrubbing(true);
}
const handlePointerUp = () => {
const ts = usePlaybackTrailStore.getState();
if (ts.isScrubbing) {
ts.setScrubbing(false);
}
document.removeEventListener('pointerup', handlePointerUp);
};
document.addEventListener('pointerup', handlePointerUp);
}, []);
//
const handleSliderChange = useCallback((e) => {
const newTime = parseFloat(e.target.value);
@ -189,11 +214,12 @@ export default function ReplayTimeline({ fromDate, toDate, onClose }) {
<div
ref={containerRef}
className={`replay-timeline ${isPlaying ? 'playing' : ''} ${isDragging ? 'dragging' : ''}`}
style={{
transform: position.x !== 0 || position.y !== 0
? `translate(${position.x}px, ${position.y}px)`
: undefined,
}}
style={hasDragged ? {
left: `${position.x}px`,
top: `${position.y}px`,
bottom: 'auto',
transform: 'none',
} : undefined}
>
{/* 드래그 가능한 헤더 */}
<div className="timeline-header" onMouseDown={handleMouseDown}>
@ -267,6 +293,7 @@ export default function ReplayTimeline({ fromDate, toDate, onClose }) {
max={endTime}
step={(endTime - startTime) / 1000}
value={currentTime}
onPointerDown={handleSliderPointerDown}
onChange={handleSliderChange}
disabled={!hasData}
style={{ '--progress': `${progress}%` }}

파일 보기

@ -27,6 +27,7 @@ import { createStaticTrackLayers, createVirtualShipLayers } from '../../map/laye
import { registerReplayLayers, unregisterReplayLayers } from '../utils/replayLayerRegistry';
import { shipBatchRenderer } from '../../map/ShipBatchRenderer';
import { hideLiveShips, showLiveShips } from '../../utils/liveControl';
import { SIGNAL_KIND_CODE_NORMAL } from '../../types/constants';
/**
* 선박 상태에 따라 표시 여부 결정
@ -216,6 +217,14 @@ export default function useReplayLayer() {
// 위치 필터링
const { iconPositions, labelPositions } = filterPositions(formattedPositions);
// 선종별 카운트 계산 → replayStore에 저장
const counts = {};
iconPositions.forEach((pos) => {
const code = pos.shipKindCode || SIGNAL_KIND_CODE_NORMAL;
counts[code] = (counts[code] || 0) + 1;
});
useReplayStore.getState().setReplayShipCounts(counts);
// 트랙 필터링
const filteredTracks = filterTracks(tracksRef.current);
@ -223,13 +232,14 @@ export default function useReplayLayer() {
const trailStore = usePlaybackTrailStore.getState();
const layers = [];
// 항적표시가 활성화되어 있으면 프레임 기록
// 항적표시가 활성화되어 있으면 프레임 기록 (shipKindCode 포함)
if (trailStore.isEnabled && iconPositions.length > 0) {
trailStore.recordFrame(
iconPositions.map((pos) => ({
vesselId: pos.vesselId,
lon: pos.lon,
lat: pos.lat,
shipKindCode: pos.shipKindCode,
}))
);
}
@ -347,10 +357,17 @@ export default function useReplayLayer() {
}, [currentTime, queryCompleted, requestAnimatedRender]);
/**
* 필터 변경 재렌더링
* 필터 변경 재렌더링 + 궤적 필터 동기화
*/
useEffect(() => {
if (!queryCompleted) return;
// 선종 필터 OFF 시 해당 선종 궤적 즉시 제거
const trailStore = usePlaybackTrailStore.getState();
if (trailStore.isEnabled && trailStore.trails.size > 0) {
trailStore.removeTrailsByFilter(shipKindCodeFilter);
}
requestAnimatedRender();
}, [filterModules, shipKindCodeFilter, vesselStates, deletedVesselIds, selectedVesselIds, queryCompleted, requestAnimatedRender]);

파일 보기

@ -3,41 +3,45 @@
* 참조: mda-react-front/src/tracking/stores/playbackTrailStore.ts
*
* 애니메이션 재생 선박의 이동 궤적을 반투명 점으로 표시
* - 거리 기반 필터: 이전 위치와 일정 거리 이상일 때만 포인트 추가
* - 시간이 지나면 크기 축소, 투명도 증가
* - 기준 프레임 초과 제거
* - 배속에 따라 maxFrames 동적 조절
* - 선종 필터 동기화: 필터 OFF 해당 선종 궤적 즉시 제거
* - 프로그레스 스크러빙: 드래그 궤적 일시정지 + 클리어
*/
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
// 기본 설정값 (75% 수준으로 조정 - 성능 최적화)
const BASE_MAX_FRAMES = 45; // 기본 프레임 수 (1x 배속 기준)
const MIN_MAX_FRAMES = 25; // 최소 프레임 수
const MAX_MAX_FRAMES = 225; // 최대 프레임 수
const DEFAULT_MAX_POINT_SIZE = 4; // 최대 4px
const DEFAULT_MIN_POINT_SIZE = 1; // 최소 1px
// 프레임 설정 (역비례 계산 — 배속 무관 동일 시각적 궤적 길이)
const REFERENCE_SPEED = 1000; // 기준 배속 (1000x에서 궤적 길이가 적절)
const REFERENCE_FRAMES = 60; // 기준 배속에서의 프레임 수
const MIN_MAX_FRAMES = 8; // 최소 프레임 수
const MAX_MAX_FRAMES = 150; // 최대 프레임 수 (저배속 cap)
// 포인트 크기
const DEFAULT_MAX_POINT_SIZE = 4;
const DEFAULT_MIN_POINT_SIZE = 1;
// 거리 기반 필터 (제곱 거리, sqrt 연산 회피)
// 0.001도 ≈ 약 100m (중위도 기준) → 제곱: 0.000001
const MIN_TRAIL_DISTANCE_SQ = 0.000001;
/**
* 배속에 따른 maxFrames 계산
* 배속이 빠르면 많은 프레임을 유지해서 비슷한 시간 길이의 항적 표시
* @param {number} playbackSpeed - 재생 배속 (0.5, 1, 2, 4, 8, 16 )
* @returns {number} maxFrames
* 배속에 따른 maxFrames 계산 (역비례)
* 배속이 낮으면 프레임당 이동거리가 짧으므로 많은 프레임을 유지해
* 모든 배속에서 동일한 시각적 궤적 길이를 보장
*
* 1000x: 60, 500x: 120, 100x: 150(cap), 50x~1x: 150(cap)
*/
const calculateMaxFrames = (playbackSpeed) => {
// 배속에 비례하여 프레임 수 증가
// 1x: 60프레임, 2x: 120프레임, 4x: 240프레임, 0.5x: 30프레임
const frames = Math.round(BASE_MAX_FRAMES * playbackSpeed);
if (playbackSpeed <= 0) return REFERENCE_FRAMES;
const frames = Math.round(REFERENCE_FRAMES * REFERENCE_SPEED / playbackSpeed);
return Math.max(MIN_MAX_FRAMES, Math.min(MAX_MAX_FRAMES, frames));
};
/**
* 재생 항적 스토어
*
* @typedef {Object} PlaybackTrailPoint
* @property {number} lon - 경도
* @property {number} lat - 위도
* @property {number} frameIndex - 기록된 프레임 인덱스
* @property {string} vesselId - 선박 ID
*/
const usePlaybackTrailStore = create(
subscribeWithSelector((set, get) => ({
@ -46,17 +50,26 @@ const usePlaybackTrailStore = create(
/** 항적표시 토글 상태 */
isEnabled: false,
/** 선박별 항적 포인트 Map (vesselId -> PlaybackTrailPoint[]) */
/** 선박별 항적 포인트 Map (vesselId -> TrailPoint[]) */
trails: new Map(),
/** 현재 프레임 인덱스 (렌더링마다 증가) */
/** 선박별 마지막 기록 위치 (거리 필터용) */
lastPositions: new Map(),
/** 선박별 선종 코드 (필터 동기화용) */
vesselKindCodes: new Map(),
/** 현재 프레임 인덱스 */
frameIndex: 0,
/** 프로그레스 바 스크러빙 중 여부 */
isScrubbing: false,
/** 현재 재생 배속 */
playbackSpeed: 1,
/** 유지할 최대 프레임 수 (배속에 따라 동적 계산) */
maxFrames: BASE_MAX_FRAMES,
/** 유지할 최대 프레임 수 */
maxFrames: MAX_MAX_FRAMES,
/** 포인트 최대 크기 (px) */
maxPointSize: DEFAULT_MAX_POINT_SIZE,
@ -68,14 +81,14 @@ const usePlaybackTrailStore = create(
/**
* 토글 ON/OFF
* @param {boolean} enabled
*/
setEnabled: (enabled) => {
if (!enabled) {
// OFF 시 전체 초기화
set({
isEnabled: false,
trails: new Map(),
lastPositions: new Map(),
vesselKindCodes: new Map(),
frameIndex: 0,
});
} else {
@ -84,88 +97,158 @@ const usePlaybackTrailStore = create(
},
/**
* 항적 전체 초기화 (정지, 슬라이더 탐색 호출)
* 항적 전체 초기화
*/
clearTrails: () => {
set({
trails: new Map(),
lastPositions: new Map(),
vesselKindCodes: new Map(),
frameIndex: 0,
});
},
/**
* 스크러빙 상태 설정
* true: 궤적 클리어 + 기록 중단
* false: 기록 재개 (재생 상태이면 자동으로 다시 그려짐)
*/
setScrubbing: (scrubbing) => {
if (scrubbing) {
set({
isScrubbing: true,
trails: new Map(),
lastPositions: new Map(),
vesselKindCodes: new Map(),
frameIndex: 0,
});
} else {
set({ isScrubbing: false });
}
},
/**
* 선종 필터와 궤적 동기화
* activeKindCodes에 포함되지 않는 선종의 궤적을 즉시 제거
* @param {Set} activeKindCodes - 현재 활성화된 선종 코드 Set
*/
removeTrailsByFilter: (activeKindCodes) => {
const state = get();
if (state.trails.size === 0) return;
const newTrails = new Map(state.trails);
const newLastPositions = new Map(state.lastPositions);
const newVesselKindCodes = new Map(state.vesselKindCodes);
let changed = false;
state.vesselKindCodes.forEach((kindCode, vesselId) => {
if (!activeKindCodes.has(kindCode)) {
newTrails.delete(vesselId);
newLastPositions.delete(vesselId);
newVesselKindCodes.delete(vesselId);
changed = true;
}
});
if (changed) {
set({
trails: newTrails,
lastPositions: newLastPositions,
vesselKindCodes: newVesselKindCodes,
});
}
},
/**
* 프레임 기록 ( 렌더링마다 호출)
* @param {Array<{vesselId: string, lon: number, lat: number}>} positions
* 거리 기반 필터: 이전 위치와 비교해 MIN_TRAIL_DISTANCE_SQ 미만이면 스킵
* @param {Array<{vesselId: string, lon: number, lat: number, shipKindCode: string}>} positions
*/
recordFrame: (positions) => {
const state = get();
if (!state.isEnabled || positions.length === 0) return;
if (!state.isEnabled || state.isScrubbing || positions.length === 0) return;
const newFrameIndex = state.frameIndex + 1;
const newTrailsMap = new Map(state.trails);
const newTrails = new Map(state.trails);
const newLastPositions = new Map(state.lastPositions);
const newVesselKindCodes = new Map(state.vesselKindCodes);
const { maxFrames } = state;
// 각 선박의 현재 위치를 항적에 추가
for (const pos of positions) {
const { vesselId, lon, lat } = pos;
const { vesselId, lon, lat, shipKindCode } = pos;
// NaN 체크
if (isNaN(lon) || isNaN(lat)) continue;
const vesselTrails = newTrailsMap.get(vesselId) || [];
// 선종 코드 업데이트
if (shipKindCode) {
newVesselKindCodes.set(vesselId, shipKindCode);
}
// 새 포인트 추가
vesselTrails.push({
lon,
lat,
frameIndex: newFrameIndex,
vesselId,
});
// 거리 기반 필터: 이전 위치와 비교
const lastPos = newLastPositions.get(vesselId);
if (lastPos) {
const dx = lon - lastPos.lon;
const dy = lat - lastPos.lat;
if (dx * dx + dy * dy < MIN_TRAIL_DISTANCE_SQ) {
continue; // 거리가 너무 가까우면 스킵
}
}
// 마지막 위치 갱신
newLastPositions.set(vesselId, { lon, lat });
// 포인트 추가
const vesselTrails = newTrails.get(vesselId) || [];
vesselTrails.push({ lon, lat, frameIndex: newFrameIndex, vesselId });
// maxFrames 초과 시 오래된 포인트 제거
while (vesselTrails.length > maxFrames) {
vesselTrails.shift();
}
newTrailsMap.set(vesselId, vesselTrails);
newTrails.set(vesselId, vesselTrails);
}
// 더 이상 위치가 없는 선박의 오래된 포인트 정리
// 현재 프레임에 없는 선박의 오래된 포인트 정리
const currentVesselIds = new Set(positions.map((p) => p.vesselId));
newTrailsMap.forEach((trails, vesselId) => {
newTrails.forEach((trails, vesselId) => {
if (!currentVesselIds.has(vesselId)) {
const validTrails = trails.filter(
(t) => newFrameIndex - t.frameIndex < maxFrames
);
if (validTrails.length === 0) {
newTrailsMap.delete(vesselId);
newTrails.delete(vesselId);
newLastPositions.delete(vesselId);
newVesselKindCodes.delete(vesselId);
} else {
newTrailsMap.set(vesselId, validTrails);
newTrails.set(vesselId, validTrails);
}
}
});
set({
trails: newTrailsMap,
trails: newTrails,
lastPositions: newLastPositions,
vesselKindCodes: newVesselKindCodes,
frameIndex: newFrameIndex,
});
},
/**
* 모든 가시 포인트 반환 (렌더링용)
* @returns {PlaybackTrailPoint[]}
* @returns {Array}
*/
getVisiblePoints: () => {
const state = get();
const result = [];
state.trails.forEach((points) => {
points.forEach((point) => {
const frameAge = state.frameIndex - point.frameIndex;
if (frameAge < state.maxFrames) {
for (let i = 0; i < points.length; i++) {
const point = points[i];
if (state.frameIndex - point.frameIndex < state.maxFrames) {
result.push(point);
}
});
}
});
return result;
@ -173,52 +256,43 @@ const usePlaybackTrailStore = create(
/**
* 포인트 투명도 계산 (0~1)
* @param {number} pointFrameIndex
* @returns {number}
*/
getOpacity: (pointFrameIndex) => {
const state = get();
const frameAge = state.frameIndex - pointFrameIndex;
if (frameAge >= state.maxFrames) return 0; // 기준 초과 → 완전 투명
if (frameAge <= 0) return 1; // 최신 포인트 → 불투명
// 선형 감소: 최신(frameAge=0) = 1, 가장 오래된(frameAge=maxFrames) = 0
if (frameAge >= state.maxFrames) return 0;
if (frameAge <= 0) return 1;
return 1 - frameAge / state.maxFrames;
},
/**
* 포인트 크기 계산 (px)
* @param {number} pointFrameIndex
* @returns {number}
*/
getPointSize: (pointFrameIndex) => {
const state = get();
const frameAge = state.frameIndex - pointFrameIndex;
if (frameAge >= state.maxFrames) return state.minPointSize; // 기준 초과 → 최소 크기
if (frameAge <= 0) return state.maxPointSize; // 최신 → 최대 크기
// 선형 감소: 최신 = maxPointSize, 가장 오래된 = minPointSize
if (frameAge >= state.maxFrames) return state.minPointSize;
if (frameAge <= 0) return state.maxPointSize;
const ratio = 1 - frameAge / state.maxFrames;
return state.minPointSize + (state.maxPointSize - state.minPointSize) * ratio;
},
/**
* 재생 배속 업데이트 (maxFrames 자동 재계산)
* @param {number} speed - 재생 배속
*/
updatePlaybackSpeed: (speed) => {
const newMaxFrames = calculateMaxFrames(speed);
set({
playbackSpeed: speed,
maxFrames: newMaxFrames,
maxFrames: calculateMaxFrames(speed),
trails: new Map(),
lastPositions: new Map(),
vesselKindCodes: new Map(),
frameIndex: 0,
});
},
/**
* 설정 변경
* @param {Object} config
*/
setConfig: (config) => {
set({

파일 보기

@ -31,6 +31,20 @@ import {
SIGNAL_KIND_CODE_BUOY,
} from '../../types/constants';
/**
* 초기 선종별 카운트 (리플레이용)
*/
const initialReplayShipCounts = {
[SIGNAL_KIND_CODE_FISHING]: 0,
[SIGNAL_KIND_CODE_KCGV]: 0,
[SIGNAL_KIND_CODE_PASSENGER]: 0,
[SIGNAL_KIND_CODE_CARGO]: 0,
[SIGNAL_KIND_CODE_TANKER]: 0,
[SIGNAL_KIND_CODE_GOV]: 0,
[SIGNAL_KIND_CODE_NORMAL]: 0,
[SIGNAL_KIND_CODE_BUOY]: 0,
};
/**
* 초기 신호원 필터 (모두 활성화)
*/
@ -104,6 +118,10 @@ const useReplayStore = create(
currentViewport: null,
currentZoomLevel: 10,
// ===== 리플레이 선종별 카운트 =====
replayShipCounts: { ...initialReplayShipCounts },
replayTotalCount: 0,
// ===== 하이라이트 =====
highlightedVesselId: null,
@ -237,6 +255,13 @@ const useReplayStore = create(
}));
},
// ===== 액션: 리플레이 카운트 =====
setReplayShipCounts: (counts) => {
const total = Object.values(counts).reduce((a, b) => a + b, 0);
set({ replayShipCounts: { ...initialReplayShipCounts, ...counts }, replayTotalCount: total });
},
// ===== 액션: 하이라이트 =====
setHighlightedVesselId: (vesselId) => set({ highlightedVesselId: vesselId }),
@ -270,6 +295,8 @@ const useReplayStore = create(
selectedVesselIds: new Set(),
sigSrcCdFilter: initialSigSrcCdFilter,
shipKindCodeFilter: initialShipKindCodeFilter,
replayShipCounts: { ...initialReplayShipCounts },
replayTotalCount: 0,
highlightedVesselId: null,
filterModules: {
[FilterModuleType.CUSTOM]: { ...DEFAULT_FILTER_MODULE },