feat: 리플레이 범례/궤적 최적화/로딩 프로그레스/UI 개선
- 리플레이 전용 범례(ReplayLegend): 재생 시점 선종별 카운트, 필터 동기화 - 궤적 표시 성능 최적화: 거리 필터(100m), 역비례 프레임 계산, 배속별 동일 시각적 길이 - 궤적 필터 동기화: 선종 OFF 즉시 제거, 프로그레스 드래그 시 클리어, 배속 변경 시 리셋 - 리플레이 로딩 프로그레스: 화면 중앙 원형 오버레이, 머지 타임스탬프 기반 진행률 - 선박 메뉴에서 필터 패널 직접 열기, 좌측 하단 필터/레이어 버튼 비활성화 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
부모
1e317c1cbe
커밋
19b2cff39e
@ -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>
|
||||
|
||||
130
src/components/ship/ReplayLegend.jsx
Normal file
130
src/components/ship/ReplayLegend.jsx
Normal file
@ -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}
|
||||
|
||||
81
src/replay/components/ReplayLoadingOverlay.jsx
Normal file
81
src/replay/components/ReplayLoadingOverlay.jsx
Normal file
@ -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;
|
||||
66
src/replay/components/ReplayLoadingOverlay.scss
Normal file
66
src/replay/components/ReplayLoadingOverlay.scss
Normal file
@ -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,35 +105,42 @@ export default function ReplayTimeline({ fromDate, toDate, onClose }) {
|
||||
}, [showSpeedMenu]);
|
||||
|
||||
// 드래그 핸들러
|
||||
// CSS 센터링(left:50% + translateX(-50%))에서 절대좌표(left/top)로 전환하여
|
||||
// transform 충돌로 인한 위치 이탈/가로스크롤 방지
|
||||
const handleMouseDown = useCallback((e) => {
|
||||
if (containerRef.current) {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
setDragOffset({
|
||||
x: e.clientX - rect.left - position.x,
|
||||
y: e.clientY - rect.top - position.y,
|
||||
});
|
||||
setIsDragging(true);
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
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) {
|
||||
const parent = containerRef.current.parentElement;
|
||||
if (parent) {
|
||||
const parentRect = parent.getBoundingClientRect();
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
if (!isDragging || !containerRef.current) return;
|
||||
const parent = containerRef.current.parentElement;
|
||||
if (!parent) return;
|
||||
const parentRect = parent.getBoundingClientRect();
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
|
||||
let newX = e.clientX - parentRect.left - dragOffset.x;
|
||||
let newY = e.clientY - parentRect.top - dragOffset.y;
|
||||
let newX = e.clientX - parentRect.left - dragOffset.x;
|
||||
let newY = e.clientY - parentRect.top - dragOffset.y;
|
||||
|
||||
// 경계 제한
|
||||
newX = Math.max(0, Math.min(newX, parentRect.width - containerRect.width));
|
||||
newY = Math.max(0, Math.min(newY, parentRect.height - containerRect.height));
|
||||
// 경계 제한
|
||||
newX = Math.max(0, Math.min(newX, parentRect.width - containerRect.width));
|
||||
newY = Math.max(0, Math.min(newY, parentRect.height - containerRect.height));
|
||||
|
||||
setPosition({ x: newX, y: newY });
|
||||
}
|
||||
}
|
||||
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 },
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user