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' },
|
// { key: 'gnb8', className: 'gnb8', label: '항적조회', path: 'tracking' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 필터/레이어 버튼 비활성화 — 선박(gnb1) 버튼에서 DisplayComponent로 통합
|
||||||
const sideList = [
|
const sideList = [
|
||||||
{ key: 'filter', className: 'filter', label: '필터', path: 'filter' },
|
// { key: 'filter', className: 'filter', label: '필터', path: 'filter' },
|
||||||
{ key: 'layer', className: 'layer', label: '레이어', path: 'layer' },
|
// { key: 'layer', className: 'layer', label: '레이어', path: 'layer' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function SideNav({ activeKey, onChange }) {
|
export default function SideNav({ activeKey, onChange }) {
|
||||||
|
|||||||
@ -59,7 +59,7 @@ export default function Sidebar() {
|
|||||||
const renderPanel = () => {
|
const renderPanel = () => {
|
||||||
switch (activeKey) {
|
switch (activeKey) {
|
||||||
case 'gnb1':
|
case 'gnb1':
|
||||||
return <Panel1Component {...panelProps} />;
|
return <DisplayComponent {...panelProps} initialTab="filter" />;
|
||||||
case 'gnb2':
|
case 'gnb2':
|
||||||
return <Panel2Component {...panelProps} />;
|
return <Panel2Component {...panelProps} />;
|
||||||
case 'gnb3':
|
case 'gnb3':
|
||||||
|
|||||||
@ -218,7 +218,7 @@ export default function ToolBar() {
|
|||||||
범례
|
범례
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li><button type="button" className="minimap">미니맵</button></li>
|
{/*<li><button type="button" className="minimap">미니맵</button></li>*/}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 useShipData from '../hooks/useShipData';
|
||||||
import useShipLayer from '../hooks/useShipLayer';
|
import useShipLayer from '../hooks/useShipLayer';
|
||||||
import ShipLegend from '../components/ship/ShipLegend';
|
import ShipLegend from '../components/ship/ShipLegend';
|
||||||
|
import ReplayLegend from '../components/ship/ReplayLegend';
|
||||||
import ShipTooltip from '../components/ship/ShipTooltip';
|
import ShipTooltip from '../components/ship/ShipTooltip';
|
||||||
import ShipDetailModal from '../components/ship/ShipDetailModal';
|
import ShipDetailModal from '../components/ship/ShipDetailModal';
|
||||||
import ShipContextMenu from '../components/ship/ShipContextMenu';
|
import ShipContextMenu from '../components/ship/ShipContextMenu';
|
||||||
@ -22,6 +23,7 @@ import { shipBatchRenderer } from './ShipBatchRenderer';
|
|||||||
import useReplayStore from '../replay/stores/replayStore';
|
import useReplayStore from '../replay/stores/replayStore';
|
||||||
import useAnimationStore from '../replay/stores/animationStore';
|
import useAnimationStore from '../replay/stores/animationStore';
|
||||||
import ReplayTimeline from '../replay/components/ReplayTimeline';
|
import ReplayTimeline from '../replay/components/ReplayTimeline';
|
||||||
|
import ReplayLoadingOverlay from '../replay/components/ReplayLoadingOverlay';
|
||||||
import { unregisterReplayLayers } from '../replay/utils/replayLayerRegistry';
|
import { unregisterReplayLayers } from '../replay/utils/replayLayerRegistry';
|
||||||
import { showLiveShips } from '../utils/liveControl';
|
import { showLiveShips } from '../utils/liveControl';
|
||||||
import { useTrackQueryStore } from '../tracking/stores/trackQueryStore';
|
import { useTrackQueryStore } from '../tracking/stores/trackQueryStore';
|
||||||
@ -440,7 +442,7 @@ export default function MapContainer() {
|
|||||||
<>
|
<>
|
||||||
<div id="map" ref={mapRef} className="map-container" />
|
<div id="map" ref={mapRef} className="map-container" />
|
||||||
<TopBar />
|
<TopBar />
|
||||||
{showLegend && <ShipLegend />}
|
{showLegend && (replayCompleted ? <ReplayLegend /> : <ShipLegend />)}
|
||||||
{hoverInfo && (
|
{hoverInfo && (
|
||||||
<ShipTooltip ship={hoverInfo.ship} x={hoverInfo.x} y={hoverInfo.y} />
|
<ShipTooltip ship={hoverInfo.ship} x={hoverInfo.x} y={hoverInfo.y} />
|
||||||
)}
|
)}
|
||||||
@ -449,6 +451,7 @@ export default function MapContainer() {
|
|||||||
))}
|
))}
|
||||||
<ShipContextMenu />
|
<ShipContextMenu />
|
||||||
<GlobalTrackQueryViewer />
|
<GlobalTrackQueryViewer />
|
||||||
|
<ReplayLoadingOverlay />
|
||||||
{replayCompleted && (
|
{replayCompleted && (
|
||||||
<ReplayTimeline
|
<ReplayTimeline
|
||||||
fromDate={replayQuery?.startTime}
|
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 [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [hasDragged, setHasDragged] = useState(false);
|
||||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
@ -104,35 +105,42 @@ export default function ReplayTimeline({ fromDate, toDate, onClose }) {
|
|||||||
}, [showSpeedMenu]);
|
}, [showSpeedMenu]);
|
||||||
|
|
||||||
// 드래그 핸들러
|
// 드래그 핸들러
|
||||||
|
// CSS 센터링(left:50% + translateX(-50%))에서 절대좌표(left/top)로 전환하여
|
||||||
|
// transform 충돌로 인한 위치 이탈/가로스크롤 방지
|
||||||
const handleMouseDown = useCallback((e) => {
|
const handleMouseDown = useCallback((e) => {
|
||||||
if (containerRef.current) {
|
if (!containerRef.current) return;
|
||||||
const rect = containerRef.current.getBoundingClientRect();
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
setDragOffset({
|
const parent = containerRef.current.parentElement;
|
||||||
x: e.clientX - rect.left - position.x,
|
if (!parent) return;
|
||||||
y: e.clientY - rect.top - position.y,
|
const parentRect = parent.getBoundingClientRect();
|
||||||
});
|
|
||||||
setIsDragging(true);
|
// 요소 내부 커서 오프셋
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
const handleMouseMove = (e) => {
|
const handleMouseMove = (e) => {
|
||||||
if (isDragging && containerRef.current) {
|
if (!isDragging || !containerRef.current) return;
|
||||||
const parent = containerRef.current.parentElement;
|
const parent = containerRef.current.parentElement;
|
||||||
if (parent) {
|
if (!parent) return;
|
||||||
const parentRect = parent.getBoundingClientRect();
|
const parentRect = parent.getBoundingClientRect();
|
||||||
const containerRect = containerRef.current.getBoundingClientRect();
|
const containerRect = containerRef.current.getBoundingClientRect();
|
||||||
|
|
||||||
let newX = e.clientX - parentRect.left - dragOffset.x;
|
let newX = e.clientX - parentRect.left - dragOffset.x;
|
||||||
let newY = e.clientY - parentRect.top - dragOffset.y;
|
let newY = e.clientY - parentRect.top - dragOffset.y;
|
||||||
|
|
||||||
// 경계 제한
|
// 경계 제한
|
||||||
newX = Math.max(0, Math.min(newX, parentRect.width - containerRect.width));
|
newX = Math.max(0, Math.min(newX, parentRect.width - containerRect.width));
|
||||||
newY = Math.max(0, Math.min(newY, parentRect.height - containerRect.height));
|
newY = Math.max(0, Math.min(newY, parentRect.height - containerRect.height));
|
||||||
|
|
||||||
setPosition({ x: newX, y: newY });
|
setPosition({ x: newX, y: newY });
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
@ -172,6 +180,23 @@ export default function ReplayTimeline({ fromDate, toDate, onClose }) {
|
|||||||
setTrailEnabled(!isTrailEnabled);
|
setTrailEnabled(!isTrailEnabled);
|
||||||
}, [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 handleSliderChange = useCallback((e) => {
|
||||||
const newTime = parseFloat(e.target.value);
|
const newTime = parseFloat(e.target.value);
|
||||||
@ -189,11 +214,12 @@ export default function ReplayTimeline({ fromDate, toDate, onClose }) {
|
|||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={`replay-timeline ${isPlaying ? 'playing' : ''} ${isDragging ? 'dragging' : ''}`}
|
className={`replay-timeline ${isPlaying ? 'playing' : ''} ${isDragging ? 'dragging' : ''}`}
|
||||||
style={{
|
style={hasDragged ? {
|
||||||
transform: position.x !== 0 || position.y !== 0
|
left: `${position.x}px`,
|
||||||
? `translate(${position.x}px, ${position.y}px)`
|
top: `${position.y}px`,
|
||||||
: undefined,
|
bottom: 'auto',
|
||||||
}}
|
transform: 'none',
|
||||||
|
} : undefined}
|
||||||
>
|
>
|
||||||
{/* 드래그 가능한 헤더 */}
|
{/* 드래그 가능한 헤더 */}
|
||||||
<div className="timeline-header" onMouseDown={handleMouseDown}>
|
<div className="timeline-header" onMouseDown={handleMouseDown}>
|
||||||
@ -267,6 +293,7 @@ export default function ReplayTimeline({ fromDate, toDate, onClose }) {
|
|||||||
max={endTime}
|
max={endTime}
|
||||||
step={(endTime - startTime) / 1000}
|
step={(endTime - startTime) / 1000}
|
||||||
value={currentTime}
|
value={currentTime}
|
||||||
|
onPointerDown={handleSliderPointerDown}
|
||||||
onChange={handleSliderChange}
|
onChange={handleSliderChange}
|
||||||
disabled={!hasData}
|
disabled={!hasData}
|
||||||
style={{ '--progress': `${progress}%` }}
|
style={{ '--progress': `${progress}%` }}
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import { createStaticTrackLayers, createVirtualShipLayers } from '../../map/laye
|
|||||||
import { registerReplayLayers, unregisterReplayLayers } from '../utils/replayLayerRegistry';
|
import { registerReplayLayers, unregisterReplayLayers } from '../utils/replayLayerRegistry';
|
||||||
import { shipBatchRenderer } from '../../map/ShipBatchRenderer';
|
import { shipBatchRenderer } from '../../map/ShipBatchRenderer';
|
||||||
import { hideLiveShips, showLiveShips } from '../../utils/liveControl';
|
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);
|
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);
|
const filteredTracks = filterTracks(tracksRef.current);
|
||||||
|
|
||||||
@ -223,13 +232,14 @@ export default function useReplayLayer() {
|
|||||||
const trailStore = usePlaybackTrailStore.getState();
|
const trailStore = usePlaybackTrailStore.getState();
|
||||||
const layers = [];
|
const layers = [];
|
||||||
|
|
||||||
// 항적표시가 활성화되어 있으면 프레임 기록
|
// 항적표시가 활성화되어 있으면 프레임 기록 (shipKindCode 포함)
|
||||||
if (trailStore.isEnabled && iconPositions.length > 0) {
|
if (trailStore.isEnabled && iconPositions.length > 0) {
|
||||||
trailStore.recordFrame(
|
trailStore.recordFrame(
|
||||||
iconPositions.map((pos) => ({
|
iconPositions.map((pos) => ({
|
||||||
vesselId: pos.vesselId,
|
vesselId: pos.vesselId,
|
||||||
lon: pos.lon,
|
lon: pos.lon,
|
||||||
lat: pos.lat,
|
lat: pos.lat,
|
||||||
|
shipKindCode: pos.shipKindCode,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -347,10 +357,17 @@ export default function useReplayLayer() {
|
|||||||
}, [currentTime, queryCompleted, requestAnimatedRender]);
|
}, [currentTime, queryCompleted, requestAnimatedRender]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 필터 변경 시 재렌더링
|
* 필터 변경 시 재렌더링 + 궤적 필터 동기화
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!queryCompleted) return;
|
if (!queryCompleted) return;
|
||||||
|
|
||||||
|
// 선종 필터 OFF 시 해당 선종 궤적 즉시 제거
|
||||||
|
const trailStore = usePlaybackTrailStore.getState();
|
||||||
|
if (trailStore.isEnabled && trailStore.trails.size > 0) {
|
||||||
|
trailStore.removeTrailsByFilter(shipKindCodeFilter);
|
||||||
|
}
|
||||||
|
|
||||||
requestAnimatedRender();
|
requestAnimatedRender();
|
||||||
}, [filterModules, shipKindCodeFilter, vesselStates, deletedVesselIds, selectedVesselIds, queryCompleted, requestAnimatedRender]);
|
}, [filterModules, shipKindCodeFilter, vesselStates, deletedVesselIds, selectedVesselIds, queryCompleted, requestAnimatedRender]);
|
||||||
|
|
||||||
|
|||||||
@ -3,41 +3,45 @@
|
|||||||
* 참조: mda-react-front/src/tracking/stores/playbackTrailStore.ts
|
* 참조: mda-react-front/src/tracking/stores/playbackTrailStore.ts
|
||||||
*
|
*
|
||||||
* 애니메이션 재생 시 선박의 이동 궤적을 반투명 점으로 표시
|
* 애니메이션 재생 시 선박의 이동 궤적을 반투명 점으로 표시
|
||||||
|
* - 거리 기반 필터: 이전 위치와 일정 거리 이상일 때만 포인트 추가
|
||||||
* - 시간이 지나면 점 크기 축소, 투명도 증가
|
* - 시간이 지나면 점 크기 축소, 투명도 증가
|
||||||
* - 기준 프레임 초과 시 제거
|
* - 기준 프레임 초과 시 제거
|
||||||
* - 배속에 따라 maxFrames 동적 조절
|
* - 배속에 따라 maxFrames 동적 조절
|
||||||
|
* - 선종 필터 동기화: 필터 OFF 시 해당 선종 궤적 즉시 제거
|
||||||
|
* - 프로그레스 스크러빙: 드래그 중 궤적 일시정지 + 클리어
|
||||||
*/
|
*/
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { subscribeWithSelector } from 'zustand/middleware';
|
import { subscribeWithSelector } from 'zustand/middleware';
|
||||||
|
|
||||||
// 기본 설정값 (75% 수준으로 조정 - 성능 최적화)
|
// 프레임 설정 (역비례 계산 — 배속 무관 동일 시각적 궤적 길이)
|
||||||
const BASE_MAX_FRAMES = 45; // 기본 프레임 수 (1x 배속 기준)
|
const REFERENCE_SPEED = 1000; // 기준 배속 (1000x에서 궤적 길이가 적절)
|
||||||
const MIN_MAX_FRAMES = 25; // 최소 프레임 수
|
const REFERENCE_FRAMES = 60; // 기준 배속에서의 프레임 수
|
||||||
const MAX_MAX_FRAMES = 225; // 최대 프레임 수
|
const MIN_MAX_FRAMES = 8; // 최소 프레임 수
|
||||||
const DEFAULT_MAX_POINT_SIZE = 4; // 최대 4px
|
const MAX_MAX_FRAMES = 150; // 최대 프레임 수 (저배속 cap)
|
||||||
const DEFAULT_MIN_POINT_SIZE = 1; // 최소 1px
|
|
||||||
|
// 포인트 크기
|
||||||
|
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 계산
|
* 배속에 따른 maxFrames 계산 (역비례)
|
||||||
* 배속이 빠르면 더 많은 프레임을 유지해서 비슷한 시간 길이의 항적 표시
|
* 배속이 낮으면 프레임당 이동거리가 짧으므로 더 많은 프레임을 유지해
|
||||||
* @param {number} playbackSpeed - 재생 배속 (0.5, 1, 2, 4, 8, 16 등)
|
* 모든 배속에서 동일한 시각적 궤적 길이를 보장
|
||||||
* @returns {number} maxFrames
|
*
|
||||||
|
* 1000x: 60, 500x: 120, 100x: 150(cap), 50x~1x: 150(cap)
|
||||||
*/
|
*/
|
||||||
const calculateMaxFrames = (playbackSpeed) => {
|
const calculateMaxFrames = (playbackSpeed) => {
|
||||||
// 배속에 비례하여 프레임 수 증가
|
if (playbackSpeed <= 0) return REFERENCE_FRAMES;
|
||||||
// 1x: 60프레임, 2x: 120프레임, 4x: 240프레임, 0.5x: 30프레임
|
const frames = Math.round(REFERENCE_FRAMES * REFERENCE_SPEED / playbackSpeed);
|
||||||
const frames = Math.round(BASE_MAX_FRAMES * playbackSpeed);
|
|
||||||
return Math.max(MIN_MAX_FRAMES, Math.min(MAX_MAX_FRAMES, frames));
|
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(
|
const usePlaybackTrailStore = create(
|
||||||
subscribeWithSelector((set, get) => ({
|
subscribeWithSelector((set, get) => ({
|
||||||
@ -46,17 +50,26 @@ const usePlaybackTrailStore = create(
|
|||||||
/** 항적표시 토글 상태 */
|
/** 항적표시 토글 상태 */
|
||||||
isEnabled: false,
|
isEnabled: false,
|
||||||
|
|
||||||
/** 선박별 항적 포인트 Map (vesselId -> PlaybackTrailPoint[]) */
|
/** 선박별 항적 포인트 Map (vesselId -> TrailPoint[]) */
|
||||||
trails: new Map(),
|
trails: new Map(),
|
||||||
|
|
||||||
/** 현재 프레임 인덱스 (렌더링마다 증가) */
|
/** 선박별 마지막 기록 위치 (거리 필터용) */
|
||||||
|
lastPositions: new Map(),
|
||||||
|
|
||||||
|
/** 선박별 선종 코드 (필터 동기화용) */
|
||||||
|
vesselKindCodes: new Map(),
|
||||||
|
|
||||||
|
/** 현재 프레임 인덱스 */
|
||||||
frameIndex: 0,
|
frameIndex: 0,
|
||||||
|
|
||||||
|
/** 프로그레스 바 스크러빙 중 여부 */
|
||||||
|
isScrubbing: false,
|
||||||
|
|
||||||
/** 현재 재생 배속 */
|
/** 현재 재생 배속 */
|
||||||
playbackSpeed: 1,
|
playbackSpeed: 1,
|
||||||
|
|
||||||
/** 유지할 최대 프레임 수 (배속에 따라 동적 계산) */
|
/** 유지할 최대 프레임 수 */
|
||||||
maxFrames: BASE_MAX_FRAMES,
|
maxFrames: MAX_MAX_FRAMES,
|
||||||
|
|
||||||
/** 포인트 최대 크기 (px) */
|
/** 포인트 최대 크기 (px) */
|
||||||
maxPointSize: DEFAULT_MAX_POINT_SIZE,
|
maxPointSize: DEFAULT_MAX_POINT_SIZE,
|
||||||
@ -68,14 +81,14 @@ const usePlaybackTrailStore = create(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 토글 ON/OFF
|
* 토글 ON/OFF
|
||||||
* @param {boolean} enabled
|
|
||||||
*/
|
*/
|
||||||
setEnabled: (enabled) => {
|
setEnabled: (enabled) => {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
// OFF 시 전체 초기화
|
|
||||||
set({
|
set({
|
||||||
isEnabled: false,
|
isEnabled: false,
|
||||||
trails: new Map(),
|
trails: new Map(),
|
||||||
|
lastPositions: new Map(),
|
||||||
|
vesselKindCodes: new Map(),
|
||||||
frameIndex: 0,
|
frameIndex: 0,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@ -84,88 +97,158 @@ const usePlaybackTrailStore = create(
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 항적 전체 초기화 (정지, 슬라이더 탐색 시 호출)
|
* 항적 전체 초기화
|
||||||
*/
|
*/
|
||||||
clearTrails: () => {
|
clearTrails: () => {
|
||||||
set({
|
set({
|
||||||
trails: new Map(),
|
trails: new Map(),
|
||||||
|
lastPositions: new Map(),
|
||||||
|
vesselKindCodes: new Map(),
|
||||||
frameIndex: 0,
|
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) => {
|
recordFrame: (positions) => {
|
||||||
const state = get();
|
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 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;
|
const { maxFrames } = state;
|
||||||
|
|
||||||
// 각 선박의 현재 위치를 항적에 추가
|
|
||||||
for (const pos of positions) {
|
for (const pos of positions) {
|
||||||
const { vesselId, lon, lat } = pos;
|
const { vesselId, lon, lat, shipKindCode } = pos;
|
||||||
|
|
||||||
// NaN 체크
|
|
||||||
if (isNaN(lon) || isNaN(lat)) continue;
|
if (isNaN(lon) || isNaN(lat)) continue;
|
||||||
|
|
||||||
const vesselTrails = newTrailsMap.get(vesselId) || [];
|
// 선종 코드 업데이트
|
||||||
|
if (shipKindCode) {
|
||||||
|
newVesselKindCodes.set(vesselId, shipKindCode);
|
||||||
|
}
|
||||||
|
|
||||||
// 새 포인트 추가
|
// 거리 기반 필터: 이전 위치와 비교
|
||||||
vesselTrails.push({
|
const lastPos = newLastPositions.get(vesselId);
|
||||||
lon,
|
if (lastPos) {
|
||||||
lat,
|
const dx = lon - lastPos.lon;
|
||||||
frameIndex: newFrameIndex,
|
const dy = lat - lastPos.lat;
|
||||||
vesselId,
|
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 초과 시 오래된 포인트 제거
|
// maxFrames 초과 시 오래된 포인트 제거
|
||||||
while (vesselTrails.length > maxFrames) {
|
while (vesselTrails.length > maxFrames) {
|
||||||
vesselTrails.shift();
|
vesselTrails.shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
newTrailsMap.set(vesselId, vesselTrails);
|
newTrails.set(vesselId, vesselTrails);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 더 이상 위치가 없는 선박의 오래된 포인트 정리
|
// 현재 프레임에 없는 선박의 오래된 포인트 정리
|
||||||
const currentVesselIds = new Set(positions.map((p) => p.vesselId));
|
const currentVesselIds = new Set(positions.map((p) => p.vesselId));
|
||||||
newTrailsMap.forEach((trails, vesselId) => {
|
newTrails.forEach((trails, vesselId) => {
|
||||||
if (!currentVesselIds.has(vesselId)) {
|
if (!currentVesselIds.has(vesselId)) {
|
||||||
const validTrails = trails.filter(
|
const validTrails = trails.filter(
|
||||||
(t) => newFrameIndex - t.frameIndex < maxFrames
|
(t) => newFrameIndex - t.frameIndex < maxFrames
|
||||||
);
|
);
|
||||||
if (validTrails.length === 0) {
|
if (validTrails.length === 0) {
|
||||||
newTrailsMap.delete(vesselId);
|
newTrails.delete(vesselId);
|
||||||
|
newLastPositions.delete(vesselId);
|
||||||
|
newVesselKindCodes.delete(vesselId);
|
||||||
} else {
|
} else {
|
||||||
newTrailsMap.set(vesselId, validTrails);
|
newTrails.set(vesselId, validTrails);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
set({
|
set({
|
||||||
trails: newTrailsMap,
|
trails: newTrails,
|
||||||
|
lastPositions: newLastPositions,
|
||||||
|
vesselKindCodes: newVesselKindCodes,
|
||||||
frameIndex: newFrameIndex,
|
frameIndex: newFrameIndex,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 모든 가시 포인트 반환 (렌더링용)
|
* 모든 가시 포인트 반환 (렌더링용)
|
||||||
* @returns {PlaybackTrailPoint[]}
|
* @returns {Array}
|
||||||
*/
|
*/
|
||||||
getVisiblePoints: () => {
|
getVisiblePoints: () => {
|
||||||
const state = get();
|
const state = get();
|
||||||
const result = [];
|
const result = [];
|
||||||
|
|
||||||
state.trails.forEach((points) => {
|
state.trails.forEach((points) => {
|
||||||
points.forEach((point) => {
|
for (let i = 0; i < points.length; i++) {
|
||||||
const frameAge = state.frameIndex - point.frameIndex;
|
const point = points[i];
|
||||||
if (frameAge < state.maxFrames) {
|
if (state.frameIndex - point.frameIndex < state.maxFrames) {
|
||||||
result.push(point);
|
result.push(point);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@ -173,52 +256,43 @@ const usePlaybackTrailStore = create(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 포인트 투명도 계산 (0~1)
|
* 포인트 투명도 계산 (0~1)
|
||||||
* @param {number} pointFrameIndex
|
|
||||||
* @returns {number}
|
|
||||||
*/
|
*/
|
||||||
getOpacity: (pointFrameIndex) => {
|
getOpacity: (pointFrameIndex) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
const frameAge = state.frameIndex - pointFrameIndex;
|
const frameAge = state.frameIndex - pointFrameIndex;
|
||||||
|
if (frameAge >= state.maxFrames) return 0;
|
||||||
if (frameAge >= state.maxFrames) return 0; // 기준 초과 → 완전 투명
|
if (frameAge <= 0) return 1;
|
||||||
if (frameAge <= 0) return 1; // 최신 포인트 → 불투명
|
|
||||||
|
|
||||||
// 선형 감소: 최신(frameAge=0) = 1, 가장 오래된(frameAge=maxFrames) = 0
|
|
||||||
return 1 - frameAge / state.maxFrames;
|
return 1 - frameAge / state.maxFrames;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 포인트 크기 계산 (px)
|
* 포인트 크기 계산 (px)
|
||||||
* @param {number} pointFrameIndex
|
|
||||||
* @returns {number}
|
|
||||||
*/
|
*/
|
||||||
getPointSize: (pointFrameIndex) => {
|
getPointSize: (pointFrameIndex) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
const frameAge = state.frameIndex - pointFrameIndex;
|
const frameAge = state.frameIndex - pointFrameIndex;
|
||||||
|
if (frameAge >= state.maxFrames) return state.minPointSize;
|
||||||
if (frameAge >= state.maxFrames) return state.minPointSize; // 기준 초과 → 최소 크기
|
if (frameAge <= 0) return state.maxPointSize;
|
||||||
if (frameAge <= 0) return state.maxPointSize; // 최신 → 최대 크기
|
|
||||||
|
|
||||||
// 선형 감소: 최신 = maxPointSize, 가장 오래된 = minPointSize
|
|
||||||
const ratio = 1 - frameAge / state.maxFrames;
|
const ratio = 1 - frameAge / state.maxFrames;
|
||||||
return state.minPointSize + (state.maxPointSize - state.minPointSize) * ratio;
|
return state.minPointSize + (state.maxPointSize - state.minPointSize) * ratio;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 재생 배속 업데이트 (maxFrames 자동 재계산)
|
* 재생 배속 업데이트 (maxFrames 자동 재계산)
|
||||||
* @param {number} speed - 재생 배속
|
|
||||||
*/
|
*/
|
||||||
updatePlaybackSpeed: (speed) => {
|
updatePlaybackSpeed: (speed) => {
|
||||||
const newMaxFrames = calculateMaxFrames(speed);
|
|
||||||
set({
|
set({
|
||||||
playbackSpeed: speed,
|
playbackSpeed: speed,
|
||||||
maxFrames: newMaxFrames,
|
maxFrames: calculateMaxFrames(speed),
|
||||||
|
trails: new Map(),
|
||||||
|
lastPositions: new Map(),
|
||||||
|
vesselKindCodes: new Map(),
|
||||||
|
frameIndex: 0,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 설정 변경
|
* 설정 변경
|
||||||
* @param {Object} config
|
|
||||||
*/
|
*/
|
||||||
setConfig: (config) => {
|
setConfig: (config) => {
|
||||||
set({
|
set({
|
||||||
|
|||||||
@ -31,6 +31,20 @@ import {
|
|||||||
SIGNAL_KIND_CODE_BUOY,
|
SIGNAL_KIND_CODE_BUOY,
|
||||||
} from '../../types/constants';
|
} 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,
|
currentViewport: null,
|
||||||
currentZoomLevel: 10,
|
currentZoomLevel: 10,
|
||||||
|
|
||||||
|
// ===== 리플레이 선종별 카운트 =====
|
||||||
|
replayShipCounts: { ...initialReplayShipCounts },
|
||||||
|
replayTotalCount: 0,
|
||||||
|
|
||||||
// ===== 하이라이트 =====
|
// ===== 하이라이트 =====
|
||||||
highlightedVesselId: null,
|
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 }),
|
setHighlightedVesselId: (vesselId) => set({ highlightedVesselId: vesselId }),
|
||||||
@ -270,6 +295,8 @@ const useReplayStore = create(
|
|||||||
selectedVesselIds: new Set(),
|
selectedVesselIds: new Set(),
|
||||||
sigSrcCdFilter: initialSigSrcCdFilter,
|
sigSrcCdFilter: initialSigSrcCdFilter,
|
||||||
shipKindCodeFilter: initialShipKindCodeFilter,
|
shipKindCodeFilter: initialShipKindCodeFilter,
|
||||||
|
replayShipCounts: { ...initialReplayShipCounts },
|
||||||
|
replayTotalCount: 0,
|
||||||
highlightedVesselId: null,
|
highlightedVesselId: null,
|
||||||
filterModules: {
|
filterModules: {
|
||||||
[FilterModuleType.CUSTOM]: { ...DEFAULT_FILTER_MODULE },
|
[FilterModuleType.CUSTOM]: { ...DEFAULT_FILTER_MODULE },
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user