diff --git a/src/components/layout/SideNav.jsx b/src/components/layout/SideNav.jsx index e6b9afff..5611e1ab 100644 --- a/src/components/layout/SideNav.jsx +++ b/src/components/layout/SideNav.jsx @@ -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 }) { diff --git a/src/components/layout/Sidebar.jsx b/src/components/layout/Sidebar.jsx index 72483f96..29105833 100644 --- a/src/components/layout/Sidebar.jsx +++ b/src/components/layout/Sidebar.jsx @@ -59,7 +59,7 @@ export default function Sidebar() { const renderPanel = () => { switch (activeKey) { case 'gnb1': - return ; + return ; case 'gnb2': return ; case 'gnb3': diff --git a/src/components/layout/ToolBar.jsx b/src/components/layout/ToolBar.jsx index a123891a..41efbe91 100644 --- a/src/components/layout/ToolBar.jsx +++ b/src/components/layout/ToolBar.jsx @@ -218,7 +218,7 @@ export default function ToolBar() { 범례 -
  • + {/*
  • */} diff --git a/src/components/ship/ReplayLegend.jsx b/src/components/ship/ReplayLegend.jsx new file mode 100644 index 00000000..c71cdf6b --- /dev/null +++ b/src/components/ship/ReplayLegend.jsx @@ -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 ( +
  • +
    onToggle(code)}> + + {label} + + {label} +
    + {count} +
  • + ); +}); + +/** + * 리플레이 전용 범례 컴포넌트 + */ +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 ( +
    + {/* 헤더 */} +
    +
    + 리플레이 현황 +
    +
    + + {/* 선박 종류별 목록 */} +
      + {LEGEND_ITEMS.map((item) => ( + + ))} +
    + + {/* 푸터 - 전체 카운트 */} +
    + 전체 + {replayTotalCount} +
    +
    + ); +}); + +export default ReplayLegend; diff --git a/src/map/MapContainer.jsx b/src/map/MapContainer.jsx index 0a4a2bf8..5aefd676 100644 --- a/src/map/MapContainer.jsx +++ b/src/map/MapContainer.jsx @@ -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() { <>
    - {showLegend && } + {showLegend && (replayCompleted ? : )} {hoverInfo && ( )} @@ -449,6 +451,7 @@ export default function MapContainer() { ))} + {replayCompleted && ( { + 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 ( +
    +
    + {/* 원형 프로그레스 */} +
    + + {/* 배경 원 */} + + {/* 진행 원 */} + + + {progressInt}% +
    +

    {statusText}

    +
    +
    + ); +}); + +export default ReplayLoadingOverlay; diff --git a/src/replay/components/ReplayLoadingOverlay.scss b/src/replay/components/ReplayLoadingOverlay.scss new file mode 100644 index 00000000..3f019e26 --- /dev/null +++ b/src/replay/components/ReplayLoadingOverlay.scss @@ -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; + } +} diff --git a/src/replay/components/ReplayTimeline.jsx b/src/replay/components/ReplayTimeline.jsx index 29a5d573..bc1bbe4a 100644 --- a/src/replay/components/ReplayTimeline.jsx +++ b/src/replay/components/ReplayTimeline.jsx @@ -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 }) {
    {/* 드래그 가능한 헤더 */}
    @@ -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}%` }} diff --git a/src/replay/hooks/useReplayLayer.js b/src/replay/hooks/useReplayLayer.js index c0805dd2..25414b0b 100644 --- a/src/replay/hooks/useReplayLayer.js +++ b/src/replay/hooks/useReplayLayer.js @@ -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]); diff --git a/src/replay/stores/playbackTrailStore.js b/src/replay/stores/playbackTrailStore.js index 0887707f..ea4cadd2 100644 --- a/src/replay/stores/playbackTrailStore.js +++ b/src/replay/stores/playbackTrailStore.js @@ -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({ diff --git a/src/replay/stores/replayStore.js b/src/replay/stores/replayStore.js index 311e8b3b..e610af52 100644 --- a/src/replay/stores/replayStore.js +++ b/src/replay/stores/replayStore.js @@ -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 },