diff --git a/src/api/trackApi.js b/src/api/trackApi.js new file mode 100644 index 00000000..26a77b36 --- /dev/null +++ b/src/api/trackApi.js @@ -0,0 +1,213 @@ +/** + * 항적 조회 API + * 참조: mda-react-front/src/tracking/services/trackQueryApi.ts + * 참조: mda-react-front/src/api/trackApi.ts + * + * - 엔드포인트: POST /api/v2/tracks/vessels + * - 선박 항적 데이터 조회 + * - 응답 데이터 가공 (ProcessedTrack 형태로 변환) + */ +import useShipStore from '../stores/shipStore'; + +/** API 엔드포인트 (메인 프로젝트와 동일) */ +const API_ENDPOINT = '/api/v2/tracks/vessels'; + +/** + * 항적 데이터 조회 + * + * @param {Object} params + * @param {string} params.startTime - 조회 시작 시간 (ISO 8601, e.g. '2026-01-01T00:00:00') + * @param {string} params.endTime - 조회 종료 시간 (ISO 8601) + * @param {Array<{ sigSrcCd: string, targetId: string }>} params.vessels - 조회 대상 선박 + * @param {boolean} [params.isIntegration=false] - 통합 조회 여부 + * @returns {Promise} ProcessedTrack 배열 + */ +export async function fetchTrackQuery({ startTime, endTime, vessels, isIntegration = false }) { + try { + const body = { + startTime, + endTime, + vessels, + isIntegration: isIntegration ? '1' : '0', + }; + + const response = await fetch(API_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + + // v2 API는 배열을 직접 반환 + const rawTracks = Array.isArray(result) ? result : (result?.data || []); + + if (!Array.isArray(rawTracks)) { + console.warn('[fetchTrackQuery] Invalid response format:', result); + return []; + } + + // 가공: CompactVesselTrack → ProcessedTrack + const processed = rawTracks + .map((raw) => processTrack(raw)) + .filter((t) => t !== null); + + console.log(`[fetchTrackQuery] Loaded ${processed.length} tracks`); + return processed; + } catch (error) { + console.error('[fetchTrackQuery] Error:', error); + throw error; + } +} + +/** + * API 응답 데이터를 ProcessedTrack으로 변환 + * 참조: mda-react-front/src/tracking/stores/trackQueryStore.ts - setTracks + * + * @param {Object} raw - API 응답의 개별 항적 데이터 + * @returns {Object|null} ProcessedTrack + */ +function processTrack(raw) { + if (!raw || !raw.geometry || raw.geometry.length === 0) return null; + + const vesselId = raw.vesselId || `${raw.sigSrcCd}_${raw.targetId}`; + + // 타임스탬프를 ms로 변환 (Unix 초 문자열 → ms) + const timestampsMs = (raw.timestamps || []).map((ts) => { + const num = typeof ts === 'string' ? Number(ts) : ts; + // 초 단위면 ms로 변환 (10자리 이하) + return num < 1e12 ? num * 1000 : num; + }); + + // geometry: [[lon, lat], ...] 형태 확인 + const geometry = raw.geometry || []; + + // speeds: knots 배열 + const speeds = raw.speeds || geometry.map(() => 0); + + // 실시간 선박 데이터에서 선명/선종 보강 + const liveShip = findLiveShipData(raw.targetId, raw.sigSrcCd); + + return { + vesselId, + targetId: raw.targetId || '', + sigSrcCd: raw.sigSrcCd || '', + shipName: raw.shipName || liveShip?.shipName || '', + shipKindCode: raw.shipKindCode || liveShip?.signalKindCode || '000027', + nationalCode: raw.nationalCode || liveShip?.nationalCode || '', + integrationTargetId: raw.integrationTargetId || '', + geometry, + timestampsMs, + speeds, + stats: { + totalDistance: raw.totalDistance || 0, + avgSpeed: raw.avgSpeed || 0, + maxSpeed: raw.maxSpeed || 0, + pointCount: raw.pointCount || geometry.length, + }, + }; +} + +/** + * 실시간 선박 데이터에서 매칭되는 선박 찾기 + * @param {string} targetId + * @param {string} sigSrcCd + * @returns {Object|null} + */ +function findLiveShipData(targetId, sigSrcCd) { + if (!targetId) return null; + + const features = useShipStore.getState().features; + + // 정확한 featureId 매칭 + if (sigSrcCd) { + const featureId = sigSrcCd + targetId; + const ship = features.get(featureId); + if (ship) return ship; + } + + // featureId로 못 찾으면 originalTargetId로 검색 + let found = null; + features.forEach((ship) => { + if (ship.originalTargetId === targetId) { + found = ship; + } + }); + + return found; +} + +/** + * 선박 객체에서 항적 조회용 파라미터 추출 + * @param {Object} ship - shipStore의 선박 데이터 + * @returns {{ sigSrcCd: string, targetId: string }} + */ +export function extractVesselIdentifier(ship) { + return { + sigSrcCd: ship.signalSourceCode || '', + targetId: ship.originalTargetId || ship.targetId || '', + }; +} + +/** + * dayjs 없이 Date를 ISO 문자열로 변환 (로컬 시간 기준) + * @param {Date} date + * @returns {string} 'YYYY-MM-DDTHH:mm:ss' + */ +export function toLocalISOString(date) { + const pad = (n) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; +} + +/** + * 통합선박 TARGET_ID 파싱 → 장비별 VesselIdentifier 배열 + * TARGET_ID 형식: "AIS_VPASS_ENAV_VTSAIS_DMFHF" + * 각 위치에 00000이면 해당 장비 없음 + * + * @param {string} targetId - 통합 TARGET_ID + * @returns {Array<{ sigSrcCd: string, targetId: string }>} + */ +export function parseIntegratedTargetId(targetId) { + if (!targetId) return []; + + const parts = targetId.split('_'); + // 위치별 장비 매핑: AIS, VPASS, ENAV, VTS_AIS, D_MF_HF + const equipmentMap = [ + { sigSrcCd: '000001', index: 0 }, // AIS + { sigSrcCd: '000003', index: 1 }, // VPASS + { sigSrcCd: '000002', index: 2 }, // ENAV + { sigSrcCd: '000004', index: 3 }, // VTS_AIS + { sigSrcCd: '000016', index: 4 }, // D_MF_HF + ]; + + const vessels = []; + equipmentMap.forEach(({ sigSrcCd, index }) => { + const id = parts[index]; + if (id && id !== '00000' && id !== '0' && id !== '') { + vessels.push({ sigSrcCd, targetId: id }); + } + }); + + return vessels; +} + +/** + * 선박 객체에서 항적 조회용 선박 목록 생성 + * 통합선박: TARGET_ID 파싱 → 모든 장비 (레이더 제외) + * 단일선박: 기본 identifier 반환 + * + * @param {Object} ship - shipStore 선박 데이터 + * @returns {Array<{ sigSrcCd: string, targetId: string }>} + */ +export function buildVesselListForQuery(ship) { + if (ship.integrate && ship.targetId && ship.targetId.includes('_')) { + return parseIntegratedTargetId(ship.targetId); + } + // 단일 장비 + return [extractVesselIdentifier(ship)]; +} diff --git a/src/components/ship/ShipContextMenu.jsx b/src/components/ship/ShipContextMenu.jsx index 038d6cef..cbcfbaa9 100644 --- a/src/components/ship/ShipContextMenu.jsx +++ b/src/components/ship/ShipContextMenu.jsx @@ -23,8 +23,9 @@ function toKstISOString(date) { const MENU_ITEMS = [ { key: 'track', label: '항적조회' }, - { key: 'analysis', label: '항적분석' }, - { key: 'detail', label: '상세정보' }, + // TODO: 임시 배포용 - 미구현 기능 숨김 + // { key: 'analysis', label: '항적분석' }, + // { key: 'detail', label: '상세정보' }, { key: 'radius', label: '반경설정', hasSubmenu: true }, ]; diff --git a/src/components/ship/ShipDetailModal.jsx b/src/components/ship/ShipDetailModal.jsx index 3b0262d4..017e2e8a 100644 --- a/src/components/ship/ShipDetailModal.jsx +++ b/src/components/ship/ShipDetailModal.jsx @@ -56,13 +56,14 @@ function getShipKindIcon(signalKindCode) { /** * 국기 아이콘 URL 반환 (서버 API) * 참조: mda-react-front/src/services/filterCheck.ts - filterNationFlag() + * 개발 환경에서는 Vite 프록시를 통해 API 서버로 전달됨 * @param {string} nationalCode - MID 숫자코드 (예: '440', '412') * @returns {string} 국기 이미지 URL */ function getNationalFlagUrl(nationalCode) { if (!nationalCode) return null; - const baseUrl = import.meta.env.VITE_API_URL || ''; - return `${baseUrl}/ship/image/small/${nationalCode}.svg`; + // 상대 경로 사용 (Vite 프록시가 /ship/image를 API 서버로 전달) + return `/ship/image/small/${nationalCode}.svg`; } /** @@ -205,6 +206,7 @@ function ShipGallery({ imageUrlList }) { export default function ShipDetailModal({ modal }) { const closeDetailModal = useShipStore((s) => s.closeDetailModal); const updateModalPos = useShipStore((s) => s.updateModalPos); + const isIntegrateMode = useShipStore((s) => s.isIntegrate); // 항적조회 패널 상태 const [showTrackPanel, setShowTrackPanel] = useState(false); @@ -276,7 +278,9 @@ export default function ShipDetailModal({ modal }) { if (startTime >= endTime) return; const isIntegrated = isIntegratedTargetId(ship.targetId); - const queryResult = buildVesselListForQuery(ship, 'modal'); + // 모달 항적조회: 통합모드 ON이면 전체 장비 조회, OFF면 단일 장비 조회 + // isIntegration API 파라미터는 항상 '0' (개별 항적 반환) + const queryResult = buildVesselListForQuery(ship, 'modal', isIntegrateMode); if (!queryResult.canQuery) { useTrackQueryStore.getState().setError(queryResult.errorMessage || '조회 불가'); @@ -304,7 +308,7 @@ export default function ShipDetailModal({ modal }) { store.setError('항적 조회 실패'); } setIsQuerying(false); - }, [modal, toKstISOString]); + }, [modal, toKstISOString, isIntegrateMode]); // 항적조회 패널 열기 + 즉시 3일 조회 const handleOpenTrackPanel = useCallback(async () => { diff --git a/src/components/ship/TrackQueryModal.jsx b/src/components/ship/TrackQueryModal.jsx new file mode 100644 index 00000000..bde49c0f --- /dev/null +++ b/src/components/ship/TrackQueryModal.jsx @@ -0,0 +1,495 @@ +/** + * 항적 조회 컨트롤 뷰어 + * 참조: mda-react-front/src/tracking/components/TrackQueryViewer.tsx + * + * 레이아웃: + * Header: [선명 | TargetId | 선종] [닫기] + * Progress: [시작시간 | 현재시간 | 종료시간] + 프로그레스 바 + * Options: [포인트] [선박아이콘] [선명표시] + * Time Form: [조회기간] [시작] ~ [종료] [조회] + * Equipment Filter: [장비별항적] [전체][기본] [A][V][E][T][D][R] (통합선박만) + * + * 모달 모드에서는 재생/배속 컨트롤 없음 (Phase 2 확장점) + */ +import { useRef, useState, useCallback, useEffect } from 'react'; +import useTrackStore, { getShipKindTrackColor } from '../../stores/trackStore'; +import { SHIP_KIND_LABELS, SIGNAL_FLAG_CONFIGS, SIGNAL_SOURCE_LABELS, TRACK_QUERY_MAX_DAYS, TRACK_QUERY_DEFAULT_DAYS } from '../../types/constants'; +import { showToast } from '../common/Toast'; +import './TrackQueryModal.scss'; + +/** 기본 조회 기간 (일) */ +const DEFAULT_QUERY_DAYS = TRACK_QUERY_DEFAULT_DAYS; + +/** 최대 조회 기간 (일) */ +const MAX_QUERY_DAYS = TRACK_QUERY_MAX_DAYS; + +/** 일 단위를 밀리초로 변환 */ +const DAYS_TO_MS = 24 * 60 * 60 * 1000; + +/** datetime-local 입력용 포맷 */ +function toDateTimeLocal(date) { + const pad = (n) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +/** MM-DD HH:mm 형식 */ +function formatShortDateTime(ms) { + if (!ms) return '--/-- --:--'; + const d = new Date(ms); + const pad = (n) => String(n).padStart(2, '0'); + return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +/** YYYY-MM-DD HH:mm:ss 형식 */ +function formatDateTime(ms) { + if (!ms) return '-'; + const d = new Date(ms); + const pad = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} + +/** + * 항적 조회 뷰어 패널 + */ +export default function TrackQueryModal({ modal }) { + const closeTrackModal = useTrackStore((s) => s.closeTrackModal); + + // 스토어 상태 구독 + const tracks = useTrackStore((s) => s.tracks); + const isLoading = useTrackStore((s) => s.isLoading); + const error = useTrackStore((s) => s.error); + const currentTime = useTrackStore((s) => s.currentTime); + const dataStartTime = useTrackStore((s) => s.dataStartTime); + const dataEndTime = useTrackStore((s) => s.dataEndTime); + const showPoints = useTrackStore((s) => s.showPoints); + const showVirtualShip = useTrackStore((s) => s.showVirtualShip); + const showLabels = useTrackStore((s) => s.showLabels); + + // 시간 입력 + const [startInput, setStartInput] = useState(() => { + const now = new Date(); + return toDateTimeLocal(new Date(now.getTime() - DEFAULT_QUERY_DAYS * DAYS_TO_MS)); + }); + const [endInput, setEndInput] = useState(() => toDateTimeLocal(new Date())); + + // 시작일 변경 핸들러 + const handleStartChange = useCallback((e) => { + setStartInput(e.target.value); + }, []); + + // 종료일 변경 핸들러 + const handleEndChange = useCallback((e) => { + setEndInput(e.target.value); + }, []); + + // 조회 기간 검증 및 자동 조정 (blur 시 실행) + const validateAndAdjustDates = useCallback((changedField) => { + const startDate = new Date(startInput); + const endDate = new Date(endInput); + + // 유효하지 않은 날짜면 무시 + if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) return; + + const diffDays = (endDate - startDate) / DAYS_TO_MS; + + if (changedField === 'start') { + // 시작일이 종료일보다 뒤인 경우 → 종료일을 시작일 + 기본조회기간으로 조정 + if (diffDays < 0) { + const adjustedEnd = new Date(startDate.getTime() + DEFAULT_QUERY_DAYS * DAYS_TO_MS); + setEndInput(toDateTimeLocal(adjustedEnd)); + showToast(`종료일이 시작일보다 앞서 기본 조회기간 ${DEFAULT_QUERY_DAYS}일로 자동 설정됩니다.`); + } + // 최대 조회기간 초과 시 종료일 자동 조정 + else if (diffDays > MAX_QUERY_DAYS) { + const adjustedEnd = new Date(startDate.getTime() + MAX_QUERY_DAYS * DAYS_TO_MS); + setEndInput(toDateTimeLocal(adjustedEnd)); + showToast(`최대 조회기간 ${MAX_QUERY_DAYS}일로 자동 설정됩니다.`); + } + } else { + // 종료일이 시작일보다 앞인 경우 → 시작일을 종료일 - 기본조회기간으로 조정 + if (diffDays < 0) { + const adjustedStart = new Date(endDate.getTime() - DEFAULT_QUERY_DAYS * DAYS_TO_MS); + setStartInput(toDateTimeLocal(adjustedStart)); + showToast(`시작일이 종료일보다 뒤서 기본 조회기간 ${DEFAULT_QUERY_DAYS}일로 자동 설정됩니다.`); + } + // 최대 조회기간 초과 시 시작일 자동 조정 + else if (diffDays > MAX_QUERY_DAYS) { + const adjustedStart = new Date(endDate.getTime() - MAX_QUERY_DAYS * DAYS_TO_MS); + setStartInput(toDateTimeLocal(adjustedStart)); + showToast(`최대 조회기간 ${MAX_QUERY_DAYS}일로 자동 설정됩니다.`); + } + } + }, [startInput, endInput]); + + // blur 핸들러 + const handleStartBlur = useCallback(() => { + validateAndAdjustDates('start'); + }, [validateAndAdjustDates]); + + const handleEndBlur = useCallback(() => { + validateAndAdjustDates('end'); + }, [validateAndAdjustDates]); + + // 드래그 상태 + const [position, setPosition] = useState({ x: 0, y: 0 }); + const dragging = useRef(false); + const dragStart = useRef({ x: 0, y: 0 }); + + // 선박 정보 + const { ships, isIntegrated } = modal; + const ship = ships[0]; + const shipName = ship?.shipName || ''; + const targetId = ship?.originalTargetId || ship?.targetId || ''; + const kindLabel = SHIP_KIND_LABELS[ship?.signalKindCode] || '기타'; + const sourceLabel = SIGNAL_SOURCE_LABELS[ship?.signalSourceCode] || ''; + + const hasTracks = tracks.length > 0; + + // 진행률 계산 + const progress = dataEndTime > dataStartTime + ? ((currentTime - dataStartTime) / (dataEndTime - dataStartTime)) * 100 + : 0; + + // 드래그 핸들러 + const handleDragStart = useCallback((e) => { + dragging.current = true; + dragStart.current = { + x: e.clientX - position.x, + y: e.clientY - position.y, + }; + e.preventDefault(); + }, [position]); + + useEffect(() => { + const handleMouseMove = (e) => { + if (!dragging.current) return; + setPosition({ + x: e.clientX - dragStart.current.x, + y: e.clientY - dragStart.current.y, + }); + }; + const handleMouseUp = () => { dragging.current = false; }; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, []); + + // 재조회 + const handleRequery = useCallback(() => { + const start = new Date(startInput); + const end = new Date(endInput); + + // 유효성 검사 + if (isNaN(start.getTime()) || isNaN(end.getTime())) { + showToast('올바른 날짜/시간을 입력해주세요.'); + return; + } + if (start >= end) { + showToast('종료 시간은 시작 시간보다 이후여야 합니다.'); + return; + } + + useTrackStore.getState().queryTracks(modal.ships, start, end); + }, [startInput, endInput, modal.ships]); + + // 프로그레스 바 클릭 + const handleProgressClick = useCallback((e) => { + const rect = e.currentTarget.getBoundingClientRect(); + const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + useTrackStore.getState().setProgressByRatio(ratio); + }, []); + + // 닫기 + const handleClose = useCallback(() => { + closeTrackModal(modal.id); + }, [closeTrackModal, modal.id]); + + return ( +
+ {/* === Header === */} +
+
+ + {shipName || targetId || '항적조회'} + + {shipName && targetId && ( + + {targetId} + + )} + + {kindLabel} + + {sourceLabel && ( + + {sourceLabel} + + )} + {isIntegrated && ( + + 통합 + + )} + {isLoading && ( + 조회중 + )} +
+
+ +
+
+ + {/* === Body === */} +
+ {/* 에러 */} + {error && ( +
{error}
+ )} + + {/* 로딩 (데이터 없을 때) */} + {isLoading && !hasTracks && ( +
항적 데이터 조회 중...
+ )} + + {/* 데이터 없음 */} + {!isLoading && !hasTracks && !error && ( +
항적 데이터가 없습니다.
+ )} + + {/* === Progress Section === */} + {hasTracks && ( +
+ {/* 시간 정보 (3열) */} +
+ + {formatShortDateTime(dataStartTime)} + + + {formatDateTime(currentTime)} + + + {formatShortDateTime(dataEndTime)} + +
+ + {/* 프로그레스 바 */} +
+
+
+
+
+ + {/* 옵션 체크박스 */} +
+ + + +
+
+ )} + + {/* === Time Form (조회 기간) === */} +
+ 조회 기간 +
+ + ~ + +
+ +
+ + {/* === Equipment Filter (통합선박만) === */} + {isIntegrated && ( + + )} + + {/* === 항적 통계 (단일 항적) === */} + {hasTracks && tracks.length === 1 && ( +
+ 거리: {tracks[0].stats.totalDistance.toFixed(1)} NM + 평균: {tracks[0].stats.avgSpeed.toFixed(1)} kn + 최대: {tracks[0].stats.maxSpeed.toFixed(1)} kn + 포인트: {tracks[0].stats.pointCount} +
+ )} + + {/* === 다중 항적 선박 목록 === */} + {hasTracks && tracks.length > 1 && !isIntegrated && ( +
+ {tracks.map((track) => ( + + ))} +
+ )} +
+
+ ); +} + +/** + * 장비 필터 (통합선박 AVETDR) + * 참조: mda-react-front/src/tracking/hooks/useEquipmentFilter.ts + */ +function EquipmentFilter({ ship }) { + const tracks = useTrackStore((s) => s.tracks); + const disabledSigSrcCds = useTrackStore((s) => s.disabledSigSrcCds); + + // 항적 데이터에 존재하는 장비만 표시 + const availableSigSrcCds = new Set(tracks.map((t) => t.sigSrcCd)); + + const handleToggle = useCallback((sigSrcCd) => { + useTrackStore.getState().toggleEquipment(sigSrcCd); + }, []); + + const handleEnableAll = useCallback(() => { + useTrackStore.getState().enableAllEquipment(); + }, []); + + const handleResetDefault = useCallback(() => { + useTrackStore.getState().resetEquipmentToDefault(ship); + }, [ship]); + + return ( +
+ 장비별 항적 +
+ + +
+
+ {SIGNAL_FLAG_CONFIGS.map((config) => { + const hasData = availableSigSrcCds.has(config.signalSourceCode); + const isEnabled = !disabledSigSrcCds.has(config.signalSourceCode); + + return ( + + ); + })} +
+
+ ); +} + +/** + * 다중 선박 목록 아이템 + */ +function VesselItem({ track }) { + const handleToggle = useCallback(() => { + useTrackStore.getState().toggleVesselEnabled(track.vesselId); + }, [track.vesselId]); + + const enabled = useTrackStore((s) => !s.disabledVesselIds.has(track.vesselId)); + const kindLabel = SHIP_KIND_LABELS[track.shipKindCode] || '기타'; + const color = getShipKindTrackColor(track.shipKindCode); + const rgbaStr = `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${color[3] / 255})`; + + return ( +
+ + + {track.shipName || track.targetId} + + {kindLabel} + + {track.stats.pointCount}pts + +
+ ); +} diff --git a/src/components/ship/TrackQueryModal.scss b/src/components/ship/TrackQueryModal.scss new file mode 100644 index 00000000..892f15e0 --- /dev/null +++ b/src/components/ship/TrackQueryModal.scss @@ -0,0 +1,483 @@ +/** + * 항적 조회 뷰어 스타일 + * 참조: mda-react-front TrackQueryViewer.scss + * 위치: 화면 하단 중앙 고정, 드래그 이동 가능 + */ + +.track-query-viewer { + position: fixed; + bottom: 24px; + left: 50%; + width: 520px; + background: rgba(22, 27, 42, 0.95); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + z-index: 900; + color: #ddd; + font-size: 12px; + backdrop-filter: blur(8px); + + // === Header === + &__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + cursor: grab; + user-select: none; + + &:active { + cursor: grabbing; + } + } + + &__header-info { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + flex: 1; + overflow: hidden; + } + + &__header-actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + margin-left: 8px; + } + + &__ship-name { + font-size: 13px; + font-weight: 600; + color: #fff; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 160px; + } + + &__badge { + padding: 1px 6px; + border-radius: 3px; + font-size: 10px; + font-weight: 500; + white-space: nowrap; + flex-shrink: 0; + + &--id { + background: rgba(255, 255, 255, 0.08); + color: #aab; + } + + &--kind { + background: rgba(79, 195, 247, 0.15); + color: #4fc3f7; + } + + &--signal { + background: rgba(129, 199, 132, 0.15); + color: #81c784; + } + + &--integrated { + background: rgba(255, 183, 77, 0.15); + color: #ffb74d; + } + } + + &__loading-badge { + padding: 1px 6px; + background: #4fc3f7; + border-radius: 3px; + font-size: 10px; + color: #fff; + font-weight: 600; + animation: tqv-pulse 1.2s ease-in-out infinite; + flex-shrink: 0; + } + + &__close-btn { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + color: #999; + font-size: 12px; + cursor: pointer; + padding: 0; + line-height: 1; + transition: all 0.15s; + + &:hover { + background: rgba(255, 255, 255, 0.08); + color: #fff; + border-color: rgba(255, 255, 255, 0.2); + } + } + + // === Body === + &__body { + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px 12px 12px; + } + + // === Error / Loading / Empty === + &__error { + padding: 6px 10px; + background: rgba(255, 68, 68, 0.12); + border: 1px solid rgba(255, 68, 68, 0.25); + border-radius: 4px; + color: #ff6b6b; + font-size: 12px; + } + + &__loading { + text-align: center; + padding: 20px 0; + color: #8b95a5; + font-size: 12px; + } + + &__empty { + text-align: center; + padding: 20px 0; + color: #8b95a5; + font-size: 12px; + } + + // === Progress Section === + &__progress-section { + display: flex; + flex-direction: column; + gap: 6px; + } + + &__time-info { + display: flex; + justify-content: space-between; + align-items: baseline; + } + + &__time-start, + &__time-end { + font-size: 11px; + color: #7a8494; + flex-shrink: 0; + } + + &__time-current { + font-size: 13px; + font-weight: 600; + color: #4fc3f7; + font-variant-numeric: tabular-nums; + } + + &__progress-bar { + position: relative; + height: 16px; + cursor: pointer; + display: flex; + align-items: center; + } + + &__progress-track { + position: absolute; + left: 0; + right: 0; + height: 6px; + background: #2a2d3a; + border-radius: 3px; + } + + &__progress-fill { + position: absolute; + left: 0; + height: 6px; + background: #4fc3f7; + border-radius: 3px; + transition: width 0.05s ease-out; + } + + &__progress-handle { + position: absolute; + top: 50%; + width: 14px; + height: 14px; + background: #4fc3f7; + border: 2px solid #fff; + border-radius: 50%; + transform: translate(-50%, -50%); + pointer-events: none; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); + transition: left 0.05s ease-out; + + &:hover { + transform: translate(-50%, -50%) scale(1.15); + } + } + + // === Options === + &__options { + display: flex; + gap: 16px; + padding-top: 2px; + } + + &__option { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: #8b95a5; + cursor: pointer; + user-select: none; + + input[type='checkbox'] { + accent-color: #4fc3f7; + width: 13px; + height: 13px; + } + + &:hover { + color: #bcc; + } + } + + // === Time Form === + &__time-form { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 6px; + } + + &__form-label { + font-size: 11px; + color: #7a8494; + white-space: nowrap; + flex-shrink: 0; + } + + &__time-inputs { + display: flex; + align-items: center; + gap: 4px; + flex: 1; + min-width: 0; + } + + &__time-separator { + color: #5a6374; + font-size: 12px; + flex-shrink: 0; + } + + &__datetime-input { + flex: 1; + min-width: 0; + padding: 3px 6px; + background: #1a1d28; + border: 1px solid #333a4a; + border-radius: 4px; + color: #ccc; + font-size: 11px; + outline: none; + font-family: inherit; + + &:focus { + border-color: #4fc3f7; + } + + &::-webkit-calendar-picker-indicator { + filter: invert(0.6); + } + } + + &__query-btn { + padding: 4px 14px; + height: 26px; + background: #4fc3f7; + border: none; + border-radius: 4px; + color: #fff; + font-size: 11px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; + transition: background 0.15s; + + &:hover { + background: #3cb0e0; + } + + &:disabled { + background: #3a4050; + color: #6a7080; + cursor: not-allowed; + } + } + + // === Equipment Filter === + &__equipment-filter { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 6px; + } + + &__filter-title { + font-size: 11px; + color: #7a8494; + white-space: nowrap; + flex-shrink: 0; + } + + &__filter-actions { + display: flex; + gap: 4px; + flex-shrink: 0; + } + + &__filter-btn { + padding: 2px 8px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 3px; + color: #8b95a5; + font-size: 10px; + cursor: pointer; + transition: all 0.15s; + + &:hover { + background: rgba(255, 255, 255, 0.1); + color: #ccc; + } + } + + &__equipment-badges { + display: flex; + gap: 4px; + flex-wrap: wrap; + } + + &__equipment-badge { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: 2px solid #555; + border-radius: 4px; + color: #666; + font-size: 11px; + font-weight: 700; + cursor: pointer; + padding: 0; + transition: all 0.15s; + + &.active { + color: #fff; + border-color: transparent; + } + + &.no-data { + opacity: 0.3; + cursor: not-allowed; + } + + &:not(.no-data):hover { + opacity: 0.85; + transform: scale(1.05); + } + } + + // === Stats === + &__stats { + display: flex; + gap: 12px; + flex-wrap: wrap; + font-size: 11px; + color: #7a8494; + padding-top: 4px; + border-top: 1px solid rgba(255, 255, 255, 0.04); + } + + // === Vessel List === + &__vessel-list { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 120px; + overflow-y: auto; + } + + &__vessel-item { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + transition: background 0.15s; + + &:hover { + background: rgba(255, 255, 255, 0.04); + } + + &.disabled { + opacity: 0.35; + } + } + + &__vessel-color { + width: 10px; + height: 10px; + border-radius: 2px; + flex-shrink: 0; + } + + &__vessel-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #ccc; + } + + &__vessel-kind { + color: #7a8494; + font-size: 11px; + flex-shrink: 0; + } + + &__vessel-points { + color: #5a6374; + font-size: 11px; + flex-shrink: 0; + } +} + +@keyframes tqv-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} diff --git a/src/map/layers/trackLayer.js b/src/map/layers/trackLayer.js new file mode 100644 index 00000000..a9224eba --- /dev/null +++ b/src/map/layers/trackLayer.js @@ -0,0 +1,220 @@ +/** + * 항적 Deck.gl 레이어 + * 참조: mda-react-front/src/tracking/layers/trackQueryLayer.ts + * + * 정적 레이어 (tracks 변경 시만 재생성): + * - PathLayer: 항적 경로 라인 (전체) + * - ScatterplotLayer: 항적 포인트 (간인 적용) + * + * 동적 레이어 (currentTime 변경 시 재생성, 경량): + * - IconLayer: 가상 선박 아이콘 + * - TextLayer: 선명 라벨 + */ +import { PathLayer, ScatterplotLayer, IconLayer, TextLayer } from '@deck.gl/layers'; +import { getShipKindTrackColor } from '../../stores/trackStore'; +import { useMapStore, THEME_COLORS, THEME_TYPES } from '../../stores/mapStore'; +import { + ICON_ATLAS_MAPPING, + ICON_MAPPING_KIND_MOVING, +} from '../../types/constants'; +import atlasImg from '../../assets/img/icon/atlas.png'; + +/** 현재 테마 색상 가져오기 */ +function getCurrentThemeColors() { + const { getTheme } = useMapStore.getState(); + const theme = getTheme(); + return THEME_COLORS[theme] || THEME_COLORS[THEME_TYPES.LIGHT]; +} + +/** 포인트 최대 표시 개수 (초과 시 간인) */ +const MAX_POINTS_PER_TRACK = 800; + +/** + * 정적 항적 레이어 생성 + * tracks, showPoints, disabledVesselIds가 변경될 때만 호출 + * currentTime과 무관 - 전체 항적 데이터를 항상 표시 + * + * @param {Object} params + * @param {Array} params.tracks - 항적 데이터 배열 + * @param {boolean} params.showPoints - 포인트 표시 여부 + * @param {string} [params.highlightedVesselId] - 하이라이트할 선박 ID + * @param {Function} [params.onPathHover] - 항적 호버 콜백 + */ +export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselId, onPathHover }) { + const layers = []; + if (!tracks || tracks.length === 0) return layers; + + // 1. PathLayer - 전체 경로 (시간 무관) + const pathData = tracks.map((track) => ({ + path: track.geometry, + color: getShipKindTrackColor(track.shipKindCode), + vesselId: track.vesselId, + })); + + layers.push( + new PathLayer({ + id: 'track-path-layer', + data: pathData, + getPath: (d) => d.path, + getColor: (d) => { + // 하이라이트된 선박이면 노란색 + if (highlightedVesselId && highlightedVesselId === d.vesselId) { + return [255, 255, 0, 255]; + } + return d.color; + }, + getWidth: (d) => { + // 하이라이트된 선박이면 더 두껍게 + if (highlightedVesselId && highlightedVesselId === d.vesselId) { + return 4; + } + return 2; + }, + widthUnits: 'pixels', + widthMinPixels: 1, + widthMaxPixels: 8, + jointRounded: true, + capRounded: true, + pickable: true, + autoHighlight: true, + highlightColor: [255, 255, 0, 220], + onHover: (info) => { + if (onPathHover) { + onPathHover(info.object?.vesselId ?? null); + } + }, + updateTriggers: { + getColor: [highlightedVesselId], + getWidth: [highlightedVesselId], + }, + }) + ); + + // 2. ScatterplotLayer - 포인트 (간인 적용) + if (showPoints) { + const pointData = []; + + tracks.forEach((track) => { + const color = getShipKindTrackColor(track.shipKindCode); + const len = track.geometry.length; + + // 간인: 포인트가 MAX_POINTS_PER_TRACK 초과 시 균등 샘플링 + if (len <= MAX_POINTS_PER_TRACK) { + // 전부 표시 + for (let i = 0; i < len; i++) { + pointData.push({ position: track.geometry[i], color }); + } + } else { + // 균등 간인 + 시작/끝 포인트 보장 + const step = len / MAX_POINTS_PER_TRACK; + for (let i = 0; i < MAX_POINTS_PER_TRACK; i++) { + const idx = Math.min(Math.floor(i * step), len - 1); + pointData.push({ position: track.geometry[idx], color }); + } + // 마지막 포인트 + pointData.push({ position: track.geometry[len - 1], color }); + } + }); + + layers.push( + new ScatterplotLayer({ + id: 'track-point-layer', + data: pointData, + getPosition: (d) => d.position, + getFillColor: (d) => d.color, + getRadius: 3, + radiusUnits: 'pixels', + radiusMinPixels: 2, + radiusMaxPixels: 5, + pickable: false, + }) + ); + } + + return layers; +} + +/** + * 동적 가상선박 레이어 생성 + * currentTime 변경 시마다 호출 (경량 - 데이터 수 = 선박 수) + * + * @param {Object} params + * @param {Array} params.currentPositions - 보간된 현재 위치 배열 + * @param {boolean} params.showVirtualShip - 아이콘 표시 + * @param {boolean} params.showLabels - 선명 라벨 표시 + * @param {Function} [params.onIconHover] - 아이콘 호버 콜백 + * @param {Function} [params.onPathHover] - 하이라이트 설정용 콜백 + * @returns {Array} Deck.gl Layer 배열 + */ +export function createVirtualShipLayers({ currentPositions, showVirtualShip, showLabels, onIconHover, onPathHover }) { + const layers = []; + + if (!currentPositions || currentPositions.length === 0) return layers; + + // 1. IconLayer - 가상 선박 아이콘 + if (showVirtualShip) { + layers.push( + new IconLayer({ + id: 'track-virtual-ship-layer', + data: currentPositions, + iconAtlas: atlasImg, + iconMapping: ICON_ATLAS_MAPPING, + getIcon: (d) => ICON_MAPPING_KIND_MOVING[d.shipKindCode] || 'etcImg', + getPosition: (d) => [d.lon, d.lat], + getSize: 24, + sizeUnits: 'pixels', + getAngle: (d) => -(d.heading || 0), + pickable: true, + onHover: (info) => { + if (info.object) { + // 하이라이트 설정 + if (onPathHover) { + onPathHover(info.object.vesselId); + } + // 툴팁 콜백 + if (onIconHover) { + onIconHover(info.object, info.x, info.y); + } + } else { + if (onPathHover) { + onPathHover(null); + } + if (onIconHover) { + onIconHover(null, 0, 0); + } + } + }, + }) + ); + } + + // 2. TextLayer - 선명 라벨 (가상선박 위치 기준) + if (showLabels) { + const labelData = currentPositions.filter((p) => p.shipName); + + if (labelData.length > 0) { + const themeColors = getCurrentThemeColors(); + + layers.push( + new TextLayer({ + id: 'track-label-layer', + data: labelData, + getPosition: (d) => [d.lon, d.lat], + getText: (d) => d.shipName, + getColor: themeColors.shipLabel, + getSize: 11, + getTextAnchor: 'start', + getAlignmentBaseline: 'center', + getPixelOffset: [14, 0], + fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, Arial, sans-serif', + fontWeight: 'bold', + outlineColor: themeColors.shipLabelOutline, + outlineWidth: 2, + pickable: false, + }) + ); + } + } + + return layers; +} diff --git a/src/stores/trackStore.js b/src/stores/trackStore.js new file mode 100644 index 00000000..034391b4 --- /dev/null +++ b/src/stores/trackStore.js @@ -0,0 +1,443 @@ +/** + * 항적 조회 Zustand 스토어 + * 참조: mda-react-front/src/tracking/stores/trackQueryStore.ts + * + * - 항적 데이터 관리 (조회 결과) + * - 타임라인 (시간 범위, 현재 시간) + * - 선박별/장비별 활성/비활성 토글 + * - 장비 필터 (통합선박 AVETDR) + */ +import { create } from 'zustand'; +import { subscribeWithSelector } from 'zustand/middleware'; +import { + fetchTrackQuery, + extractVesselIdentifier, + toLocalISOString, + buildVesselListForQuery, +} from '../api/trackApi'; + +// ===================== +// 선종별 항적 색상 (RGBA) +// ===================== +export const SHIP_KIND_TRACK_COLORS = { + '000020': [25, 116, 25, 150], // 어선 + '000021': [0, 41, 255, 150], // 함정 + '000022': [176, 42, 42, 150], // 여객선 + '000023': [255, 139, 54, 150], // 화물선 + '000024': [255, 0, 0, 150], // 유조선 + '000025': [92, 30, 224, 150], // 관공선 + '000027': [255, 135, 207, 150], // 기타 + '000028': [232, 95, 27, 150], // 부이 +}; + +export const DEFAULT_TRACK_COLOR = [128, 128, 128, 150]; + +/** + * 선종코드로 항적 색상 반환 + */ +export function getShipKindTrackColor(shipKindCode) { + return SHIP_KIND_TRACK_COLORS[shipKindCode] || DEFAULT_TRACK_COLOR; +} + +/** 기본 조회 기간 (3일) */ +const DEFAULT_QUERY_DAYS = 3; + +// ===================== +// 항적 스토어 +// ===================== +const useTrackStore = create(subscribeWithSelector((set, get) => ({ + // ===================== + // 항적 데이터 + // ===================== + + /** 조회된 항적 배열 (ProcessedTrack[]) */ + tracks: [], + + /** 비활성화된 선박 ID Set */ + disabledVesselIds: new Set(), + + /** 비활성화된 장비(신호원) Set - 통합선박 장비필터용 */ + disabledSigSrcCds: new Set(), + + // ===================== + // 시간 범위 + // ===================== + + /** 데이터 전체 시작 시간 (ms) */ + dataStartTime: 0, + + /** 데이터 전체 종료 시간 (ms) */ + dataEndTime: 0, + + /** 조회 요청 시작 시간 (ms) */ + requestedStartTime: 0, + + /** 현재 시간 위치 (ms) - 프로그레스 바 위치 */ + currentTime: 0, + + // ===================== + // 로딩/에러 + // ===================== + isLoading: false, + error: null, + + // ===================== + // 표시 옵션 + // ===================== + showPoints: true, + showVirtualShip: true, + showLabels: true, + + // ===================== + // 모달 상태 + // ===================== + + /** 항적 조회 모달 배열 [{ ships, id, initialPos, isIntegrated }] */ + trackModals: [], + + // ===================== + // 액션 (Actions) + // ===================== + + /** + * 항적 데이터 설정 + * currentTime을 dataEndTime으로 설정하여 전체 항적이 즉시 보이도록 함 + */ + setTracks: (tracks, requestedStartTime) => { + if (!tracks || tracks.length === 0) { + set({ + tracks: [], + dataStartTime: 0, + dataEndTime: 0, + requestedStartTime: 0, + currentTime: 0, + disabledVesselIds: new Set(), + disabledSigSrcCds: new Set(), + }); + return; + } + + // 전체 시간 범위 계산 + let minTime = Infinity; + let maxTime = -Infinity; + + tracks.forEach((track) => { + if (track.timestampsMs.length > 0) { + const first = track.timestampsMs[0]; + const last = track.timestampsMs[track.timestampsMs.length - 1]; + if (first < minTime) minTime = first; + if (last > maxTime) maxTime = last; + } + }); + + set({ + tracks, + dataStartTime: minTime, + dataEndTime: maxTime, + requestedStartTime: requestedStartTime || minTime, + // 핵심 수정: currentTime을 dataEndTime으로 설정 → 전체 항적 즉시 표시 + currentTime: maxTime, + disabledVesselIds: new Set(), + disabledSigSrcCds: new Set(), + error: null, + }); + }, + + /** + * 현재 시간 설정 (범위 클램프) + */ + setCurrentTime: (time) => { + const { dataStartTime, dataEndTime } = get(); + const clamped = Math.max(dataStartTime, Math.min(dataEndTime, time)); + set({ currentTime: clamped }); + }, + + /** + * 진행률로 시간 설정 (0~1) + */ + setProgressByRatio: (ratio) => { + const { dataStartTime, dataEndTime } = get(); + const time = dataStartTime + (dataEndTime - dataStartTime) * ratio; + set({ currentTime: time }); + }, + + setLoading: (loading) => set({ isLoading: loading }), + setError: (error) => set({ error }), + setShowPoints: (show) => set({ showPoints: show }), + setShowVirtualShip: (show) => set({ showVirtualShip: show }), + setShowLabels: (show) => set({ showLabels: show }), + + /** + * 선박 활성/비활성 토글 + */ + toggleVesselEnabled: (vesselId) => { + const { disabledVesselIds } = get(); + const newSet = new Set(disabledVesselIds); + if (newSet.has(vesselId)) { + newSet.delete(vesselId); + } else { + newSet.add(vesselId); + } + set({ disabledVesselIds: newSet }); + }, + + isVesselEnabled: (vesselId) => !get().disabledVesselIds.has(vesselId), + + /** + * 장비(신호원) 토글 - 통합선박 장비필터 + */ + toggleEquipment: (sigSrcCd) => { + const { disabledSigSrcCds, tracks } = get(); + const newDisabledSrc = new Set(disabledSigSrcCds); + const newDisabledVessels = new Set(get().disabledVesselIds); + + if (newDisabledSrc.has(sigSrcCd)) { + // 활성화 + newDisabledSrc.delete(sigSrcCd); + tracks.filter((t) => t.sigSrcCd === sigSrcCd).forEach((t) => { + newDisabledVessels.delete(t.vesselId); + }); + } else { + // 비활성화 + newDisabledSrc.add(sigSrcCd); + tracks.filter((t) => t.sigSrcCd === sigSrcCd).forEach((t) => { + newDisabledVessels.add(t.vesselId); + }); + } + + set({ disabledSigSrcCds: newDisabledSrc, disabledVesselIds: newDisabledVessels }); + }, + + /** 장비 전체 활성화 */ + enableAllEquipment: () => { + set({ disabledSigSrcCds: new Set(), disabledVesselIds: new Set() }); + }, + + /** 장비 기본값 복원 (ship의 active 장비만 활성) */ + resetEquipmentToDefault: (ship) => { + if (!ship) return; + const { tracks } = get(); + const newDisabledSrc = new Set(); + const newDisabledVessels = new Set(); + + // ship 객체의 active 플래그로 판단 + const activeMap = { + '000001': ship.ais, + '000003': ship.vpass, + '000002': ship.enav, + '000004': ship.vtsAis, + '000016': ship.dMfHf, + '000005': ship.vtsRadar, + }; + + Object.entries(activeMap).forEach(([srcCd, isActive]) => { + if (!isActive) { + newDisabledSrc.add(srcCd); + tracks.filter((t) => t.sigSrcCd === srcCd).forEach((t) => { + newDisabledVessels.add(t.vesselId); + }); + } + }); + + set({ disabledSigSrcCds: newDisabledSrc, disabledVesselIds: newDisabledVessels }); + }, + + isEquipmentEnabled: (sigSrcCd) => !get().disabledSigSrcCds.has(sigSrcCd), + + /** + * 활성화된 항적만 반환 + */ + getEnabledTracks: () => { + const { tracks, disabledVesselIds } = get(); + return tracks.filter((t) => !disabledVesselIds.has(t.vesselId)); + }, + + /** + * 현재 시간 기준 각 선박의 보간된 위치 반환 + * 이진 탐색 + 선형 보간 + */ + getCurrentPositions: () => { + const { tracks, currentTime, disabledVesselIds } = get(); + const positions = []; + + tracks.forEach((track) => { + if (disabledVesselIds.has(track.vesselId)) return; + + const { timestampsMs, geometry, speeds } = track; + if (timestampsMs.length === 0) return; + + // 범위 밖 + if (currentTime <= timestampsMs[0]) { + positions.push({ + vesselId: track.vesselId, + lon: geometry[0][0], + lat: geometry[0][1], + heading: 0, + speed: speeds[0] || 0, + shipName: track.shipName, + shipKindCode: track.shipKindCode, + }); + return; + } + + if (currentTime >= timestampsMs[timestampsMs.length - 1]) { + const last = geometry.length - 1; + positions.push({ + vesselId: track.vesselId, + lon: geometry[last][0], + lat: geometry[last][1], + heading: 0, + speed: speeds[last] || 0, + shipName: track.shipName, + shipKindCode: track.shipKindCode, + }); + return; + } + + // 이진 탐색 + let lo = 0; + let hi = timestampsMs.length - 1; + while (lo < hi - 1) { + const mid = (lo + hi) >> 1; + if (timestampsMs[mid] <= currentTime) lo = mid; + else hi = mid; + } + + // 선형 보간 + const t1 = timestampsMs[lo]; + const t2 = timestampsMs[hi]; + const ratio = t2 === t1 ? 0 : (currentTime - t1) / (t2 - t1); + + const p1 = geometry[lo]; + const p2 = geometry[hi]; + const lon = p1[0] + (p2[0] - p1[0]) * ratio; + const lat = p1[1] + (p2[1] - p1[1]) * ratio; + + // 방위각 + const dLon = (p2[0] - p1[0]) * Math.PI / 180; + const lat1Rad = p1[1] * Math.PI / 180; + const lat2Rad = p2[1] * Math.PI / 180; + const y = Math.sin(dLon) * Math.cos(lat2Rad); + const x = Math.cos(lat1Rad) * Math.sin(lat2Rad) - + Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(dLon); + let heading = Math.atan2(y, x) * 180 / Math.PI; + if (heading < 0) heading += 360; + + const speed = speeds[lo] + (speeds[hi] - speeds[lo]) * ratio; + + positions.push({ + vesselId: track.vesselId, + lon, lat, heading, speed, + shipName: track.shipName, + shipKindCode: track.shipKindCode, + }); + }); + + return positions; + }, + + getProgress: () => { + const { dataStartTime, dataEndTime, currentTime } = get(); + if (dataEndTime === dataStartTime) return 0; + return ((currentTime - dataStartTime) / (dataEndTime - dataStartTime)) * 100; + }, + + // ===================== + // 항적 모달 관리 + // ===================== + + /** + * 항적 조회 모달 열기 + 즉시 3일 자동 조회 + */ + openTrackModal: (ships) => { + const state = get(); + const id = ships.map((s) => s.featureId || s.originalTargetId).join(','); + + if (state.trackModals.some((m) => m.id === id)) return; + + // 통합선박 여부 판단 + const isIntegrated = ships.length === 1 && !!ships[0].integrate; + + const newModal = { ships, id, isIntegrated }; + + // 기존 모달 대체 (하나의 항적만 활성) + set({ trackModals: [newModal] }); + + // 즉시 3일 자동 조회 + get().queryTracks(ships); + }, + + /** + * 항적 조회 실행 + */ + queryTracks: async (ships, startDate, endDate) => { + const now = new Date(); + const start = startDate || new Date(now.getTime() - DEFAULT_QUERY_DAYS * 24 * 60 * 60 * 1000); + const end = endDate || now; + + set({ isLoading: true, error: null }); + + try { + const startTime = toLocalISOString(start); + const endTime = toLocalISOString(end); + + // 통합선박이면 장비별로 쿼리 목록 생성 + const ship = ships[0]; + const isIntegrated = ships.length === 1 && ship.integrate; + const vessels = isIntegrated + ? buildVesselListForQuery(ship) + : ships.map((s) => extractVesselIdentifier(s)); + + const result = await fetchTrackQuery({ + startTime, + endTime, + vessels, + isIntegration: false, + }); + + get().setTracks(result, start.getTime()); + } catch (err) { + set({ error: err.message || '항적 조회 실패' }); + } finally { + set({ isLoading: false }); + } + }, + + /** + * 항적 모달 닫기 + */ + closeTrackModal: (modalId) => { + set({ + trackModals: [], + tracks: [], + dataStartTime: 0, + dataEndTime: 0, + currentTime: 0, + error: null, + isLoading: false, + disabledVesselIds: new Set(), + disabledSigSrcCds: new Set(), + }); + }, + + /** 전체 초기화 */ + reset: () => { + set({ + tracks: [], + disabledVesselIds: new Set(), + disabledSigSrcCds: new Set(), + dataStartTime: 0, + dataEndTime: 0, + requestedStartTime: 0, + currentTime: 0, + isLoading: false, + error: null, + showPoints: true, + showVirtualShip: true, + showLabels: true, + trackModals: [], + }); + }, +}))); + +export default useTrackStore; diff --git a/src/tracking/components/GlobalTrackQueryViewer.jsx b/src/tracking/components/GlobalTrackQueryViewer.jsx new file mode 100644 index 00000000..b4e4a9a7 --- /dev/null +++ b/src/tracking/components/GlobalTrackQueryViewer.jsx @@ -0,0 +1,51 @@ +/** + * 전역 항적조회 뷰어 컴포넌트 + * + * 우클릭 컨텍스트 메뉴 등에서 호출된 항적조회 결과를 표시 + * - 메인 맵에 전역으로 마운트되어 항상 사용 가능 + * - trackQueryStore에 데이터가 있을 때만 표시 + * - 닫기 버튼으로 항적 레이어 및 데이터 정리 + */ + +import React, { useCallback, useEffect } from 'react'; +import { useTrackQueryStore } from '../stores/trackQueryStore'; +import { TrackQueryViewer } from './TrackQueryViewer'; +import { unregisterTrackQueryLayers } from '../utils/trackQueryLayerUtils'; +import { shipBatchRenderer } from '../../map/ShipBatchRenderer'; + +export const GlobalTrackQueryViewer = () => { + const tracks = useTrackQueryStore(state => state.tracks); + const isModalMode = useTrackQueryStore(state => state.isModalMode); + const showPlayback = useTrackQueryStore(state => state.showPlayback); + const resetStore = useTrackQueryStore(state => state.reset); + + // 컴포넌트 언마운트 시 정리 + useEffect(() => { + return () => { + unregisterTrackQueryLayers(); + }; + }, []); + + // 닫기 핸들러 + const handleClose = useCallback(() => { + unregisterTrackQueryLayers(); + resetStore(); + + // deck.gl 레이어 업데이트 + shipBatchRenderer.immediateRender(); + }, [resetStore]); + + // 선박 모달 모드일 때는 전역 뷰어 숨김 + if (isModalMode) { + return null; + } + + // 데이터가 없으면 렌더링하지 않음 + if (tracks.length === 0) { + return null; + } + + return ; +}; + +export default GlobalTrackQueryViewer; diff --git a/src/tracking/components/GlobalTrackQueryViewer.scss b/src/tracking/components/GlobalTrackQueryViewer.scss new file mode 100644 index 00000000..37535a95 --- /dev/null +++ b/src/tracking/components/GlobalTrackQueryViewer.scss @@ -0,0 +1,43 @@ +/** + * 전역 항적조회 뷰어 스타일 + */ + +.global-track-query-container { + position: fixed; + z-index: 1001; + + .global-track-query-close-btn { + position: fixed; + bottom: 130px; + left: calc(50% + 420px); + z-index: 1001; + width: 28px; + height: 28px; + border: none; + border-radius: 50%; + background: rgba(220, 53, 69, 0.9); + color: #fff; + font-size: 18px; + font-weight: bold; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + transition: all 0.2s ease; + + &:hover { + background: rgba(200, 35, 51, 1); + transform: scale(1.1); + } + + &:active { + transform: scale(0.95); + } + + span { + line-height: 1; + margin-top: -2px; + } + } +} diff --git a/src/tracking/components/QueryProgressPanel.scss b/src/tracking/components/QueryProgressPanel.scss new file mode 100644 index 00000000..43046f99 --- /dev/null +++ b/src/tracking/components/QueryProgressPanel.scss @@ -0,0 +1,234 @@ +// WebSocket 쿼리 진행률 패널 스타일 + +.query-progress-panel { + background: rgba(255, 255, 255, 0.97); + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); + overflow: hidden; + transition: all 0.3s ease; + margin-bottom: 0.75rem; + backdrop-filter: blur(5px); + + // 확장 모드 + &.expanded { + .progress-content-expanded { + padding: 2rem; + animation: slideDown 0.3s ease; + + .progress-header { + display: flex; + justify-content: center; + align-items: flex-start; + margin-bottom: 0.75rem; + + .progress-title { + font-size: 1rem; + font-weight: 600; + color: #212529; + display: flex; + align-items: center; + + i { + color: #0d6efd; + animation: pulse-icon 2s infinite; + font-size: 1.1rem; + } + } + + .minimize-btn { + padding: 0; + color: #6c757d; + text-decoration: none; + background: transparent !important; + border: none; + margin-top: -0.25rem; + line-height: 1; + + &:hover { + color: #495057; + background: transparent !important; + } + + &:focus { + box-shadow: none; + } + + i { + font-size: 1.2rem; + } + } + } + + .progress-bar-wrapper { + margin-bottom: 0.75rem; + position: relative; + + .progress { + height: 36px; + border-radius: 8px; + background-color: #dddddd; + position: relative; + overflow: hidden; + + .progress-bar { + transition: width 0.5s ease; + background: linear-gradient(90deg, #213079 0%, #213079 100%); + border-radius: 8px; + position: relative; + display: flex; + align-items: center; + justify-content: center; + min-width: 60px; + height: 100%; + + span { + position: absolute; + left: 50%; + transform: translateX(-50%); + color: white; + font-size: 0.9rem; + font-weight: 700; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); + white-space: nowrap; + z-index: 2; + } + } + } + } + + .progress-details { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.6rem; + font-size: 0.85rem; + + .detail-row { + display: flex; + //align-items: center; + //gap: 0.5rem; + //padding: 0.5rem 0.75rem; + //background: rgba(13, 110, 253, 0.05); + //border-radius: 6px; + //white-space: nowrap; + + i { + color: #0d6efd; + font-size: 1rem; + flex-shrink: 0; + } + + .detail-des { + display: flex; + align-items: center; + white-space: nowrap; + color: #212529; + font-weight: 600; + font-size: 0.85rem; + flex: 0.4; + } + + .detail-text { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: rgba(13, 110, 253, 0.05); + //border-radius: 6px; + white-space: nowrap; + color: #212529; + font-weight: 600; + font-family: 'Monaco', 'Consolas', monospace; + font-size: 0.85rem; + flex: 0.6; + } + } + + @media (max-width: 600px) { + grid-template-columns: 1fr; + } + } + } + } + + // 최소화 모드 + &.minimized { + .progress-content-minimized { + padding: 0.75rem 1rem; + cursor: pointer; + transition: background 0.2s ease; + + &:hover { + background: rgba(13, 110, 253, 0.05); + } + + .minimized-info { + display: flex; + align-items: center; + margin-bottom: 0.4rem; + font-size: 0.85rem; + + i { + color: #0d6efd; + animation: pulse-icon 2s infinite; + font-size: 1rem; + } + + .minimized-text { + font-weight: 600; + color: #212529; + } + + small { + font-size: 0.75rem; + } + } + + .minimized-progress { + height: 6px; + background: #e9ecef; + border-radius: 3px; + overflow: hidden; + + .minimized-progress-bar { + height: 100%; + background: linear-gradient(90deg, #0d6efd 0%, #0a58ca 100%); + transition: width 0.5s ease; + animation: shimmer 1.5s infinite; + } + } + } + } +} + +// 애니메이션 +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes pulse-icon { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.7; + transform: scale(1.1); + } +} + +@keyframes shimmer { + 0% { + background-position: -200% center; + } + 100% { + background-position: 200% center; + } +} diff --git a/src/tracking/components/ReplayControlV2.scss b/src/tracking/components/ReplayControlV2.scss new file mode 100644 index 00000000..fa28419e --- /dev/null +++ b/src/tracking/components/ReplayControlV2.scss @@ -0,0 +1,132 @@ +.sig-src-cd-filter { + background: white; + //border-radius: 8px; + //padding: 16px; + //box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + //margin-bottom: 16px; + display: flex; + border-bottom: 1px solid #dddddd; + + .filter-header { + width: 50px; + display: flex; + align-items: center; + justify-content: center; + background-color: #f0f0f0; + min-height: 54px; + + h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: #333; + } + + .filter-actions { + display: flex; + gap: 8px; + + button { + padding: 4px 8px; + font-size: 12px; + border: 1px solid #ddd; + background: white; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: #f5f5f5; + } + + &.select-all-btn { + color: #1890ff; + border-color: #1890ff; + + &:hover { + background: #e6f7ff; + } + } + + &.deselect-all-btn { + color: #ff4d4f; + border-color: #ff4d4f; + + &:hover { + background: #fff1f0; + } + } + } + } + } + + .filter-list { + width: 80%; + display: flex; + flex-wrap: wrap; + gap: 25px 0; + padding: 12px 10px; + font-size: 14px; + .filter-item { + display: flex; + align-items: center; + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: background-color 0.2s; + + &:hover { + background-color: #f5f5f5; + } + + input[type="checkbox"] { + margin-right: 8px; + cursor: pointer; + } + + .filter-name { + font-size: 13px; + color: #333; + user-select: none; + } + } + .toggle-area { + input[type="checkbox"]:checked + label:after { + transform: translate3d(12px,0,0); + } + label { + width: 26px; + height: 14px; + &::after { + width: 10px; + height: 10px; + } + } + input[type="checkbox"] { + display: none; + &:checked { + +label { + background-color: #2494D3; + } + } + } + label { + cursor: pointer; + display: block; + position: relative; + transition: all 0.4s ease-out; + background-color: #AAAAAA; + border-radius: 20px; + &::after { + position: absolute; + top: 2px; + left: 3px; + transition: all 0.4s ease-out; + background-color:#ffffff; + border-radius: 50%; + content: ""; + } + } + } + } +} \ No newline at end of file diff --git a/src/tracking/components/ReplayV2.scss b/src/tracking/components/ReplayV2.scss new file mode 100644 index 00000000..34b29fcf --- /dev/null +++ b/src/tracking/components/ReplayV2.scss @@ -0,0 +1,455 @@ +/** + * 리플레이 v2 컴포넌트 스타일 - 충돌 방지 버전 + * 기존 tracking 컴포넌트 스타일과 충돌하지 않도록 네임스페이스 사용 + */ + +@keyframes replay-slideUpFadeIn { + from { + opacity: 0; + transform: translate(-50%, 20px); + } + to { + opacity: 1; + transform: translate(-50%, 0); + } +} + +// ReplayV2 전용 컨테이너 (네임스페이스로 스타일 격리) +.replay-v2-container-new { + position: fixed; + bottom: 60px; + left: 50%; + transform: translateX(-50%); + width: 700px; + max-width: 95vw; + max-height: 40vh; + z-index: 1060; + //background: #ffffff; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2), 0 8px 16px rgba(0, 0, 0, 0.1); + //border: 1px solid rgba(33, 48, 121, 0.2); + //backdrop-filter: blur(10px); + animation: replay-slideUpFadeIn 0.3s ease-out; + overflow: visible; // 드롭다운이 잘리지 않도록 변경 + + // 네임스페이스 내에서만 적용되는 스타일 + > .card { + border: none; + background: transparent; + height: 100%; + overflow: visible; // 드롭다운이 잘리지 않도록 + } + + > .card > .card-header { + background: linear-gradient(135deg, #213079 0%, #1a2660 100%); + color: #ffffff; + border-bottom: none; + border-radius: 12px 12px 0 0; + padding: 6px 10px; + overflow: hidden; // 헤더 내부 요소는 border-radius 안에 유지 + + h5 { + font-weight: 600; + font-size: 13px; + margin-bottom: 0; + letter-spacing: -0.02em; + } + + .btn { + border-radius: 5px; + font-size: 11px; + padding: 2px 5px; + transition: all 0.2s ease; + + &:hover { + transform: scale(1.05); + } + } + } + + > .card > .card-body.compact-layout { + //padding: 6px; + display: flex; + flex-direction: column; + gap: 5px; + overflow: visible; // 드롭다운이 잘리지 않도록 + } + + // 타임라인 섹션 (항상 상단에 표시) + .replay-timeline-section { + flex-shrink: 0; // 축소되지 않도록 + overflow: visible; // 드롭다운이 잘리지 않도록 + border-radius: 10px; + } + + // 접을 수 있는 필터 컨트롤 + .replay-filter-collapse { + flex-shrink: 0; + + &[open] { + .replay-filter-toggle { + span:first-child { + transform: rotate(90deg); + } + } + } + + .replay-filter-toggle { + list-style: none; + outline: none; + transition: all 0.2s ease; + + &::-webkit-details-marker { + display: none; + } + + span:first-child { + transition: transform 0.2s ease; + display: inline-block; + } + + &:hover { + background: linear-gradient(135deg, #bbdefb 0%, #90caf9 100%); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + } + + .replay-filter-content { + animation: slideDown 0.2s ease-out; + } + } + + @keyframes slideDown { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + // 컴팩트 컨트롤 패널 - 고유한 클래스명 사용 + .replay-compact-control-panel { + // 첫 번째 행 - Flexbox로 수평 배치 + .replay-control-row { + display: flex; + gap: 6px; + margin-bottom: 5px; + + // 각 섹션을 동일한 너비로 배치 + .replay-info-section, + .replay-timeline-section, + .replay-params-section { + flex: 1; + min-width: 0; // flexbox 오버플로우 방지 + } + } + + // 정보 섹션 + .replay-info-section { + .replay-info-box { + background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%); + border: 1px solid #2196f3; + border-radius: 6px; + padding: 5px; + height: 56px; + display: flex; + align-items: center; + justify-content: center; + + // TrackingStats를 안전하게 래핑 + .tracking-stats { + width: 100%; + text-align: center; + + .stat-card { + background: transparent !important; + border: none !important; + padding: 2px !important; + margin: 0 !important; + + .stat-value { + font-size: 0.9rem !important; + margin-bottom: 1px !important; + } + + .stat-label { + font-size: 0.6rem !important; + } + } + } + } + } + + // 타임라인 섹션 (드래그 가능한 영역) + .replay-timeline-section { + position: relative; + + // ReplayTimeline 스타일 조정 + .replay-timeline { + .timeline-header { + .header-title { + font-size: 12px; + } + + .header-date-range { + font-size: 10px; + } + } + + .timeline-controls { + gap: 6px; + padding: 6px 10px; + + .control-btn { + width: 28px; + height: 28px; + font-size: 10px; + } + + .speed-btn { + font-size: 10px; + padding: 4px 8px; + min-width: 45px; + } + + .current-time-display { + font-size: 10px; + min-width: 115px; + } + } + } + } + + // 파라미터 섹션 + .replay-params-section { + .replay-params-box { + background: linear-gradient(135deg, #f3e5f5 0%, #e1bee7 100%); + border: 1px solid #9c27b0; + border-radius: 6px; + padding: 5px; + height: 56px; + + .replay-section-title { + font-size: 10px; + font-weight: 600; + color: #7b1fa2; + margin-bottom: 4px; + display: flex; + align-items: center; + + i { + margin-right: 2px; + font-size: 8px; + } + } + + .replay-param-item { + margin-bottom: 2px; + + .replay-param-label { + font-size: 8px; + color: #7b1fa2; + font-weight: 600; + margin-right: 4px; + } + + .replay-param-value { + font-size: 8px; + color: #4a148c; + font-weight: 500; + } + + .badge { + font-size: 7px; + padding: 1px 3px; + } + } + } + } + + // 고급 설정 (접힌 상태) + .replay-advanced-control { + margin-top: 5px; + + .replay-advanced-summary { + list-style: none; + outline: none; + cursor: pointer; + font-size: 10px; + font-weight: 600; + color: #495057; + padding: 4px 0; + border-bottom: 1px solid #dee2e6; + background: none; + border: none; + display: flex; + align-items: center; + + &::before { + content: '▶'; + margin-right: 4px; + transition: transform 0.2s; + font-size: 8px; + } + + i { + margin-right: 4px; + font-size: 8px; + } + } + + &[open] .replay-advanced-summary::before { + transform: rotate(90deg); + } + + .replay-advanced-content { + max-height: 150px; + overflow-y: auto; + margin-top: 4px; + + // TrackingControl 내부 스타일 최소한 조정 + .tracking-control { + .card { + font-size: 9px !important; + + .card-header { + padding: 4px 6px !important; + + h5 { + font-size: 9px !important; + margin: 0 !important; + } + } + + .card-body { + padding: 6px !important; + + .form-label { + font-size: 8px !important; + margin-bottom: 1px !important; + } + + .form-control, + .form-select { + font-size: 8px !important; + padding: 1px 3px !important; + height: auto !important; + } + + .btn { + font-size: 8px !important; + padding: 1px 3px !important; + } + + .btn-group .btn { + font-size: 7px !important; + padding: 1px 2px !important; + } + + .mb-3 { + margin-bottom: 0.25rem !important; + } + } + } + } + } + } + } + + // 알림 메시지 + .replay-compact-alert { + padding: 3px 5px; + font-size: 9px; + border-radius: 5px; + margin-bottom: 5px; + } + + // 반응형 조정 + @media (max-width: 1200px) { + width: 95vw; + + .replay-compact-control-panel .replay-control-row { + flex-direction: column; + gap: 6px; + } + + .replay-info-box, + .replay-timeline-box, + .replay-params-box { + height: 48px !important; + } + } + + @media (max-width: 768px) { + width: 98vw; + bottom: 10px; + + > .card > .card-header { + padding: 5px 8px; + + h5 { + font-size: 12px; + } + + .btn { + font-size: 9px; + padding: 2px 4px; + } + } + + > .card > .card-body.compact-layout { + padding: 4px; + } + + .replay-info-box, + .replay-timeline-box, + .replay-params-box { + height: 40px !important; + padding: 3px !important; + } + } + + @media (max-width: 480px) { + width: 99vw; + bottom: 5px; + + .replay-info-box, + .replay-timeline-box, + .replay-params-box { + height: 32px !important; + } + } +} + +// 기존 컨테이너 숨김 +.replay-v2-container { + display: none !important; +} + +// QueryProgressPanel 위치 조정 (ReplayV2와 독립적으로 표시) +.query-progress-panel { + position: fixed; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 500px; + max-width: 95vw; + z-index: 1059; // ReplayV2보다 약간 낮은 z-index + + @media (max-width: 1200px) { + width: 95vw; + bottom: 260px; + } + + @media (max-width: 768px) { + width: 98vw; + bottom: 240px; + } + + @media (max-width: 480px) { + width: 99vw; + bottom: 220px; + } +} \ No newline at end of file diff --git a/src/tracking/components/TrackQueryTimeline.jsx b/src/tracking/components/TrackQueryTimeline.jsx new file mode 100644 index 00000000..4df51c53 --- /dev/null +++ b/src/tracking/components/TrackQueryTimeline.jsx @@ -0,0 +1,308 @@ +/** + * 항적조회 전용 타임라인 컨트롤 + * + * TrackQueryViewer와 함께 사용하는 재생 컨트롤 + * - 재생/일시정지/정지 + * - 배속 조절 (1x ~ 1000x) + * - 반복 재생 + */ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useTrackQueryAnimationStore, PLAYBACK_SPEED_OPTIONS } from '../stores/trackQueryAnimationStore'; +import { useTrackQueryStore } from '../stores/trackQueryStore'; +import './TrackQueryTimeline.scss'; + +/** + * 항적조회 타임라인 컨트롤 컴포넌트 + * + * @param {Object} props + * @param {number} props.startTime 데이터 시작 시간 + * @param {number} props.endTime 데이터 종료 시간 + * @param {boolean} [props.compact] 컴팩트 모드 + */ +export const TrackQueryTimeline = ({ startTime, endTime, compact = false }) => { + // 애니메이션 스토어 + const isPlaying = useTrackQueryAnimationStore(state => state.isPlaying); + const animationCurrentTime = useTrackQueryAnimationStore(state => state.currentTime); + const playbackSpeed = useTrackQueryAnimationStore(state => state.playbackSpeed); + const loop = useTrackQueryAnimationStore(state => state.loop); + const loopStart = useTrackQueryAnimationStore(state => state.loopStart); + const loopEnd = useTrackQueryAnimationStore(state => state.loopEnd); + const play = useTrackQueryAnimationStore(state => state.play); + const pause = useTrackQueryAnimationStore(state => state.pause); + const stop = useTrackQueryAnimationStore(state => state.stop); + const setAnimationCurrentTime = useTrackQueryAnimationStore(state => state.setCurrentTime); + const setPlaybackSpeed = useTrackQueryAnimationStore(state => state.setPlaybackSpeed); + const toggleLoop = useTrackQueryAnimationStore(state => state.toggleLoop); + const setLoopSection = useTrackQueryAnimationStore(state => state.setLoopSection); + const setTimeRange = useTrackQueryAnimationStore(state => state.setTimeRange); + const getProgress = useTrackQueryAnimationStore(state => state.getProgress); + const getLoopProgress = useTrackQueryAnimationStore(state => state.getLoopProgress); + + // 항적조회 스토어 (시간 동기화용) + const setProgressByRatio = useTrackQueryStore(state => state.setProgressByRatio); + + // 배속 드롭다운 상태 + const [showSpeedMenu, setShowSpeedMenu] = useState(false); + const speedMenuRef = useRef(null); + const sliderContainerRef = useRef(null); + + // 구간반복 마커 드래그 상태 + const [draggingMarker, setDraggingMarker] = useState(null); + + // 시간 범위 설정 + useEffect(() => { + if (startTime > 0 && endTime > startTime) { + setTimeRange(startTime, endTime); + } + }, [startTime, endTime, setTimeRange]); + + // 스토어 동기화 쓰로틀링용 ref (200ms = 5fps, 배치 렌더러와 동기화) + const lastSyncTimeRef = useRef(0); + const wasPlayingRef = useRef(false); + const SYNC_THROTTLE_MS = 200; + + // 애니메이션 시간 → 항적 스토어 시간 동기화 (쓰로틀링 적용) + useEffect(() => { + if (isPlaying && animationCurrentTime >= startTime && animationCurrentTime <= endTime) { + const now = performance.now(); + if (now - lastSyncTimeRef.current >= SYNC_THROTTLE_MS) { + lastSyncTimeRef.current = now; + const ratio = (animationCurrentTime - startTime) / (endTime - startTime); + setProgressByRatio(ratio); + } + wasPlayingRef.current = true; + } else if (wasPlayingRef.current && !isPlaying) { + wasPlayingRef.current = false; + const ratio = (animationCurrentTime - startTime) / (endTime - startTime); + setProgressByRatio(Math.max(0, Math.min(1, ratio))); + } + }, [animationCurrentTime, isPlaying, startTime, endTime, setProgressByRatio]); + + // 외부 클릭 시 드롭다운 닫기 + useEffect(() => { + const handleClickOutside = (event) => { + if (speedMenuRef.current && !speedMenuRef.current.contains(event.target)) { + setShowSpeedMenu(false); + } + }; + + if (showSpeedMenu) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [showSpeedMenu]); + + // 재생/일시정지 토글 + const handlePlayPause = useCallback(() => { + if (isPlaying) { + pause(); + } else { + play(); + } + }, [isPlaying, play, pause]); + + // 정지 + const handleStop = useCallback(() => { + stop(); + }, [stop]); + + // 배속 변경 + const handleSpeedChange = useCallback( + (speed) => { + setPlaybackSpeed(speed); + setShowSpeedMenu(false); + }, + [setPlaybackSpeed], + ); + + // 슬라이더로 시간 변경 + const handleSliderChange = useCallback( + (e) => { + const newTime = parseFloat(e.target.value); + setAnimationCurrentTime(newTime); + const ratio = (newTime - startTime) / (endTime - startTime); + setProgressByRatio(ratio); + }, + [setAnimationCurrentTime, startTime, endTime, setProgressByRatio], + ); + + // 반복 토글 + const handleLoopToggle = useCallback(() => { + toggleLoop(); + }, [toggleLoop]); + + // 구간 마커 드래그 시작 + const handleMarkerMouseDown = useCallback( + (marker) => (e) => { + if (isPlaying) return; + e.preventDefault(); + e.stopPropagation(); + setDraggingMarker(marker); + }, + [isPlaying], + ); + + // 구간 마커 드래그 중 + useEffect(() => { + if (!draggingMarker || !sliderContainerRef.current) return; + + const handleMouseMove = (e) => { + const rect = sliderContainerRef.current.getBoundingClientRect(); + const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const newTime = startTime + ratio * (endTime - startTime); + + if (draggingMarker === 'start') { + const maxStart = loopEnd - (endTime - startTime) * 0.01; + setLoopSection(Math.min(newTime, maxStart), loopEnd); + } else { + const minEnd = loopStart + (endTime - startTime) * 0.01; + setLoopSection(loopStart, Math.max(newTime, minEnd)); + } + }; + + const handleMouseUp = () => { + setDraggingMarker(null); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [draggingMarker, startTime, endTime, loopStart, loopEnd, setLoopSection]); + + // 데이터 유효성 확인 + const hasData = endTime > startTime && startTime > 0; + const progress = getProgress(); + const loopProgress = getLoopProgress(); + + // 시간 포맷팅 (날짜 + 시간) - YYYY-MM-DD HH:mm:ss + const formatTime = (timestamp) => { + if (!timestamp) return '---------- --:--:--'; + const date = new Date(timestamp); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; + }; + + return ( +
+
+ {/* 배속 선택 */} +
+ + {showSpeedMenu && ( +
+ {PLAYBACK_SPEED_OPTIONS.map(speed => ( + + ))} +
+ )} +
+ + {/* 재생/일시정지 버튼 */} + + + {/* 정지 버튼 */} + + + {/* 슬라이더 */} +
+ +
+ + {/* 구간반복 UI (반복 체크 시에만 표시) */} + {loop && hasData && ( + <> + {/* 구간반복 범위 하이라이트 */} +
+ + {/* 시작점 마커 (A) */} +
+ A +
+ + {/* 종료점 마커 (B) */} +
+ B +
+ + )} +
+ + {/* 현재 시간 */} + {hasData ? formatTime(animationCurrentTime) : '--:--:--'} + + {/* 반복 토글 */} + +
+
+ ); +}; + +export default TrackQueryTimeline; diff --git a/src/tracking/components/TrackQueryTimeline.scss b/src/tracking/components/TrackQueryTimeline.scss new file mode 100644 index 00000000..eec67cb4 --- /dev/null +++ b/src/tracking/components/TrackQueryTimeline.scss @@ -0,0 +1,408 @@ +/** + * 항적조회 타임라인 스타일 + * TrackQueryViewer와 통합되는 재생 컨트롤 + */ + +.track-query-timeline { + background: rgba(30, 35, 55, 0.95); + border-radius: 6px; + padding: 8px 12px; + margin-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.1); + + &.compact { + padding: 6px 8px; + + .control-btn { + width: 28px; + height: 28px; + font-size: 10px; + } + + .speed-btn { + font-size: 10px; + padding: 4px 8px; + } + + .current-time-display { + font-size: 10px; + } + } + + &.playing { + .play-btn { + animation: pulse-glow 1.5s infinite; + } + } +} + +.timeline-controls { + display: flex; + align-items: center; + gap: 8px; +} + +// 배속 선택기 +.speed-selector { + position: relative; + z-index: 100; + + .speed-btn { + background: rgba(79, 195, 247, 0.2); + border: 1px solid rgba(79, 195, 247, 0.4); + border-radius: 4px; + color: #4fc3f7; + font-size: 11px; + font-weight: 600; + padding: 5px 10px; + cursor: pointer; + transition: all 0.2s ease; + min-width: 50px; + + &:hover:not(:disabled) { + background: rgba(79, 195, 247, 0.3); + border-color: #4fc3f7; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + + .speed-menu { + position: absolute; + bottom: 100%; + left: 0; + margin-bottom: 4px; + background: rgba(40, 45, 70, 0.98); + border: 1px solid rgba(79, 195, 247, 0.4); + border-radius: 6px; + padding: 6px; + display: flex; + flex-wrap: wrap; + gap: 4px; + min-width: 180px; + box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.3); + z-index: 101; + + .speed-option { + flex: 0 0 calc(33.333% - 4px); + background: rgba(255, 255, 255, 0.1); + border: 1px solid transparent; + border-radius: 4px; + color: #fff; + font-size: 11px; + font-weight: 500; + padding: 6px 8px; + cursor: pointer; + transition: all 0.2s ease; + text-align: center; + + &:hover { + background: rgba(79, 195, 247, 0.3); + border-color: rgba(79, 195, 247, 0.5); + } + + &.active { + background: rgba(79, 195, 247, 0.5); + border-color: #4fc3f7; + color: #fff; + } + } + } +} + +// 컨트롤 버튼 +.control-btn { + width: 32px; + height: 32px; + border: none; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + transition: all 0.2s ease; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &.play-btn { + background: linear-gradient(135deg, #4caf50, #45a049); + color: #fff; + + &:hover:not(:disabled) { + transform: scale(1.1); + box-shadow: 0 0 12px rgba(76, 175, 80, 0.5); + } + + &.playing { + background: linear-gradient(135deg, #ffc107, #ffb300); + } + } + + &.stop-btn { + background: rgba(244, 67, 54, 0.8); + color: #fff; + + &:hover:not(:disabled) { + background: rgba(244, 67, 54, 1); + transform: scale(1.1); + } + } +} + +// 슬라이더 컨테이너 +.timeline-slider-container { + flex: 1; + position: relative; + height: 20px; + display: flex; + align-items: center; + min-width: 100px; + + .timeline-slider { + width: 100%; + height: 6px; + -webkit-appearance: none; + appearance: none; + background: rgba(255, 255, 255, 0.2); + border-radius: 3px; + cursor: pointer; + position: relative; + z-index: 2; + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + background: #fff; + border: 2px solid #4fc3f7; + border-radius: 50%; + cursor: grab; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + transition: transform 0.1s ease; + + &:hover { + transform: scale(1.2); + } + + &:active { + cursor: grabbing; + } + } + + &::-moz-range-thumb { + width: 14px; + height: 14px; + background: #fff; + border: 2px solid #4fc3f7; + border-radius: 50%; + cursor: grab; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + + &::-webkit-slider-thumb { + cursor: not-allowed; + } + } + } + + .slider-progress { + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + height: 6px; + background: linear-gradient(90deg, #4fc3f7, #29b6f6); + border-radius: 3px; + pointer-events: none; + z-index: 1; + transition: width 0.05s ease-out; + } + + // 구간반복 범위 하이라이트 + .loop-section-highlight { + position: absolute; + top: 50%; + transform: translateY(-50%); + height: 10px; + background: rgba(255, 193, 7, 0.25); + border-radius: 5px; + pointer-events: none; + z-index: 0; + border: 1px solid rgba(255, 193, 7, 0.5); + } + + // 구간반복 마커 (A, B) + .loop-marker { + position: absolute; + top: 50%; + width: 16px; + height: 22px; + transform: translate(-50%, -50%); + cursor: ew-resize; + z-index: 10; + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.1s ease; + + .marker-label { + display: flex; + align-items: center; + justify-content: center; + width: 14px; + height: 18px; + background: #ffc107; + color: #000; + font-size: 9px; + font-weight: 700; + border-radius: 2px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + user-select: none; + } + + &:hover:not(.disabled) { + transform: translate(-50%, -50%) scale(1.15); + + .marker-label { + background: #ffca28; + } + } + + &.dragging { + transform: translate(-50%, -50%) scale(1.2); + z-index: 11; + + .marker-label { + background: #ffb300; + box-shadow: 0 0 8px rgba(255, 193, 7, 0.6); + } + } + + &.disabled { + cursor: not-allowed; + opacity: 0.6; + } + + // 마커 꼬리 (삼각형) + &::after { + content: ''; + position: absolute; + bottom: -4px; + left: 50%; + transform: translateX(-50%); + width: 0; + height: 0; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 5px solid #ffc107; + } + + &.loop-marker-start { + .marker-label { + background: #4caf50; + color: #fff; + } + + &::after { + border-top-color: #4caf50; + } + + &:hover:not(.disabled) .marker-label { + background: #66bb6a; + } + + &.dragging .marker-label { + background: #43a047; + } + } + + &.loop-marker-end { + .marker-label { + background: #f44336; + color: #fff; + } + + &::after { + border-top-color: #f44336; + } + + &:hover:not(.disabled) .marker-label { + background: #ef5350; + } + + &.dragging .marker-label { + background: #e53935; + } + } + } +} + +// 현재 시간 표시 +.current-time-display { + font-family: 'Consolas', 'Monaco', monospace; + font-size: 11px; + font-weight: 500; + color: #4fc3f7; + min-width: 110px; + text-align: center; + white-space: nowrap; +} + +// 반복 토글 +.loop-toggle, +.trail-toggle { + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + font-size: 11px; + color: rgba(255, 255, 255, 0.8); + white-space: nowrap; + + input[type='checkbox'] { + width: 14px; + height: 14px; + cursor: pointer; + accent-color: #4fc3f7; + + &:disabled { + cursor: not-allowed; + } + } + + &:hover { + color: #fff; + } +} + +// 항적표시 토글 (액센트 색상) +.trail-toggle { + input[type='checkbox'] { + accent-color: #ff9800; + } +} + +// 재생 중 글로우 애니메이션 +@keyframes pulse-glow { + 0% { + box-shadow: 0 0 0 0 rgba(255, 193, 7, 0.4); + } + 70% { + box-shadow: 0 0 0 8px rgba(255, 193, 7, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(255, 193, 7, 0); + } +} diff --git a/src/tracking/components/TrackQueryViewer.jsx b/src/tracking/components/TrackQueryViewer.jsx new file mode 100644 index 00000000..9e294333 --- /dev/null +++ b/src/tracking/components/TrackQueryViewer.jsx @@ -0,0 +1,949 @@ +/** + * 항적조회 뷰어 컴포넌트 + * + * 선박 모달 항적조회에서 사용 + * - 프로그레스 바 기반 시간 이동 + * - 선종 색상 기반 항적 레이어 + * - 포인트 호버 정보 + * - 가상 선박 아이콘 + */ + +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useTrackQueryStore } from '../stores/trackQueryStore'; +import { useTrackQueryAnimationStore } from '../stores/trackQueryAnimationStore'; +import { + createPathLayers, + createPointsLayerOnly, + createDynamicTrackLayers, + createLiveConnectionLayer, + registerTrackQueryLayers, + unregisterTrackQueryLayers, + formatPointInfo, + LAYER_IDS, +} from '../utils/trackQueryLayerUtils'; +import { getShipKindName, getSignalSourceName } from '../types/trackQuery.types'; +import { TRACK_QUERY_MAX_DAYS, TRACK_QUERY_DEFAULT_DAYS } from '../../types/constants'; +import { showToast } from '../../components/common/Toast'; +import { useTrackHighlight } from '../hooks/useTrackHighlight'; +import { TrackQueryTimeline } from './TrackQueryTimeline'; +import { useEquipmentFilter } from '../hooks/useEquipmentFilter'; +import { shipBatchRenderer } from '../../map/ShipBatchRenderer'; +import { fromLonLat } from 'ol/proj'; + +import ShipTooltip from '../../components/ship/ShipTooltip'; +import './TrackQueryViewer.scss'; + +/** 일 단위를 밀리초로 변환 */ +const DAYS_TO_MS = 24 * 60 * 60 * 1000; + +/** datetime-local 입력용 포맷 */ +function toDateTimeLocal(date) { + const pad = (n) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +/** + * 유효한 선명인지 확인하고 포맷팅 + */ +function formatShipName(name, fallback = '선명 없음') { + if (!name) return fallback; + const trimmed = name.trim(); + if (!trimmed) return fallback; + if (trimmed === '-') return fallback; + if (/^\d+$/.test(trimmed)) return fallback; + return trimmed; +} + +/** + * 메모이제이션된 선박 목록 아이템 + */ +const TrackQueryShipItem = React.memo( + ({ + vesselId, + shipName, + targetId, + shipKindCode, + sigSrcCd, + isEnabled, + isActive, + isHighlighted, + speed, + onToggle, + onMouseEnter, + onMouseLeave, + onContextMenu, + }) => ( +
onToggle(vesselId)} + onMouseEnter={() => onMouseEnter(vesselId)} + onMouseLeave={onMouseLeave} + onContextMenu={e => onContextMenu(vesselId, e)} + title={isEnabled ? '좌클릭: 비활성화 | 우클릭: 위치로 이동' : '좌클릭: 활성화 | 우클릭: 위치로 이동'} + > + {formatShipName(shipName, targetId)} + {getShipKindName(shipKindCode)} + {getSignalSourceName(sigSrcCd)} + {speed !== null ? ( + {speed.toFixed(1)} kn + ) : ( + {isEnabled ? '범위 외' : 'OFF'} + )} +
+ ), + (prev, next) => + prev.isActive === next.isActive && + prev.isEnabled === next.isEnabled && + prev.isHighlighted === next.isHighlighted && + prev.speed === next.speed, +); + +/** + * 항적조회 뷰어 메인 컴포넌트 + * + * @param {Object} props + * @param {boolean} [props.compact] 컴팩트 모드 + * @param {Function} [props.onClose] 닫기 핸들러 + * @param {boolean} [props.modalMode] 선박 모달 모드 + * @param {boolean} [props.isIntegrated] 통합선박 여부 + * @param {Object} [props.timeRange] 시간 범위 { fromDate, toDate } + * @param {Function} [props.onTimeRangeChange] 시간 범위 변경 핸들러 + * @param {Function} [props.onQuery] 조회 버튼 클릭 핸들러 + * @param {boolean} [props.isQuerying] 조회 중 상태 + * @param {boolean} [props.showPlayback] 재생 컨트롤 표시 여부 + */ +export const TrackQueryViewer = ({ + compact = false, + onClose, + modalMode = false, + isIntegrated = false, + timeRange, + onTimeRangeChange, + onQuery, + isQuerying = false, + showPlayback = false, +}) => { + // 스토어 상태 + const tracks = useTrackQueryStore(state => state.tracks); + const currentTime = useTrackQueryStore(state => state.currentTime); + const dataStartTime = useTrackQueryStore(state => state.dataStartTime); + const dataEndTime = useTrackQueryStore(state => state.dataEndTime); + const storeError = useTrackQueryStore(state => state.error); + const showPoints = useTrackQueryStore(state => state.showPoints); + const showVirtualShip = useTrackQueryStore(state => state.showVirtualShip); + const hideLiveShips = useTrackQueryStore(state => state.hideLiveShips); + const showLabels = useTrackQueryStore(state => state.showLabels); + const disabledVesselIds = useTrackQueryStore(state => state.disabledVesselIds); + const setProgressByRatio = useTrackQueryStore(state => state.setProgressByRatio); + const setShowPoints = useTrackQueryStore(state => state.setShowPoints); + const setShowVirtualShip = useTrackQueryStore(state => state.setShowVirtualShip); + const setHideLiveShips = useTrackQueryStore(state => state.setHideLiveShips); + const setShowLabels = useTrackQueryStore(state => state.setShowLabels); + const toggleVesselEnabled = useTrackQueryStore(state => state.toggleVesselEnabled); + const getEnabledTracks = useTrackQueryStore(state => state.getEnabledTracks); + const getCurrentPositions = useTrackQueryStore(state => state.getCurrentPositions); + const getProgress = useTrackQueryStore(state => state.getProgress); + + // 포인트 호버 상태 (스토어에서 직접 구독) + const storeHoveredPoint = useTrackQueryStore(state => state.hoveredPoint); + const storeHoveredPointPosition = useTrackQueryStore(state => state.hoveredPointPosition); + + // 애니메이션 스토어 (구간반복 상태, 재생 상태) + const isPlaying = useTrackQueryAnimationStore(state => state.isPlaying); + const loop = useTrackQueryAnimationStore(state => state.loop); + const loopStart = useTrackQueryAnimationStore(state => state.loopStart); + const loopEnd = useTrackQueryAnimationStore(state => state.loopEnd); + + // 하이라이트 훅 (선박 목록 ↔ 지도 항적 양방향 연동) + const { highlightedVesselId, handleListItemHover, handlePathHover, isHighlighted, clearHighlight } = useTrackHighlight(); + + // 장비 필터 훅 (통합선박 modalMode 전용) + const { + equipments, + enabledEquipments, + toggleEquipment, + enableAll, + resetToDefault, + hasMultipleEquipments, + } = useEquipmentFilter(tracks); + + // 로컬 상태 + const [hoveredPoint, setHoveredPoint] = useState(null); + const [hoveredShip, setHoveredShip] = useState(null); + const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 }); + const [shipTooltipPosition, setShipTooltipPosition] = useState({ x: 0, y: 0 }); + const [isDragging, setIsDragging] = useState(false); + const [zoomLevel, setZoomLevel] = useState(undefined); + const [staticLayerVersion, setStaticLayerVersion] = useState(0); + const progressBarRef = useRef(null); + const panelRef = useRef(null); + const pathTriggerRef = useRef(0); + const pointsTriggerRef = useRef(0); + const dynamicTriggerRef = useRef(0); + const pathLayersRef = useRef([]); + const pointsLayerRef = useRef(null); + const liveConnectionLayerRef = useRef(null); + const liveConnectionTriggerRef = useRef(0); + + // 줌 레벨 변경 감지 (클러스터링용) - tracks 변경 시 재시도 + useEffect(() => { + const mapInstance = window.__mainMap__; + if (!mapInstance) return; + + const view = mapInstance.getView(); + if (!view) return; + + const initialZoom = view.getZoom(); + if (initialZoom !== undefined) { + setZoomLevel(Math.floor(initialZoom)); + } + + const handleZoomChange = () => { + const newZoom = view.getZoom(); + if (newZoom !== undefined) { + const flooredZoom = Math.floor(newZoom); + setZoomLevel(prev => (prev !== flooredZoom ? flooredZoom : prev)); + } + }; + + view.on('change:resolution', handleZoomChange); + + return () => { + view.un('change:resolution', handleZoomChange); + }; + }, [tracks.length]); + + // 컴포넌트 unmount 시 애니메이션 스토어 초기화 + useEffect(() => { + return () => { + useTrackQueryAnimationStore.getState().reset(); + }; + }, []); + + // 패널 드래그 상태 + const [isDraggingPanel, setIsDraggingPanel] = useState(false); + const [dragDelta, setDragDelta] = useState({ x: 0, y: 0 }); + const dragStartRef = useRef({ x: 0, y: 0, deltaX: 0, deltaY: 0 }); + + // 활성화된 항적만 가져오기 + const enabledTracks = useMemo(() => { + const baseEnabledTracks = getEnabledTracks(); + if (modalMode && isIntegrated && hasMultipleEquipments) { + return baseEnabledTracks.filter(track => enabledEquipments.has(track.sigSrcCd)); + } + return baseEnabledTracks; + }, [tracks, disabledVesselIds, getEnabledTracks, modalMode, isIntegrated, hasMultipleEquipments, enabledEquipments]); + + // 현재 위치 계산 (활성화된 선박만) + const currentPositions = useMemo(() => { + const allPositions = getCurrentPositions(); + if (modalMode && isIntegrated && hasMultipleEquipments) { + const enabledVesselIds = new Set(enabledTracks.map(t => t.vesselId)); + return allPositions.filter(pos => enabledVesselIds.has(pos.vesselId)); + } + return allPositions; + }, [currentTime, tracks, disabledVesselIds, getCurrentPositions, modalMode, isIntegrated, hasMultipleEquipments, enabledTracks]); + + // 현재 시간에 활성 상태인 선박 ID Set + const activeVesselIds = useMemo(() => { + const active = new Set(); + tracks.forEach(track => { + if (track.timestampsMs.length === 0) return; + const firstTime = track.timestampsMs[0]; + const lastTime = track.timestampsMs[track.timestampsMs.length - 1]; + if (currentTime >= firstTime && currentTime <= lastTime) { + active.add(track.vesselId); + } + }); + return active; + }, [tracks, currentTime]); + + // 현재 시간의 선박 속도 Map + const vesselSpeedMap = useMemo(() => { + const speedMap = new Map(); + currentPositions.forEach(pos => { + speedMap.set(pos.vesselId, pos.speed ?? null); + }); + return speedMap; + }, [currentPositions]); + + // 진행률 계산 + const progress = useMemo(() => { + return getProgress(); + }, [currentTime, dataStartTime, dataEndTime, getProgress]); + + // 라이브 선박 토글 핸들러 + const handleToggleLiveShips = useCallback(() => { + const newHideState = !hideLiveShips; + setHideLiveShips(newHideState); + shipBatchRenderer.immediateRender(); + }, [hideLiveShips, setHideLiveShips]); + + // 닫기 핸들러 + const handleClose = useCallback(() => { + useTrackQueryAnimationStore.getState().reset(); + setHideLiveShips(false); + if (onClose) { + onClose(); + } + }, [setHideLiveShips, onClose]); + + // 포인트 호버 핸들러 + const handlePointHover = useCallback((info, x, y) => { + setHoveredPoint(info); + setTooltipPosition({ x, y }); + }, []); + + // 가상 선박 아이콘 호버 핸들러 + const handleIconHover = useCallback((shipData, x, y) => { + if (shipData) { + // ShipTooltip 형식에 맞게 변환 + setHoveredShip({ + shipName: shipData.shipName, + targetId: shipData.vesselId?.split('_').pop() || shipData.vesselId, + signalKindCode: shipData.shipKindCode, + sog: shipData.speed || 0, + cog: shipData.heading || 0, + }); + setShipTooltipPosition({ x, y }); + // 하이라이트도 설정 + handlePathHover(shipData.vesselId); + } else { + setHoveredShip(null); + clearHighlight(); + } + }, [handlePathHover, clearHighlight]); + + // 선박 목록 우클릭 핸들러 (해당 위치로 지도 중심 이동) + const handleShipContextMenu = useCallback( + (vesselId, e) => { + e.preventDefault(); + + const mapInstance = window.__mainMap__; + if (!mapInstance) return; + + const track = tracks.find(t => t.vesselId === vesselId); + if (!track || !track.geometry || track.geometry.length === 0) return; + + let targetLon; + let targetLat; + + const currentPos = currentPositions.find(p => p.vesselId === vesselId); + if (currentPos) { + targetLon = currentPos.position[0]; + targetLat = currentPos.position[1]; + } else { + if (!track.timestampsMs || track.timestampsMs.length === 0) return; + const firstTime = track.timestampsMs[0]; + const lastTime = track.timestampsMs[track.timestampsMs.length - 1]; + + if (currentTime < firstTime) { + targetLon = track.geometry[0][0]; + targetLat = track.geometry[0][1]; + } else { + const lastIdx = track.geometry.length - 1; + targetLon = track.geometry[lastIdx][0]; + targetLat = track.geometry[lastIdx][1]; + } + } + + const view = mapInstance.getView(); + view.animate({ + center: fromLonLat([targetLon, targetLat]), + duration: 300, + }); + }, + [tracks, currentPositions, currentTime], + ); + + // 포인트 레이어 업데이트 (클러스터링) + useEffect(() => { + if (tracks.length === 0 || !showPoints) { + if (pointsLayerRef.current !== null) { + pointsLayerRef.current = null; + setStaticLayerVersion(v => v + 1); + } + return; + } + + pointsTriggerRef.current += 1; + + const pointsLayer = createPointsLayerOnly( + enabledTracks, + { + updateTrigger: pointsTriggerRef.current, + zoomLevel, + }, + handlePointHover, + ); + + pointsLayerRef.current = pointsLayer; + setStaticLayerVersion(v => v + 1); + }, [tracks, enabledTracks, showPoints, zoomLevel, handlePointHover]); + + // 경로 레이어 업데이트 + useEffect(() => { + if (tracks.length === 0) { + pathLayersRef.current = []; + return; + } + + pathTriggerRef.current += 1; + + const effectiveLoop = showPlayback && loop; + + const pathLayers = createPathLayers( + enabledTracks, + { + updateTrigger: pathTriggerRef.current, + highlightedVesselId, + loop: effectiveLoop, + loopStart: effectiveLoop ? loopStart : 0, + loopEnd: effectiveLoop ? loopEnd : 0, + }, + handlePathHover, + ); + + pathLayersRef.current = pathLayers; + setStaticLayerVersion(v => v + 1); + }, [tracks, enabledTracks, highlightedVesselId, loop, loopStart, loopEnd, showPlayback, handlePathHover]); + + // unmount 시에만 레이어 해제 + useEffect(() => { + return () => { + unregisterTrackQueryLayers(); + shipBatchRenderer.immediateRender(); + }; + }, []); + + // 라이브 연결선 업데이트 (항적 끝점 ↔ 라이브 선박 점선) + // showPlayback 모드에서는 표시하지 않음 + useEffect(() => { + if (tracks.length === 0 || showPlayback) { + liveConnectionLayerRef.current = null; + return; + } + + // 초기 생성 + liveConnectionTriggerRef.current += 1; + liveConnectionLayerRef.current = createLiveConnectionLayer( + enabledTracks, + liveConnectionTriggerRef.current, + ); + + // 1초 주기로 라이브 선박 위치 갱신 + const intervalId = setInterval(() => { + liveConnectionTriggerRef.current += 1; + const newLayer = createLiveConnectionLayer( + enabledTracks, + liveConnectionTriggerRef.current, + ); + liveConnectionLayerRef.current = newLayer; + + // __trackQueryLayers__에서 라이브 연결선 레이어만 교체 + const currentLayers = window.__trackQueryLayers__ || []; + const updatedLayers = currentLayers.filter( + layer => layer?.id !== LAYER_IDS.LIVE_CONNECTION + ); + if (newLayer) { + updatedLayers.push(newLayer); + } + registerTrackQueryLayers(updatedLayers); + shipBatchRenderer.immediateRender(); + }, 1000); + + return () => { + clearInterval(intervalId); + liveConnectionLayerRef.current = null; + }; + }, [tracks, enabledTracks, showPlayback]); + + // 동적 레이어 업데이트 (가상 선박 아이콘, 라벨) + useEffect(() => { + if (tracks.length === 0) { + unregisterTrackQueryLayers(); + shipBatchRenderer.immediateRender(); + return; + } + + dynamicTriggerRef.current += 1; + + const dynamicLayers = createDynamicTrackLayers( + currentPositions, + enabledTracks, + { + showVirtualShip, + showLabels, + updateTrigger: dynamicTriggerRef.current, + }, + handleIconHover, + ); + + const allLayers = [ + ...pathLayersRef.current, + ...(pointsLayerRef.current ? [pointsLayerRef.current] : []), + ...dynamicLayers, + ...(liveConnectionLayerRef.current ? [liveConnectionLayerRef.current] : []), + ]; + registerTrackQueryLayers(allLayers); + + // deck.gl 렌더링 트리거 + shipBatchRenderer.immediateRender(); + }, [tracks.length, enabledTracks, currentPositions, currentTime, isPlaying, showVirtualShip, showLabels, staticLayerVersion, handleIconHover]); + + // 프로그레스 바 드래그 핸들러 + const handleProgressMouseDown = useCallback((e) => { + setIsDragging(true); + updateProgressFromMouse(e); + }, []); + + const handleProgressMouseMove = useCallback( + (e) => { + if (isDragging) { + updateProgressFromMouse(e); + } + }, + [isDragging], + ); + + const handleProgressMouseUp = useCallback(() => { + setIsDragging(false); + }, []); + + const updateProgressFromMouse = useCallback( + (e) => { + if (!progressBarRef.current) return; + + const rect = progressBarRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left; + const ratio = Math.max(0, Math.min(1, x / rect.width)); + setProgressByRatio(ratio); + }, + [setProgressByRatio], + ); + + // 프로그레스 바 마우스 이벤트 리스너 + useEffect(() => { + if (isDragging) { + window.addEventListener('mousemove', handleProgressMouseMove); + window.addEventListener('mouseup', handleProgressMouseUp); + } + + return () => { + window.removeEventListener('mousemove', handleProgressMouseMove); + window.removeEventListener('mouseup', handleProgressMouseUp); + }; + }, [isDragging, handleProgressMouseMove, handleProgressMouseUp]); + + // 패널 드래그 핸들러 + const handlePanelDragStart = useCallback((e) => { + if (e.target.closest('button')) return; + + e.preventDefault(); + dragStartRef.current = { + x: e.clientX, + y: e.clientY, + deltaX: dragDelta.x, + deltaY: dragDelta.y, + }; + + setIsDraggingPanel(true); + }, [dragDelta]); + + // 패널 드래그 마우스 이벤트 리스너 + useEffect(() => { + if (!isDraggingPanel) return; + + const handleMouseMove = (e) => { + const newDeltaX = dragStartRef.current.deltaX + (e.clientX - dragStartRef.current.x); + const newDeltaY = dragStartRef.current.deltaY + (e.clientY - dragStartRef.current.y); + setDragDelta({ x: newDeltaX, y: newDeltaY }); + }; + + const handleMouseUp = () => { + setIsDraggingPanel(false); + }; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, [isDraggingPanel]); + + // 시간 포맷팅 + const formatTime = useCallback((timestamp) => { + if (!timestamp) return '--:--:--'; + const date = new Date(timestamp); + return date.toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + }, []); + + const formatDate = useCallback((timestamp) => { + if (!timestamp) return '----.--.--'; + const date = new Date(timestamp); + return date.toLocaleDateString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + }, []); + + // 조회 기간 검증 및 자동 조정 (blur 시 실행) + const validateAndAdjustTimeRange = useCallback((changedField) => { + if (!timeRange || !onTimeRangeChange) return; + + const fromDate = new Date(timeRange.fromDate); + const toDate = new Date(timeRange.toDate); + + // 유효하지 않은 날짜면 무시 + if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) return; + + const diffDays = (toDate - fromDate) / DAYS_TO_MS; + + if (changedField === 'from') { + // 시작일이 종료일보다 뒤인 경우 → 종료일을 시작일 + 기본조회기간으로 조정 + if (diffDays < 0) { + const adjustedTo = new Date(fromDate.getTime() + TRACK_QUERY_DEFAULT_DAYS * DAYS_TO_MS); + onTimeRangeChange({ ...timeRange, toDate: toDateTimeLocal(adjustedTo) }); + showToast(`종료일이 시작일보다 앞서 기본 조회기간 ${TRACK_QUERY_DEFAULT_DAYS}일로 자동 설정됩니다.`); + } + // 최대 조회기간 초과 시 종료일 자동 조정 + else if (diffDays > TRACK_QUERY_MAX_DAYS) { + const adjustedTo = new Date(fromDate.getTime() + TRACK_QUERY_MAX_DAYS * DAYS_TO_MS); + onTimeRangeChange({ ...timeRange, toDate: toDateTimeLocal(adjustedTo) }); + showToast(`최대 조회기간 ${TRACK_QUERY_MAX_DAYS}일로 자동 설정됩니다.`); + } + } else { + // 종료일이 시작일보다 앞인 경우 → 시작일을 종료일 - 기본조회기간으로 조정 + if (diffDays < 0) { + const adjustedFrom = new Date(toDate.getTime() - TRACK_QUERY_DEFAULT_DAYS * DAYS_TO_MS); + onTimeRangeChange({ ...timeRange, fromDate: toDateTimeLocal(adjustedFrom) }); + showToast(`시작일이 종료일보다 뒤서 기본 조회기간 ${TRACK_QUERY_DEFAULT_DAYS}일로 자동 설정됩니다.`); + } + // 최대 조회기간 초과 시 시작일 자동 조정 + else if (diffDays > TRACK_QUERY_MAX_DAYS) { + const adjustedFrom = new Date(toDate.getTime() - TRACK_QUERY_MAX_DAYS * DAYS_TO_MS); + onTimeRangeChange({ ...timeRange, fromDate: toDateTimeLocal(adjustedFrom) }); + showToast(`최대 조회기간 ${TRACK_QUERY_MAX_DAYS}일로 자동 설정됩니다.`); + } + } + }, [timeRange, onTimeRangeChange]); + + // 조회 버튼 클릭 핸들러 (검증 포함) + const handleQueryClick = useCallback(() => { + if (!timeRange || !onQuery) return; + + const fromDate = new Date(timeRange.fromDate); + const toDate = new Date(timeRange.toDate); + + // 유효성 검사 + if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) { + showToast('올바른 날짜/시간을 입력해주세요.'); + return; + } + if (fromDate >= toDate) { + showToast('종료 시간은 시작 시간보다 이후여야 합니다.'); + return; + } + + onQuery(); + }, [timeRange, onQuery]); + + // 데이터가 없으면 렌더링하지 않음 (모달 모드에서는 시간 입력 폼을 위해 허용) + if (tracks.length === 0 && !modalMode) { + return null; + } + + const hasTracks = tracks.length > 0; + + // 단일 선박이면 너비를 줄임 + const isSingleVessel = tracks.length === 1; + const singleVessel = isSingleVessel ? tracks[0] : null; + + // 패널 스타일 (드래그 위치 적용) + const panelStyle = + dragDelta.x !== 0 || dragDelta.y !== 0 + ? { + transform: `translateX(calc(-50% + ${dragDelta.x}px)) translateY(${dragDelta.y}px)`, + } + : {}; + + return ( +
+ {/* 헤더 (선명, TargetId, 버튼) - 드래그 핸들 */} +
+
+ {modalMode && hasTracks ? ( + <> + {formatShipName(tracks[0]?.shipName)} + {tracks[0]?.targetId} + {getShipKindName(tracks[0]?.shipKindCode)} + + ) : modalMode && !hasTracks ? ( + 항적조회 + ) : singleVessel ? ( + <> + {formatShipName(singleVessel.shipName)} + {singleVessel.targetId} + {getSignalSourceName(singleVessel.sigSrcCd)} + + ) : ( + 항적조회 ({tracks.length}척) + )} +
+
+ + {onClose && ( + + )} +
+
+ + {/* 프로그레스 바 섹션 (항적 데이터가 있을 때만) */} + {hasTracks && ( +
+ {/* 기존 프로그레스 바 (showPlayback이 false일 때만 표시) */} + {!showPlayback && ( + <> +
+ + {formatDate(dataStartTime)} {formatTime(dataStartTime)} + + + {formatDate(currentTime)} {formatTime(currentTime)} + + + {formatDate(dataEndTime)} {formatTime(dataEndTime)} + +
+ +
+
+
+
+
+
+ + )} + + {/* 옵션 토글 */} +
+ + + {tracks.length > 1 && ( + + )} +
+ + {/* 재생 컨트롤 (showPlayback이 true일 때만 표시) */} + {showPlayback && dataStartTime > 0 && dataEndTime > dataStartTime && ( + + )} +
+ )} + + {/* 선박 모달 모드: 시간 입력 폼 */} + {modalMode && timeRange && onTimeRangeChange && ( +
+
+ +
+ onTimeRangeChange({ ...timeRange, fromDate: e.target.value })} + onBlur={() => validateAndAdjustTimeRange('from')} + /> + ~ + onTimeRangeChange({ ...timeRange, toDate: e.target.value })} + onBlur={() => validateAndAdjustTimeRange('to')} + /> +
+ +
+
+ )} + + {/* 모달 모드: 로딩/에러 상태 */} + {modalMode && !hasTracks && (isQuerying || storeError) && ( +
+ {isQuerying && 항적 데이터 조회 중...} + {!isQuerying && storeError && {storeError}} +
+ )} + + {/* 통합선박 장비 필터 (modalMode + 다중 장비) */} + {modalMode && isIntegrated && hasMultipleEquipments && ( +
+ 장비별 항적 +
+ + +
+
+ {equipments.map(eq => ( + + ))} +
+
+ )} + + {/* 선박 목록 (우클릭 모드에서 2척 이상일 때만 표시) */} + {!modalMode && tracks.length > 1 && ( +
+ {tracks.map(track => ( + + ))} +
+ )} + + {/* 가상 선박 아이콘 호버 툴팁 */} + {hoveredShip && createPortal( + , + document.body + )} + + {/* 포인트 호버 툴팁 */} + {storeHoveredPoint && createPortal( +
+ {(() => { + const formatted = formatPointInfo(storeHoveredPoint); + return ( + <> +
+ 시간: + {formatted.time} +
+
+ 위치: + {formatted.position} +
+
+ 속도: + {formatted.speed} +
+ + ); + })()} +
, + document.body + )} +
+ ); +}; + +export default TrackQueryViewer; diff --git a/src/tracking/components/TrackQueryViewer.scss b/src/tracking/components/TrackQueryViewer.scss new file mode 100644 index 00000000..64de4672 --- /dev/null +++ b/src/tracking/components/TrackQueryViewer.scss @@ -0,0 +1,610 @@ +/** + * 항적조회 뷰어 스타일 + */ + +.track-query-viewer { + position: fixed; + bottom: 120px; + left: 50%; + transform: translateX(-50%); + z-index: 1000; + background: rgba(30, 35, 55, 0.95); + border-radius: 8px; + padding: 12px 16px; + width: 980px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + color: #fff; + + &.compact { + width: 600px; + padding: 8px 12px; + + .track-query-time-info { + font-size: 11px; + } + + .track-query-progress-bar { + height: 16px; + } + + .progress-handle { + width: 14px; + height: 14px; + } + } + + // 단일 선박 모드 (선박 모달에서 1척 조회 시) + &.single-vessel { + width: 480px; + } + + // 선박 모달 모드 (fixed 배치, 중앙 하단) + &.modal-mode { + position: fixed; + bottom: 120px; + left: 50%; + transform: translateX(-50%); + width: 520px; + z-index: 1000; + } + + // 드래그 중 + &.dragging { + user-select: none; + opacity: 0.95; + } +} + +// 헤더 (선명, TargetId, 버튼) +.track-query-header { + display: flex; + justify-content: space-between; + align-items: center; + margin: -12px -16px 8px; + padding: 8px 12px; + background: linear-gradient(135deg, rgba(79, 195, 247, 0.3), rgba(41, 182, 246, 0.2)); + border-bottom: 1px solid rgba(79, 195, 247, 0.3); + border-radius: 8px 8px 0 0; + + // 드래그 가능 상태 + &.draggable { + cursor: move; + cursor: grab; + + &:active { + cursor: grabbing; + } + } + + .header-info { + display: flex; + align-items: center; + gap: 10px; + + .vessel-name { + font-size: 14px; + font-weight: 600; + color: #fff; + } + + .vessel-id { + font-size: 12px; + color: rgba(255, 255, 255, 0.7); + padding: 2px 6px; + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; + } + + .vessel-signal { + font-size: 11px; + color: #4fc3f7; + padding: 2px 6px; + background: rgba(79, 195, 247, 0.2); + border-radius: 3px; + } + + .vessel-kind { + font-size: 11px; + color: #81c784; + padding: 2px 6px; + background: rgba(129, 199, 132, 0.2); + border-radius: 3px; + } + + .vessel-count { + font-size: 14px; + font-weight: 600; + color: #fff; + } + } + + .header-actions { + display: flex; + align-items: center; + gap: 8px; + + .live-ship-toggle { + padding: 4px 10px; + border: none; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + + &.on { + background: rgba(76, 175, 80, 0.8); + color: #fff; + + &:hover { + background: rgba(76, 175, 80, 1); + } + } + + &.off { + background: rgba(244, 67, 54, 0.8); + color: #fff; + + &:hover { + background: rgba(244, 67, 54, 1); + } + } + } + + .close-btn { + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.7); + font-size: 14px; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + transition: all 0.2s ease; + line-height: 1; + + &:hover { + background: rgba(244, 67, 54, 0.3); + color: #fff; + } + } + } +} + +.track-query-progress-section { + display: flex; + flex-direction: column; + gap: 8px; +} + +.track-query-time-info { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + color: rgba(255, 255, 255, 0.8); + + .start-time, + .end-time { + font-size: 11px; + color: rgba(255, 255, 255, 0.6); + } + + .current-time { + font-size: 14px; + font-weight: 600; + color: #4fc3f7; + } +} + +.track-query-progress-bar { + position: relative; + height: 20px; + cursor: pointer; + user-select: none; + + .progress-track { + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 6px; + transform: translateY(-50%); + background: rgba(255, 255, 255, 0.2); + border-radius: 3px; + overflow: visible; + } + + .progress-fill { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: linear-gradient(90deg, #4fc3f7, #29b6f6); + border-radius: 3px; + transition: width 0.05s ease-out; + } + + .progress-handle { + position: absolute; + top: 50%; + width: 16px; + height: 16px; + background: #fff; + border: 2px solid #4fc3f7; + border-radius: 50%; + transform: translate(-50%, -50%); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + cursor: grab; + transition: transform 0.1s ease; + + &:hover { + transform: translate(-50%, -50%) scale(1.2); + } + + &:active { + cursor: grabbing; + transform: translate(-50%, -50%) scale(1.1); + } + } +} + +.track-query-options { + display: flex; + gap: 12px; + justify-content: center; + margin-top: 4px; + + .option-toggle { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + font-size: 12px; + color: rgba(255, 255, 255, 0.5); + padding: 3px 8px; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.15); + background: transparent; + transition: all 0.2s ease; + user-select: none; + + input[type='checkbox'] { + display: none; + } + + // 체크된 상태 + &:has(input:checked) { + color: #fff; + background: rgba(79, 195, 247, 0.25); + border-color: rgba(79, 195, 247, 0.5); + } + + // 체크 안된 상태 + &:has(input:not(:checked)) { + color: rgba(255, 255, 255, 0.4); + border-color: rgba(255, 255, 255, 0.1); + } + + &:hover { + color: #fff; + border-color: rgba(79, 195, 247, 0.4); + } + } +} + +.track-query-ship-info { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.1); + justify-content: flex-start; + + .ship-info-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 4px 6px; + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; + font-size: 10px; + min-width: 80px; + max-width: 90px; + flex: 0 0 auto; + cursor: pointer; + transition: all 0.15s ease; + border: 2px solid transparent; + + &:hover { + background: rgba(255, 255, 255, 0.15); + } + + // 활성 상태 (현재 시간에 선박 아이콘이 표시됨) + &.active { + background: rgba(79, 195, 247, 0.25); + border-color: rgba(79, 195, 247, 0.6); + box-shadow: 0 0 8px rgba(79, 195, 247, 0.4); + + .ship-name { + color: #4fc3f7; + } + } + + // 비활성화 상태 (사용자가 OFF한 경우) + &.disabled { + opacity: 0.4; + background: rgba(100, 100, 100, 0.2); + + &:hover { + opacity: 0.6; + } + } + + // 하이라이트 상태 (호버 연동) + &.highlighted { + background: rgba(255, 255, 0, 0.3); + border-color: rgba(255, 255, 0, 0.8); + box-shadow: 0 0 12px rgba(255, 255, 0, 0.5); + transform: scale(1.05); + z-index: 1; + + .ship-name { + color: #ffeb3b; + } + } + + .ship-name { + font-weight: 600; + color: #fff; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: center; + } + + .ship-kind { + padding: 1px 4px; + background: rgba(79, 195, 247, 0.3); + border-radius: 3px; + color: #4fc3f7; + font-size: 9px; + } + + .ship-signal { + display: none; + } + + .ship-speed { + color: #81c784; + font-weight: 500; + font-size: 9px; + } + + .ship-status { + font-size: 8px; + color: rgba(255, 255, 255, 0.5); + margin-top: 2px; + } + } +} + +.track-query-tooltip { + position: fixed; + z-index: 1000; + background: rgba(30, 35, 60, 0.95); + border: 1px solid rgba(79, 195, 247, 0.5); + border-radius: 6px; + padding: 8px 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + pointer-events: none; + font-size: 12px; + min-width: 180px; + + .tooltip-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 2px 0; + + .label { + color: rgba(255, 255, 255, 0.6); + margin-right: 12px; + } + + .value { + color: #fff; + font-weight: 500; + } + } +} + +// 모달 모드: 로딩/에러 상태 +.track-query-status { + text-align: center; + padding: 16px 0; + + .status-loading { + font-size: 13px; + color: #4fc3f7; + } + + .status-error { + font-size: 13px; + color: #ef5350; + } +} + +// 선박 모달 모드: 시간 입력 폼 +.track-query-time-form { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.15); + + .time-form-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + + > label { + font-size: 11px; + color: rgba(255, 255, 255, 0.8); + white-space: nowrap; + } + + .time-inputs { + display: flex; + align-items: center; + gap: 4px; + flex: 1; + min-width: 0; + + input[type='datetime-local'] { + flex: 1; + min-width: 0; + padding: 2px 6px; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + background: rgba(255, 255, 255, 0.1); + color: #fff; + font-size: 11px; + line-height: 1.2; + height: 26px; + outline: none; + transition: border-color 0.2s ease; + + &:focus { + border-color: #4fc3f7; + } + + &::-webkit-calendar-picker-indicator { + filter: invert(1); + cursor: pointer; + } + } + + .time-separator { + color: rgba(255, 255, 255, 0.6); + font-size: 12px; + flex-shrink: 0; + } + } + + .query-btn { + padding: 2px 12px; + height: 26px; + border: none; + border-radius: 4px; + background: linear-gradient(135deg, #4fc3f7, #29b6f6); + color: #fff; + font-size: 11px; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; + transition: all 0.2s ease; + + &:hover:not(:disabled) { + background: linear-gradient(135deg, #29b6f6, #03a9f4); + transform: translateY(-1px); + } + + &:disabled { + background: rgba(128, 128, 128, 0.5); + cursor: not-allowed; + } + } + } +} + +// 장비별 항적 필터 (통합선박 modalMode 전용) - 한 줄 배치 +.track-query-equipment-filter { + display: flex; + align-items: center; + gap: 10px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid rgba(255, 255, 255, 0.15); + + .filter-title { + font-size: 11px; + font-weight: 500; + color: rgba(255, 255, 255, 0.7); + white-space: nowrap; + } + + .filter-actions { + display: flex; + gap: 4px; + + .filter-action-btn { + padding: 2px 6px; + border: 1px solid rgba(255, 255, 255, 0.25); + border-radius: 3px; + background: transparent; + color: rgba(255, 255, 255, 0.6); + font-size: 10px; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.4); + color: #fff; + } + } + } + + .equipment-filter-list { + display: flex; + gap: 4px; + + .equipment-toggle { + display: flex; + align-items: center; + justify-content: center; + padding: 0; + border: 2px solid transparent; + border-radius: 4px; + background: transparent; + cursor: pointer; + transition: all 0.15s ease; + + &.enabled { + .equipment-badge { + opacity: 1; + } + } + + &.disabled { + .equipment-badge { + opacity: 0.4; + } + } + + &:hover { + transform: scale(1.1); + + .equipment-badge { + opacity: 1; + } + } + + .equipment-badge { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + color: #fff; + transition: all 0.15s ease; + } + } + } +} diff --git a/src/tracking/components/TrackTimelineBar.scss b/src/tracking/components/TrackTimelineBar.scss new file mode 100644 index 00000000..186c3217 --- /dev/null +++ b/src/tracking/components/TrackTimelineBar.scss @@ -0,0 +1,168 @@ +/** + * TrackTimelineBar 스타일 + * + * 항적 조회용 타임라인 바 스타일 + */ + +.track-timeline-bar { + padding: 0.75rem 1rem; + background: rgba(48, 56, 95, 0.9); + border-radius: 8px; + user-select: none; + + // 비활성화 상태 + &.disabled { + opacity: 0.6; + pointer-events: none; + + .timeline-no-data { + text-align: center; + color: #a0a4b8; + font-size: 0.85rem; + padding: 0.5rem 0; + } + } + + // 드래그 중 + &.dragging { + .track-handle { + transform: translateX(-50%) scale(1.2); + + .handle-inner { + background: #5a9eff; + box-shadow: 0 0 12px rgba(90, 158, 255, 0.6); + } + } + } + + // 컴팩트 모드 + &.compact { + padding: 0.5rem 0.75rem; + + .timeline-labels { + margin-bottom: 0.375rem; + + .time-label { + font-size: 0.7rem; + + &.current { + font-size: 0.75rem; + } + } + } + + .timeline-track { + height: 8px; + } + + .track-handle { + width: 14px; + height: 14px; + top: -3px; + + .handle-inner { + width: 8px; + height: 8px; + } + } + } + + // 시간 라벨 영역 + .timeline-labels { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; + + .time-label { + font-family: 'Monaco', 'Consolas', monospace; + font-size: 0.75rem; + color: #a0a4b8; + + &.start, + &.end { + flex-shrink: 0; + } + + &.current { + font-size: 0.85rem; + font-weight: 600; + color: #ffffff; + } + } + } + + // 트랙 영역 + .timeline-track { + position: relative; + height: 12px; + cursor: pointer; + touch-action: none; + + &:hover { + .track-handle { + transform: translateX(-50%) scale(1.1); + } + } + } + + // 배경 트랙 + .track-background { + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 6px; + transform: translateY(-50%); + background: rgba(255, 255, 255, 0.2); + border-radius: 3px; + } + + // 진행 바 + .track-progress { + position: absolute; + top: 50%; + left: 0; + height: 6px; + transform: translateY(-50%); + background: linear-gradient(90deg, #0d6efd 0%, #5a9eff 100%); + border-radius: 3px; + transition: width 0.05s linear; + } + + // 핸들 + .track-handle { + position: absolute; + top: -2px; + width: 16px; + height: 16px; + transform: translateX(-50%); + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.1s ease; + z-index: 10; + + .handle-inner { + width: 10px; + height: 10px; + background: #0d6efd; + border-radius: 50%; + box-shadow: 0 2px 6px rgba(13, 110, 253, 0.4); + transition: all 0.15s ease; + } + } +} + +// 항적 아이콘 레이어 스타일 (deck.gl에서 사용) +.track-vessel-icon { + // 역사적 위치 아이콘 (투명도 적용) + &.historical { + opacity: 0.85; + } + + // 호버 시 강조 + &:hover { + filter: brightness(1.2); + } +} diff --git a/src/tracking/components/TrackingTimeline.scss b/src/tracking/components/TrackingTimeline.scss new file mode 100644 index 00000000..15c787ee --- /dev/null +++ b/src/tracking/components/TrackingTimeline.scss @@ -0,0 +1,359 @@ +/** + * 리플레이 타임라인 스타일 + * TrackQueryTimeline 디자인 참조 (다크 테마) + */ + +.replay-timeline { + background: rgba(30, 35, 55, 0.95); + border-radius: 6px; + overflow: visible; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + min-width: 400px; + + &.playing { + .play-btn { + animation: pulse-glow 1.5s infinite; + } + } + + &.dragging { + cursor: grabbing; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + opacity: 0.95; + } +} + +// 드래그 가능한 헤더 +.timeline-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: linear-gradient(135deg, rgba(79, 195, 247, 0.3), rgba(41, 182, 246, 0.2)); + border-bottom: 1px solid rgba(79, 195, 247, 0.3); + border-radius: 6px 6px 0 0; + cursor: grab; + user-select: none; + + &:active { + cursor: grabbing; + } + + .header-content { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + } + + .header-title { + font-size: 13px; + font-weight: 600; + color: #fff; + letter-spacing: 0.5px; + } + + .header-date-range { + font-family: 'Consolas', 'Monaco', monospace; + font-size: 11px; + font-weight: 500; + color: #4fc3f7; + background: rgba(0, 0, 0, 0.2); + padding: 2px 8px; + border-radius: 4px; + } + + .header-close-btn { + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.7); + font-size: 14px; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + transition: all 0.2s ease; + line-height: 1; + + &:hover { + background: rgba(244, 67, 54, 0.3); + color: #fff; + } + } +} + +.timeline-controls { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; +} + +// 배속 선택기 +.speed-selector { + position: relative; + z-index: 100; + + .speed-btn { + background: rgba(79, 195, 247, 0.2); + border: 1px solid rgba(79, 195, 247, 0.4); + border-radius: 4px; + color: #4fc3f7; + font-size: 11px; + font-weight: 600; + padding: 5px 10px; + cursor: pointer; + transition: all 0.2s ease; + min-width: 50px; + + &:hover:not(:disabled) { + background: rgba(79, 195, 247, 0.3); + border-color: #4fc3f7; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + + .speed-menu { + position: absolute; + bottom: 100%; + left: 0; + margin-bottom: 4px; + background: rgba(40, 45, 70, 0.98); + border: 1px solid rgba(79, 195, 247, 0.4); + border-radius: 6px; + padding: 6px; + display: flex; + flex-wrap: wrap; + gap: 4px; + min-width: 180px; + box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.3); + z-index: 101; + + .speed-option { + flex: 0 0 calc(33.333% - 4px); + background: rgba(255, 255, 255, 0.1); + border: 1px solid transparent; + border-radius: 4px; + color: #fff; + font-size: 11px; + font-weight: 500; + padding: 6px 8px; + cursor: pointer; + transition: all 0.2s ease; + text-align: center; + + &:hover { + background: rgba(79, 195, 247, 0.3); + border-color: rgba(79, 195, 247, 0.5); + } + + &.active { + background: rgba(79, 195, 247, 0.5); + border-color: #4fc3f7; + color: #fff; + } + } + } +} + +// 컨트롤 버튼 +.control-btn { + width: 32px; + height: 32px; + border: none; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + transition: all 0.2s ease; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &.play-btn { + background: linear-gradient(135deg, #4caf50, #45a049); + color: #fff; + + &:hover:not(:disabled) { + transform: scale(1.1); + box-shadow: 0 0 12px rgba(76, 175, 80, 0.5); + } + + &.playing { + background: linear-gradient(135deg, #ffc107, #ffb300); + } + } + + &.stop-btn { + background: rgba(244, 67, 54, 0.8); + color: #fff; + + &:hover:not(:disabled) { + background: rgba(244, 67, 54, 1); + transform: scale(1.1); + } + } +} + +// 슬라이더 컨테이너 +.timeline-slider-container { + flex: 1; + position: relative; + height: 20px; + display: flex; + align-items: center; + min-width: 100px; + + .timeline-slider { + width: 100%; + height: 6px; + -webkit-appearance: none; + appearance: none; + background: rgba(255, 255, 255, 0.2); + border-radius: 3px; + cursor: pointer; + position: relative; + z-index: 2; + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + background: #fff; + border: 2px solid #4fc3f7; + border-radius: 50%; + cursor: grab; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + transition: transform 0.1s ease; + + &:hover { + transform: scale(1.2); + } + + &:active { + cursor: grabbing; + } + } + + &::-moz-range-thumb { + width: 14px; + height: 14px; + background: #fff; + border: 2px solid #4fc3f7; + border-radius: 50%; + cursor: grab; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + + &::-webkit-slider-thumb { + cursor: not-allowed; + } + } + } + + .slider-progress { + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + height: 6px; + background: linear-gradient(90deg, #4fc3f7, #29b6f6); + border-radius: 3px; + pointer-events: none; + z-index: 1; + transition: width 0.05s ease-out; + } +} + +// 현재 시간 표시 +.current-time-display { + font-family: 'Consolas', 'Monaco', monospace; + font-size: 11px; + font-weight: 500; + color: #4fc3f7; + min-width: 130px; + text-align: center; + white-space: nowrap; +} + +// 토글 스타일 공통 +.loop-toggle, +.trail-toggle { + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + font-size: 11px; + color: rgba(255, 255, 255, 0.8); + white-space: nowrap; + + input[type='checkbox'] { + width: 14px; + height: 14px; + cursor: pointer; + accent-color: #4fc3f7; + + &:disabled { + cursor: not-allowed; + } + } + + &:hover { + color: #fff; + } +} + +// 항적표시 토글 (액센트 색상) +.trail-toggle { + input[type='checkbox'] { + accent-color: #ff9800; + } +} + +// 재생 중 글로우 애니메이션 +@keyframes pulse-glow { + 0% { + box-shadow: 0 0 0 0 rgba(255, 193, 7, 0.4); + } + 70% { + box-shadow: 0 0 0 8px rgba(255, 193, 7, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(255, 193, 7, 0); + } +} + +// 반응형 대응 +@media (max-width: 768px) { + .replay-timeline { + padding: 6px 8px; + + .control-btn { + width: 28px; + height: 28px; + font-size: 10px; + } + + .speed-btn { + font-size: 10px; + padding: 4px 8px; + } + + .current-time-display { + font-size: 10px; + min-width: 100px; + } + } +} diff --git a/src/tracking/hooks/useEquipmentFilter.js b/src/tracking/hooks/useEquipmentFilter.js new file mode 100644 index 00000000..f145eb85 --- /dev/null +++ b/src/tracking/hooks/useEquipmentFilter.js @@ -0,0 +1,140 @@ +/** + * 장비 필터 훅 (모달 항적조회 전용) + * + * 항적 데이터에서 장비(신호원천)별로 ON/OFF 토글 관리 + * - 기본값: 최우선순위 장비만 ON + * - 통합선박 전용 기능 + */ + +import { useState, useMemo, useCallback, useEffect } from 'react'; +import { + SIGNAL_SOURCE_CODE_AIS, + SIGNAL_SOURCE_CODE_D_MF_HF, + SIGNAL_SOURCE_CODE_ENAV, + SIGNAL_SOURCE_CODE_RADAR, + SIGNAL_SOURCE_CODE_VPASS, + SIGNAL_SOURCE_CODE_VTS_AIS, +} from '../../types/constants'; + +/** + * 신호 원천별 설정 + */ +const SIGNAL_CONFIGS = { + [SIGNAL_SOURCE_CODE_AIS]: { key: 'A', signalSourceCode: SIGNAL_SOURCE_CODE_AIS, name: 'AIS', shortName: 'AIS', background: '#C2A7DC', priority: 2, displayOrder: 1 }, + [SIGNAL_SOURCE_CODE_VPASS]: { key: 'V', signalSourceCode: SIGNAL_SOURCE_CODE_VPASS, name: 'V-Pass', shortName: 'V-P', background: '#8FAEFC', priority: 3, displayOrder: 2 }, + [SIGNAL_SOURCE_CODE_ENAV]: { key: 'E', signalSourceCode: SIGNAL_SOURCE_CODE_ENAV, name: 'E-Nav', shortName: 'E-N', background: '#74B2F0', priority: 4, displayOrder: 3 }, + [SIGNAL_SOURCE_CODE_VTS_AIS]: { key: 'T', signalSourceCode: SIGNAL_SOURCE_CODE_VTS_AIS, name: 'VTS-AIS', shortName: 'VTS', background: '#4190DF', priority: 1, displayOrder: 4 }, + [SIGNAL_SOURCE_CODE_D_MF_HF]: { key: 'D', signalSourceCode: SIGNAL_SOURCE_CODE_D_MF_HF, name: 'D-MF/HF', shortName: 'DMF', background: '#459EF6', priority: 5, displayOrder: 5 }, + [SIGNAL_SOURCE_CODE_RADAR]: { key: 'R', signalSourceCode: SIGNAL_SOURCE_CODE_RADAR, name: 'VTS-RT', shortName: 'RT', background: '#4577F6', priority: 6, displayOrder: 6 }, +}; + +/** + * 항적 데이터에서 사용된 장비 목록 추출 + */ +const extractEquipmentsFromTracks = (tracks) => { + const equipmentCounts = new Map(); + + tracks.forEach(track => { + const sigSrcCd = track.sigSrcCd; + if (sigSrcCd && SIGNAL_CONFIGS[sigSrcCd]) { + const pointCount = track.geometry?.length || track.timestampsMs?.length || 0; + equipmentCounts.set(sigSrcCd, (equipmentCounts.get(sigSrcCd) || 0) + pointCount); + } + }); + + return equipmentCounts; +}; + +/** + * 장비 필터 훅 + * + * @param {Array} tracks 항적 데이터 배열 + * @returns {Object} 장비 필터 관련 상태 및 함수 + */ +export const useEquipmentFilter = (tracks) => { + const [enabledSet, setEnabledSet] = useState(new Set()); + + const equipmentData = useMemo(() => { + return extractEquipmentsFromTracks(tracks); + }, [tracks]); + + const equipments = useMemo(() => { + const items = []; + + equipmentData.forEach((count, sigSrcCd) => { + const config = SIGNAL_CONFIGS[sigSrcCd]; + if (config) { + items.push({ + ...config, + isEnabled: enabledSet.has(sigSrcCd), + trackCount: count, + }); + } + }); + + return items.sort((a, b) => a.displayOrder - b.displayOrder); + }, [equipmentData, enabledSet]); + + const highestPriorityEquipment = useMemo(() => { + if (equipments.length === 0) return null; + return equipments.reduce((prev, curr) => (curr.priority < prev.priority ? curr : prev)); + }, [equipments]); + + // 트랙 데이터 변경 시 기본값 설정 + useEffect(() => { + if (equipmentData.size > 0 && enabledSet.size === 0) { + const sigSrcCodes = Array.from(equipmentData.keys()); + const sorted = sigSrcCodes.sort((a, b) => { + const priorityA = SIGNAL_CONFIGS[a]?.priority ?? 999; + const priorityB = SIGNAL_CONFIGS[b]?.priority ?? 999; + return priorityA - priorityB; + }); + if (sorted.length > 0) { + setEnabledSet(new Set([sorted[0]])); + } + } + }, [equipmentData]); + + const toggleEquipment = useCallback((signalSourceCode) => { + setEnabledSet(prev => { + const next = new Set(prev); + if (next.has(signalSourceCode)) { + if (next.size > 1) { + next.delete(signalSourceCode); + } + } else { + next.add(signalSourceCode); + } + return next; + }); + }, []); + + const enableAll = useCallback(() => { + setEnabledSet(new Set(equipmentData.keys())); + }, [equipmentData]); + + const resetToDefault = useCallback(() => { + if (highestPriorityEquipment) { + setEnabledSet(new Set([highestPriorityEquipment.signalSourceCode])); + } + }, [highestPriorityEquipment]); + + const hasMultipleEquipments = equipments.length > 1; + + const filteredTracks = useMemo(() => { + if (enabledSet.size === 0) return tracks; + return tracks.filter(track => enabledSet.has(track.sigSrcCd)); + }, [tracks, enabledSet]); + + return { + equipments, + enabledEquipments: enabledSet, + toggleEquipment, + enableAll, + resetToDefault, + hasMultipleEquipments, + filteredTracks, + }; +}; + +export default useEquipmentFilter; diff --git a/src/tracking/hooks/useTrackHighlight.js b/src/tracking/hooks/useTrackHighlight.js new file mode 100644 index 00000000..ea72f710 --- /dev/null +++ b/src/tracking/hooks/useTrackHighlight.js @@ -0,0 +1,48 @@ +/** + * 항적 하이라이트 공통 훅 + * + * 선박 목록 호버 <-> 지도 항적 하이라이트 양방향 연동 + */ +import { useCallback } from 'react'; +import { useTrackQueryStore } from '../stores/trackQueryStore'; + +/** + * 항적 하이라이트 관리 훅 + */ +export const useTrackHighlight = () => { + const highlightedVesselId = useTrackQueryStore(state => state.highlightedVesselId); + const setHighlightedVesselId = useTrackQueryStore(state => state.setHighlightedVesselId); + + const handleListItemHover = useCallback( + (vesselId) => { + setHighlightedVesselId(vesselId); + }, + [setHighlightedVesselId], + ); + + const handlePathHover = useCallback( + (vesselId) => { + setHighlightedVesselId(vesselId); + }, + [setHighlightedVesselId], + ); + + const isHighlighted = useCallback( + (vesselId) => { + return highlightedVesselId === vesselId; + }, + [highlightedVesselId], + ); + + const clearHighlight = useCallback(() => { + setHighlightedVesselId(null); + }, [setHighlightedVesselId]); + + return { + highlightedVesselId, + handleListItemHover, + handlePathHover, + isHighlighted, + clearHighlight, + }; +}; diff --git a/src/tracking/services/trackQueryApi.js b/src/tracking/services/trackQueryApi.js new file mode 100644 index 00000000..a4e76c7f --- /dev/null +++ b/src/tracking/services/trackQueryApi.js @@ -0,0 +1,392 @@ +/** + * 항적조회 v2 REST API 서비스 + * + * /api/v2/tracks/vessels 엔드포인트 사용 + * dark 프로젝트: axios 대신 fetch API 사용 + */ +import { SOURCE_PRIORITY_RANK, SOURCE_TO_ACTIVE_KEY } from '../../types/constants'; + +/** API 엔드포인트 */ +const API_ENDPOINT = '/api/v2/tracks/vessels'; + +/** + * 선박 항적 조회 (v2 API) + * + * @param {Object} params 조회 파라미터 + * @param {string} params.startTime ISO 8601 형식 시작 시간 + * @param {string} params.endTime ISO 8601 형식 종료 시간 + * @param {Array<{sigSrcCd: string, targetId: string}>} params.vessels 조회 대상 선박 목록 + * @param {string} [params.isIntegration] 통합선박 조회 시 "1" + * @returns {Promise} 항적 데이터 배열 + */ +export async function fetchVesselTracksV2(params) { + const request = { + startTime: params.startTime, + endTime: params.endTime, + vessels: params.vessels, + ...(params.isIntegration && { isIntegration: params.isIntegration }), + }; + + const response = await fetch(API_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(request), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + return Array.isArray(result) ? result : (result?.data || []); +} + +/** + * 단일 선박 항적 조회 헬퍼 + */ +export async function fetchSingleVesselTrackV2(sigSrcCd, targetId, startTime, endTime, isIntegration) { + const tracks = await fetchVesselTracksV2({ + startTime, + endTime, + vessels: [{ sigSrcCd, targetId }], + isIntegration, + }); + return tracks.length > 0 ? tracks[0] : null; +} + +/** + * 유효한 선명인지 확인 + */ +function isValidApiShipName(name) { + if (!name) return false; + const trimmed = name.trim(); + if (!trimmed) return false; + if (trimmed === '-') return false; + if (/^\d+$/.test(trimmed)) return false; + return true; +} + +/** + * API 응답 → 가공 데이터 변환 + * - timestamps를 밀리초로 변환 + * - 시간순 정렬 보장 + * + * @param {Array} tracks API 응답 항적 배열 + * @returns {Array} ProcessedTrackV2 배열 + */ +export function convertToProcessedTracks(tracks) { + return tracks.map(track => { + // timestamps를 밀리초로 변환 + const timestampsMs = track.timestamps.map(ts => { + const num = parseInt(ts, 10); + return num < 10000000000 ? num * 1000 : num; + }); + + // 시간순 정렬 인덱스 생성 + const sortedIndices = timestampsMs.map((_, i) => i).sort((a, b) => timestampsMs[a] - timestampsMs[b]); + + // 선명 처리 + const shipName = isValidApiShipName(track.shipName) ? track.shipName.trim() : ''; + + return { + vesselId: track.vesselId, + targetId: track.targetId, + sigSrcCd: track.sigSrcCd, + shipName, + shipKindCode: track.shipKindCode || '000027', + nationalCode: track.nationalCode || '', + integrationTargetId: track.integrationTargetId || undefined, + geometry: sortedIndices.map(i => track.geometry[i]), + timestampsMs: sortedIndices.map(i => timestampsMs[i]), + speeds: sortedIndices.map(i => track.speeds?.[i] || 0), + stats: { + totalDistance: track.totalDistance, + avgSpeed: track.avgSpeed, + maxSpeed: track.maxSpeed, + pointCount: track.pointCount, + }, + }; + }); +} + +/** + * 통합선박 TARGET_ID 파싱 + * 형식: AIS_VPASS_ENAV_VTSAIS_DMFHF + * + * @param {string} targetId 통합 TARGET_ID + * @returns {Array<{sigSrcCd: string, targetId: string}>|null} + */ +export function parseIntegratedTargetId(targetId) { + const parts = targetId.split('_'); + if (parts.length !== 5) return null; + + const [ais, vpass, enav, vtsAis, dmfhf] = parts; + const vessels = []; + + if (ais) vessels.push({ sigSrcCd: '000001', targetId: ais }); + if (vpass) vessels.push({ sigSrcCd: '000003', targetId: vpass }); + if (enav) vessels.push({ sigSrcCd: '000002', targetId: enav }); + if (vtsAis) vessels.push({ sigSrcCd: '000004', targetId: vtsAis }); + if (dmfhf) vessels.push({ sigSrcCd: '000016', targetId: dmfhf }); + + return vessels.length > 0 ? vessels : null; +} + +/** 통합선박 여부 확인 */ +export function isIntegratedVessel(signalSourceCode) { + return signalSourceCode === '999999'; +} + +/** 레이더 타겟 여부 확인 */ +export function isRadarTarget(signalSourceCode) { + return signalSourceCode === '000005'; +} + +/** + * 레이더 타겟 항적조회 가능 여부 확인 + * + * @param {string} targetId 레이더 타겟의 TARGET_ID + * @param {string} [isPriority] IS_PRIORITY 값 + * @returns {{ canQuery: boolean, vessel?: Object, errorMessage?: string }} + */ +export function checkRadarTargetForTrackQuery(targetId, isPriority) { + const isIntegrated = targetId.includes('_'); + + if (!isIntegrated) { + return { + canQuery: false, + errorMessage: '레이더 타겟은 항적 정보가 존재하지 않습니다.', + }; + } + + const parsedVessels = parseIntegratedTargetId(targetId); + + if (!parsedVessels || parsedVessels.length === 0) { + return { + canQuery: false, + errorMessage: '레이더 타겟은 항적 정보가 존재하지 않습니다.', + }; + } + + const priorityOrder = ['000004', '000001', '000003', '000002', '000016']; + + for (const sigSrcCd of priorityOrder) { + const vessel = parsedVessels.find(v => v.sigSrcCd === sigSrcCd); + if (vessel) { + return { canQuery: true, vessel }; + } + } + + return { canQuery: true, vessel: parsedVessels[0] }; +} + +/** + * 통합선박 전체 장비 조회용 선박 목록 반환 + * + * @param {string} targetId 통합선박 TARGET_ID + * @returns {Array<{sigSrcCd: string, targetId: string}>} + */ +export function getAllVesselsForIntegratedShip(targetId) { + if (!targetId.includes('_')) return []; + const vessels = parseIntegratedTargetId(targetId); + return vessels || []; +} + +/** + * 통합선박의 활성화된 장비만 조회용 선박 목록 반환 + * dark 프로젝트의 ship 객체 프로퍼티 기반 + * + * @param {Object} ship shipStore의 선박 데이터 + * @returns {Array<{sigSrcCd: string, targetId: string}>} + */ +export function getActiveVesselsForIntegratedShip(ship) { + const targetId = ship.targetId; + if (!targetId || !targetId.includes('_')) return []; + + const parts = targetId.split('_'); + if (parts.length !== 5) return []; + + const [ais, vpass, enav, vtsAis, dmfhf] = parts; + const vessels = []; + + if (ais && ship.ais === '1') vessels.push({ sigSrcCd: '000001', targetId: ais }); + if (vpass && ship.vpass === '1') vessels.push({ sigSrcCd: '000003', targetId: vpass }); + if (enav && ship.enav === '1') vessels.push({ sigSrcCd: '000002', targetId: enav }); + if (vtsAis && ship.vtsAis === '1') vessels.push({ sigSrcCd: '000004', targetId: vtsAis }); + if (dmfhf && ship.dMfHf === '1') vessels.push({ sigSrcCd: '000016', targetId: dmfhf }); + + return vessels; +} + +/** + * 통합선박 TARGET_ID 여부 확인 + */ +export function isIntegratedTargetId(targetId) { + return targetId && targetId.includes('_'); +} + +/** + * 통합선박에서 활성화된 장비 중 우선순위가 가장 높은 선박 반환 + * dark 프로젝트의 ship 객체 프로퍼티 기반 + * + * @param {Object} ship shipStore의 선박 데이터 + * @returns {{ sigSrcCd: string, targetId: string }|null} + */ +export function getHighestPriorityActiveVessel(ship) { + const targetId = ship.targetId; + if (!targetId || !targetId.includes('_')) return null; + + const parts = targetId.split('_'); + if (parts.length !== 5) return null; + + const [ais, vpass, enav, vtsAis, dmfhf] = parts; + + // 우선순위: VTS-AIS > AIS > V-Pass > E-Nav > D-MF/HF + // is_active 플래그는 '1'/'0' 문자열이므로 === '1'로 비교 ('0'도 truthy이므로) + if (vtsAis && ship.vtsAis === '1') return { sigSrcCd: '000004', targetId: vtsAis }; + if (ais && ship.ais === '1') return { sigSrcCd: '000001', targetId: ais }; + if (vpass && ship.vpass === '1') return { sigSrcCd: '000003', targetId: vpass }; + if (enav && ship.enav === '1') return { sigSrcCd: '000002', targetId: enav }; + if (dmfhf && ship.dMfHf === '1') return { sigSrcCd: '000016', targetId: dmfhf }; + + return null; +} + +/** + * 항적조회용 선박 목록 생성 (통합 함수) + * dark 프로젝트의 ship 객체 기반으로 변환 + * + * 통합선박ON (isIntegrate=true): + * 1. 레이더 타겟인가? + * - 레이더 + 미통합 → 제외 + * - 레이더 + 통합 + 통합선박(targetId에 '_') → is_active='1'인 장비 중 최고 우선순위 + * - 레이더 + 통합 + 단독선박(targetId에 '_' 없음) → 해당 단독선박 정보 직접 전달 + * 2. 비레이더 + 통합선박 → is_active='1'인 장비 중 최고 우선순위 + * 3. 비레이더 + 단독선박 → sigSrcCd + originalTargetId 직접 전달 + * + * 통합선박OFF (isIntegrate=false): + * - 레이더 타겟 → 제외 + * - 나머지 → sigSrcCd + originalTargetId 직접 전달 + * + * @param {Object} ship shipStore의 선박 데이터 + * @param {'modal'|'rightClick'} mode 조회 모드 + * @param {boolean} isIntegrate 통합선박 ON/OFF 상태 + * @param {Map} [features] shipStore.features (레이더+단독선박 통합 시 비레이더 탐색용) + * @returns {{ canQuery: boolean, vessels: Array, errorMessage?: string }} + */ +export function buildVesselListForQuery(ship, mode, isIntegrate, features) { + const sigSrcCd = ship.signalSourceCode || ''; + const targetId = ship.targetId || ''; + const isRadar = isRadarTarget(sigSrcCd); + + // ===== 통합선박 OFF ===== + if (!isIntegrate) { + // 레이더 타겟 → 항적 조회 불가 + if (isRadar) { + return { canQuery: false, vessels: [], errorMessage: '레이더 타겟은 항적 정보가 존재하지 않습니다.' }; + } + // 나머지 → sigSrcCd + originalTargetId 직접 전달 + const singleTargetId = ship.originalTargetId || targetId; + return { canQuery: true, vessels: [{ sigSrcCd, targetId: singleTargetId }] }; + } + + // ===== 통합선박 ON ===== + + // 1. 레이더 타겟 처리 + if (isRadar) { + // 1-1. 미통합 레이더 → 제외 + if (!ship.integrate) { + return { canQuery: false, vessels: [], errorMessage: '레이더 타겟은 항적 정보가 존재하지 않습니다.' }; + } + // 1-2. 통합된 레이더 + 통합선박 (targetId에 '_' 있음) + if (targetId.includes('_')) { + const activeVessel = getHighestPriorityActiveVessel(ship); + if (activeVessel) return { canQuery: true, vessels: [activeVessel] }; + // fallback: 활성 장비 없으면 targetId 파싱해서 첫 번째라도 전달 + const parsed = parseIntegratedTargetId(targetId); + if (parsed && parsed.length > 0) return { canQuery: true, vessels: [parsed[0]] }; + return { canQuery: false, vessels: [], errorMessage: '활성화된 장비가 없습니다.' }; + } + // 1-3. 통합된 레이더 + 단독선박 (targetId에 '_' 없음) + // 레이더와 통합된 단독선박 → 같은 targetId를 공유하므로, 해당 선박의 비레이더 feature 탐색 + return resolveStandaloneForRadar(ship, features); + } + + // 2. 비레이더 + 통합선박 (targetId에 '_' 있음) + if (targetId.includes('_')) { + if (mode === 'modal') { + // 모달: 전체 장비 목록 전달 + const allVessels = getAllVesselsForIntegratedShip(targetId); + if (allVessels.length > 0) return { canQuery: true, vessels: allVessels }; + } + // rightClick: is_active='1'인 장비 중 최고 우선순위 + const activeVessel = getHighestPriorityActiveVessel(ship); + if (activeVessel) return { canQuery: true, vessels: [activeVessel] }; + const fallbackTargetId = ship.originalTargetId || targetId; + return { canQuery: true, vessels: [{ sigSrcCd, targetId: fallbackTargetId }] }; + } + + // 3. 비레이더 + 단독선박 + const singleTargetId = ship.originalTargetId || targetId; + return { canQuery: true, vessels: [{ sigSrcCd, targetId: singleTargetId }] }; +} + +/** + * 레이더 + 단독선박 통합 시 비레이더 feature를 탐색하여 요청 파라미터 생성 + * shipStore.features에서 같은 targetId의 비레이더 신호원을 찾아 우선순위 기반 선택 + * + * @param {Object} ship 레이더 타겟 ship 객체 + * @param {Map} features shipStore.features + * @returns {{ canQuery: boolean, vessels: Array, errorMessage?: string }} + */ +function resolveStandaloneForRadar(ship, features) { + if (!features) { + return { canQuery: false, vessels: [], errorMessage: '통합된 단독선박 정보를 찾을 수 없습니다.' }; + } + const targetId = ship.targetId; + let bestVessel = null; + let bestRank = 99; + + features.forEach((f) => { + if (f.targetId !== targetId) return; + if (isRadarTarget(f.signalSourceCode)) return; + const rank = SOURCE_PRIORITY_RANK[f.signalSourceCode] ?? 99; + const activeKey = SOURCE_TO_ACTIVE_KEY[f.signalSourceCode]; + // is_active 확인 + if (activeKey && f[activeKey] === '1' && rank < bestRank) { + bestRank = rank; + bestVessel = { sigSrcCd: f.signalSourceCode, targetId: f.originalTargetId || f.targetId }; + } + }); + + if (bestVessel) return { canQuery: true, vessels: [bestVessel] }; + + // 활성 장비 없으면 비레이더 feature 중 아무거나 + features.forEach((f) => { + if (bestVessel) return; + if (f.targetId !== targetId) return; + if (isRadarTarget(f.signalSourceCode)) return; + bestVessel = { sigSrcCd: f.signalSourceCode, targetId: f.originalTargetId || f.targetId }; + }); + + if (bestVessel) return { canQuery: true, vessels: [bestVessel] }; + return { canQuery: false, vessels: [], errorMessage: '통합된 단독선박 정보를 찾을 수 없습니다.' }; +} + +/** + * 항적조회 요청 파라미터 중복 제거 + * (sigSrcCd, targetId) 조합이 동일한 항목 제거 + * + * @param {Array<{sigSrcCd: string, targetId: string}>} vessels + * @returns {Array<{sigSrcCd: string, targetId: string}>} + */ +export function deduplicateVessels(vessels) { + const seen = new Set(); + return vessels.filter(v => { + const key = `${v.sigSrcCd}_${v.targetId}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} diff --git a/src/tracking/stores/trackQueryAnimationStore.js b/src/tracking/stores/trackQueryAnimationStore.js new file mode 100644 index 00000000..79e19142 --- /dev/null +++ b/src/tracking/stores/trackQueryAnimationStore.js @@ -0,0 +1,165 @@ +/** + * 항적조회 전용 애니메이션 스토어 + * + * - 재생/일시정지/정지 + * - 배속 조절 (1x ~ 1000x) + * - 반복 재생 + * - requestAnimationFrame 기반 애니메이션 + */ +import { create } from 'zustand'; + +// 애니메이션 프레임 관리용 변수 (스토어 외부) +let animationFrameId = null; +let lastFrameTime = null; + +export const useTrackQueryAnimationStore = create((set, get) => { + const animate = () => { + const state = get(); + if (!state.isPlaying) return; + + const now = performance.now(); + if (lastFrameTime === null) { + lastFrameTime = now; + } + + const delta = now - lastFrameTime; + lastFrameTime = now; + + const newTime = state.currentTime + delta * state.playbackSpeed; + + const effectiveEnd = state.loop ? state.loopEnd : state.endTime; + const effectiveStart = state.loop ? state.loopStart : state.startTime; + + if (newTime >= effectiveEnd) { + if (state.loop) { + set({ currentTime: effectiveStart }); + } else { + set({ currentTime: state.endTime, isPlaying: false }); + animationFrameId = null; + lastFrameTime = null; + return; + } + } else { + set({ currentTime: newTime }); + } + + animationFrameId = requestAnimationFrame(animate); + }; + + return { + isPlaying: false, + currentTime: 0, + startTime: 0, + endTime: 0, + playbackSpeed: 1, + loop: false, + loopStart: 0, + loopEnd: 0, + + play: () => { + const state = get(); + if (state.endTime <= state.startTime) return; + + lastFrameTime = null; + + if (state.loop) { + if (state.currentTime < state.loopStart || state.currentTime >= state.loopEnd) { + set({ isPlaying: true, currentTime: state.loopStart }); + } else { + set({ isPlaying: true }); + } + } else { + set({ isPlaying: true }); + } + + animationFrameId = requestAnimationFrame(animate); + }, + + pause: () => { + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + } + lastFrameTime = null; + set({ isPlaying: false }); + }, + + stop: () => { + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + } + lastFrameTime = null; + set({ isPlaying: false, currentTime: get().startTime }); + }, + + setCurrentTime: (time) => { + const { startTime, endTime } = get(); + const clampedTime = Math.max(startTime, Math.min(endTime, time)); + set({ currentTime: clampedTime }); + }, + + setPlaybackSpeed: (speed) => set({ playbackSpeed: speed }), + + toggleLoop: () => set({ loop: !get().loop }), + + setLoopSection: (start, end) => { + const { startTime, endTime } = get(); + const clampedStart = Math.max(startTime, Math.min(end, start)); + const clampedEnd = Math.max(start, Math.min(endTime, end)); + set({ loopStart: clampedStart, loopEnd: clampedEnd }); + }, + + resetLoopSection: () => { + const { startTime, endTime } = get(); + set({ loopStart: startTime, loopEnd: endTime }); + }, + + setTimeRange: (start, end) => { + set({ + startTime: start, + endTime: end, + currentTime: start, + loopStart: start, + loopEnd: end, + }); + }, + + getProgress: () => { + const { currentTime, startTime, endTime } = get(); + if (endTime <= startTime) return 0; + return ((currentTime - startTime) / (endTime - startTime)) * 100; + }, + + getLoopProgress: () => { + const { startTime, endTime, loopStart, loopEnd } = get(); + if (endTime <= startTime) return { start: 0, end: 100 }; + const totalDuration = endTime - startTime; + return { + start: ((loopStart - startTime) / totalDuration) * 100, + end: ((loopEnd - startTime) / totalDuration) * 100, + }; + }, + + reset: () => { + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + } + lastFrameTime = null; + set({ + isPlaying: false, + currentTime: 0, + startTime: 0, + endTime: 0, + playbackSpeed: 1, + loop: false, + loopStart: 0, + loopEnd: 0, + }); + }, + }; +}); + +/** 재생 가능한 배속 옵션 */ +export const PLAYBACK_SPEED_OPTIONS = [1, 5, 10, 50, 100, 1000]; diff --git a/src/tracking/stores/trackQueryStore.js b/src/tracking/stores/trackQueryStore.js new file mode 100644 index 00000000..19d56b1b --- /dev/null +++ b/src/tracking/stores/trackQueryStore.js @@ -0,0 +1,359 @@ +/** + * 항적조회 전용 상태 관리 스토어 + * + * 선박 모달 항적조회에서 사용 + * 프로그레스 바 기반 시간 이동 및 선박 위치 계산 + */ + +import { create } from 'zustand'; +import { subscribeWithSelector } from 'zustand/middleware'; +import useShipStore from '../../stores/shipStore'; + +/** + * 유효한 선명인지 확인 + */ +function isValidShipName(name) { + if (!name) return false; + const trimmed = name.trim(); + if (!trimmed) return false; + if (trimmed === '-') return false; + if (trimmed === 'No Exist Name') return false; + if (/^\d+$/.test(trimmed)) return false; + return true; +} + +/** + * 라이브 선박 데이터 가져오기 + * dark 프로젝트의 shipStore.features 기반 + */ +function getLiveShipInfo(sigSrcCd, targetId) { + const { features } = useShipStore.getState(); + const featureKey = `${sigSrcCd}${targetId}`; + const liveShip = features.get(featureKey); + + const result = { shipName: null, shipKindCode: null, integrationTargetId: null }; + + if (liveShip) { + if (isValidShipName(liveShip.shipName)) { + result.shipName = liveShip.shipName.trim(); + } + if (liveShip.signalKindCode && liveShip.signalKindCode.trim()) { + result.shipKindCode = liveShip.signalKindCode.trim(); + } + if (liveShip.targetId && liveShip.targetId.includes('_')) { + result.integrationTargetId = liveShip.targetId; + } + } + + return result; +} + +/** + * 항적 데이터에 라이브 선박 정보 병합 + */ +function mergeWithLiveData(tracks) { + return tracks.map(track => { + const liveInfo = getLiveShipInfo(track.sigSrcCd, track.targetId); + let updated = { ...track }; + let hasChanges = false; + + if (liveInfo.shipName) { + if (track.shipName !== liveInfo.shipName) { + updated.shipName = liveInfo.shipName; + hasChanges = true; + } + } else if (!isValidShipName(track.shipName)) { + if (track.shipName !== '') { + updated.shipName = ''; + hasChanges = true; + } + } + + if (liveInfo.shipKindCode && track.shipKindCode !== liveInfo.shipKindCode) { + updated.shipKindCode = liveInfo.shipKindCode; + hasChanges = true; + } + + if (liveInfo.integrationTargetId) { + if (!track.integrationTargetId || track.integrationTargetId !== liveInfo.integrationTargetId) { + updated.integrationTargetId = liveInfo.integrationTargetId; + hasChanges = true; + } + } + + return hasChanges ? updated : track; + }); +} + +/** + * 두 지점 사이의 선박 위치를 시간 기반으로 보간 + */ +function interpolatePosition(p1, p2, t1, t2, currentTime) { + if (t1 === t2) return p1; + if (currentTime <= t1) return p1; + if (currentTime >= t2) return p2; + + const ratio = (currentTime - t1) / (t2 - t1); + const lon = p1[0] + (p2[0] - p1[0]) * ratio; + const lat = p1[1] + (p2[1] - p1[1]) * ratio; + + return [lon, lat]; +} + +/** + * 두 지점 간의 방향(heading) 계산 + */ +function calculateHeading(p1, p2) { + const [lon1, lat1] = p1; + const [lon2, lat2] = p2; + + const dx = lon2 - lon1; + const dy = lat2 - lat1; + + let angle = (Math.atan2(dx, dy) * 180) / Math.PI; + angle = -angle; + if (angle < 0) angle += 360; + + return angle; +} + +/** + * 항적조회 스토어 + */ +export const useTrackQueryStore = create(subscribeWithSelector((set, get) => ({ + tracks: [], + disabledVesselIds: new Set(), + dataStartTime: 0, + dataEndTime: 0, + requestedStartTime: 0, + currentTime: 0, + isLoading: false, + error: null, + showPoints: false, + showVirtualShip: true, + hideLiveShips: false, + showLabels: true, + isModalMode: false, + modalSourceId: null, + highlightedVesselId: null, + showPlayback: false, + + // 포인트 호버 정보 + hoveredPoint: null, + hoveredPointPosition: { x: 0, y: 0 }, + + /** + * 항적 데이터 설정 + * 라이브 선박 데이터와 병합하여 선명 정보 보완 + */ + setTracks: (tracks, requestedStartTime, showPlayback = false) => { + if (tracks.length === 0) { + set({ + tracks: [], + dataStartTime: 0, + dataEndTime: 0, + requestedStartTime: 0, + currentTime: 0, + error: null, + showPlayback: false, + }); + return; + } + + const mergedTracks = mergeWithLiveData(tracks); + + let minTime = Infinity; + let maxTime = -Infinity; + + mergedTracks.forEach(track => { + if (track.timestampsMs.length > 0) { + minTime = Math.min(minTime, track.timestampsMs[0]); + maxTime = Math.max(maxTime, track.timestampsMs[track.timestampsMs.length - 1]); + } + }); + + const initialTime = + requestedStartTime >= minTime && requestedStartTime <= maxTime ? requestedStartTime : minTime; + + set({ + tracks: mergedTracks, + dataStartTime: minTime, + dataEndTime: maxTime, + requestedStartTime, + currentTime: initialTime, + error: null, + showPlayback, + }); + }, + + setCurrentTime: (time) => { + const { dataStartTime, dataEndTime } = get(); + const clampedTime = Math.max(dataStartTime, Math.min(dataEndTime, time)); + set({ currentTime: clampedTime }); + }, + + setProgressByRatio: (ratio) => { + const { dataStartTime, dataEndTime } = get(); + const clampedRatio = Math.max(0, Math.min(1, ratio)); + const newTime = dataStartTime + (dataEndTime - dataStartTime) * clampedRatio; + set({ currentTime: newTime }); + }, + + setLoading: (loading) => set({ isLoading: loading }), + setError: (error) => set({ error }), + setShowPoints: (show) => set({ showPoints: show }), + setShowVirtualShip: (show) => set({ showVirtualShip: show }), + setHideLiveShips: (hide) => set({ hideLiveShips: hide }), + setShowLabels: (show) => set({ showLabels: show }), + + setModalMode: (isModal, sourceId) => { + set({ + isModalMode: isModal, + modalSourceId: isModal ? (sourceId || null) : null, + }); + }, + + getModalSourceId: () => get().modalSourceId, + + setHighlightedVesselId: (vesselId) => set({ highlightedVesselId: vesselId }), + + setHoveredPoint: (point, x, y) => set({ + hoveredPoint: point, + hoveredPointPosition: { x: x || 0, y: y || 0 }, + }), + + clearHoveredPoint: () => set({ + hoveredPoint: null, + hoveredPointPosition: { x: 0, y: 0 }, + }), + + toggleVesselEnabled: (vesselId) => { + const { disabledVesselIds } = get(); + const newDisabled = new Set(disabledVesselIds); + if (newDisabled.has(vesselId)) { + newDisabled.delete(vesselId); + } else { + newDisabled.add(vesselId); + } + set({ disabledVesselIds: newDisabled }); + }, + + isVesselEnabled: (vesselId) => !get().disabledVesselIds.has(vesselId), + + getEnabledTracks: () => { + const { tracks, disabledVesselIds } = get(); + return tracks.filter(track => !disabledVesselIds.has(track.vesselId)); + }, + + getProgress: () => { + const { currentTime, dataStartTime, dataEndTime } = get(); + if (dataEndTime === dataStartTime) return 0; + return ((currentTime - dataStartTime) / (dataEndTime - dataStartTime)) * 100; + }, + + /** + * 현재 시간의 모든 선박 위치 계산 + * 이진 탐색 + 선형 보간 + */ + getCurrentPositions: () => { + const { tracks, currentTime, disabledVesselIds } = get(); + const positions = []; + + tracks.forEach(track => { + if (disabledVesselIds.has(track.vesselId)) return; + const { timestampsMs, geometry, speeds, vesselId, targetId, sigSrcCd, shipName, shipKindCode, nationalCode } = track; + if (timestampsMs.length === 0) return; + + const firstTime = timestampsMs[0]; + const lastTime = timestampsMs[timestampsMs.length - 1]; + + if (currentTime < firstTime || currentTime > lastTime) return; + + // 이진 탐색 + let left = 0; + let right = timestampsMs.length - 1; + + while (left < right) { + const mid = Math.floor((left + right) / 2); + if (timestampsMs[mid] < currentTime) { + left = mid + 1; + } else { + right = mid; + } + } + + const idx1 = Math.max(0, left - 1); + const idx2 = Math.min(timestampsMs.length - 1, left); + + let position; + let heading; + let speed; + + if (idx1 === idx2 || timestampsMs[idx1] === timestampsMs[idx2]) { + position = geometry[idx1]; + speed = speeds[idx1] || 0; + + if (idx2 < geometry.length - 1) { + heading = calculateHeading(geometry[idx1], geometry[idx2 + 1]); + } else if (idx1 > 0) { + heading = calculateHeading(geometry[idx1 - 1], geometry[idx1]); + } else { + heading = 0; + } + } else { + position = interpolatePosition(geometry[idx1], geometry[idx2], timestampsMs[idx1], timestampsMs[idx2], currentTime); + heading = calculateHeading(geometry[idx1], geometry[idx2]); + + const ratio = (currentTime - timestampsMs[idx1]) / (timestampsMs[idx2] - timestampsMs[idx1]); + speed = (speeds[idx1] || 0) + ((speeds[idx2] || 0) - (speeds[idx1] || 0)) * ratio; + } + + positions.push({ + vesselId, + targetId, + sigSrcCd, + shipName, + shipKindCode, + nationalCode, + position, + heading, + speed, + timestamp: currentTime, + }); + }); + + return positions; + }, + + reset: () => { + set({ + tracks: [], + disabledVesselIds: new Set(), + dataStartTime: 0, + dataEndTime: 0, + requestedStartTime: 0, + currentTime: 0, + isLoading: false, + error: null, + showPoints: false, + showVirtualShip: true, + hideLiveShips: false, + showLabels: true, + isModalMode: false, + modalSourceId: null, + highlightedVesselId: null, + showPlayback: false, + hoveredPoint: null, + hoveredPointPosition: { x: 0, y: 0 }, + }); + }, +}))); + +// 편의 셀렉터 +export const useTrackQueryTracks = () => useTrackQueryStore(state => state.tracks); +export const useTrackQueryCurrentTime = () => useTrackQueryStore(state => state.currentTime); +export const useTrackQueryIsLoading = () => useTrackQueryStore(state => state.isLoading); +export const useTrackQueryShowPoints = () => useTrackQueryStore(state => state.showPoints); +export const useTrackQueryShowVirtualShip = () => useTrackQueryStore(state => state.showVirtualShip); + +export default useTrackQueryStore; diff --git a/src/tracking/types/trackQuery.types.js b/src/tracking/types/trackQuery.types.js new file mode 100644 index 00000000..6c35ff35 --- /dev/null +++ b/src/tracking/types/trackQuery.types.js @@ -0,0 +1,60 @@ +/** + * 항적조회 v2 API 전용 타입 정의 (JavaScript 변환) + * + * /api/v2/tracks/vessels REST API 기반 + * WebSocket 리플레이와 분리된 단순 항적 조회용 + * + * TypeScript 타입은 JSDoc 주석으로 대체 + */ + +// ========== 선종 색상 매핑 ========== + +/** 선종별 색상 정의 (RGBA, 60% 투명도) */ +export const SHIP_KIND_COLORS = { + '000020': [25, 116, 25, 150], // 어선 - 녹색 + '000021': [0, 41, 255, 150], // 함정 - 파란색 + '000022': [176, 42, 42, 150], // 여객선 - 빨간색 + '000023': [255, 139, 54, 150], // 화물선 - 주황색 + '000024': [255, 0, 0, 150], // 유조선 - 빨간색 + '000025': [92, 30, 224, 150], // 관공선 - 보라색 + '000027': [255, 135, 207, 150], // 기타 - 분홍색 + '000028': [232, 95, 27, 150], // 부이 - 주황색 +}; + +/** 기본 색상 (선종 미확인 시) */ +export const DEFAULT_TRACK_COLOR = [128, 128, 128, 150]; + +/** 선종 코드로 색상 가져오기 */ +export function getShipKindColor(shipKindCode) { + if (!shipKindCode) return DEFAULT_TRACK_COLOR; + return SHIP_KIND_COLORS[shipKindCode] || DEFAULT_TRACK_COLOR; +} + +/** 선종 코드 → 선종명 */ +export function getShipKindName(shipKindCode) { + const names = { + '000020': '어선', + '000021': '함정', + '000022': '여객선', + '000023': '화물선', + '000024': '유조선', + '000025': '관공선', + '000027': '기타', + '000028': '부이', + }; + return names[shipKindCode || ''] || '기타'; +} + +/** 신호원 코드 → 신호원명 */ +export function getSignalSourceName(sigSrcCd) { + const names = { + '000001': 'AIS', + '000002': 'E-Nav', + '000003': 'V-Pass', + '000004': 'VTS-AIS', + '000005': 'VTS-RT', + '000016': 'D-MF/HF', + '999999': '통합선박', + }; + return names[sigSrcCd || ''] || 'Unknown'; +} diff --git a/src/tracking/utils/resetTrackQuery.js b/src/tracking/utils/resetTrackQuery.js new file mode 100644 index 00000000..d439e11f --- /dev/null +++ b/src/tracking/utils/resetTrackQuery.js @@ -0,0 +1,29 @@ +/** + * 항적조회 초기화 유틸 + * + * 항적 레이어 정리 및 스토어 초기화 + */ + +import { useTrackQueryStore } from '../stores/trackQueryStore'; +import { unregisterTrackQueryLayers } from './trackQueryLayerUtils'; +import { shipBatchRenderer } from '../../map/ShipBatchRenderer'; + +/** + * 항적조회 상태 및 레이어 초기화 + * + * @param {Object} [map] OpenLayers Map 인스턴스 + */ +export const resetTrackQuery = (map) => { + // 스토어 초기화 + useTrackQueryStore.getState().reset(); + + // 레이어 정리 + unregisterTrackQueryLayers(); + + // deck.gl 레이어 업데이트 + if (map) { + shipBatchRenderer.immediateRender(); + } +}; + +export default resetTrackQuery; diff --git a/src/tracking/utils/shipIconUtil.js b/src/tracking/utils/shipIconUtil.js new file mode 100644 index 00000000..f76e4eb9 --- /dev/null +++ b/src/tracking/utils/shipIconUtil.js @@ -0,0 +1,75 @@ +/** + * 선박 아이콘 유틸리티 + * + * 선박 종류 코드와 항해/정박 상태에 따라 적절한 아이콘을 제공 + */ + +import { ICON_MAPPING_KIND_MOVING, ICON_MAPPING_KIND_STOPPING } from '../../types/constants'; + +/** + * 선박 종류 코드와 항해 상태로 아이콘 이름 가져오기 + * + * @param {string} shipKindCode 선박 종류 코드 (000020-000028) + * @param {boolean} [isMoving=true] 항해 중 여부 + * @returns {string} 아이콘 매핑 키 + */ +export function getShipIconUrl(shipKindCode, isMoving = true) { + const iconMapping = isMoving + ? ICON_MAPPING_KIND_MOVING + : ICON_MAPPING_KIND_STOPPING; + + return iconMapping[shipKindCode] || iconMapping['000027']; +} + +/** + * 속도 기반 선박 아이콘 가져오기 + * 0.5 knots를 기준으로 항해 중 여부를 결정 + * + * @param {string} shipKindCode 선박 종류 코드 + * @param {number} [speed=0] 선박 속도 (knots) + * @returns {string} 아이콘 매핑 키 + */ +export function getV2ShipIconUrl(shipKindCode, speed = 0) { + const isMoving = speed > 0.5; + return getShipIconUrl(shipKindCode, isMoving); +} + +/** + * 선박 종류 코드를 한글 이름으로 변환 + * + * @param {string} shipKindCode 선박 종류 코드 + * @returns {string} 선박 타입 한글 이름 + */ +export function getShipKindName(shipKindCode) { + const kindNames = { + '000020': '어선', + '000021': '함정', + '000022': '여객선', + '000023': '화물선', + '000024': '유조선', + '000025': '관공선', + '000027': '기타', + '000028': '부이', + }; + + return kindNames[shipKindCode] || '기타'; +} + +/** + * 신호 소스 코드를 이름으로 변환 + * + * @param {string} signalSourceCode 신호 소스 코드 + * @returns {string} 신호 소스 이름 + */ +export function getSignalSourceName(signalSourceCode) { + const sourceNames = { + '000001': 'AIS', + '000002': 'E-NAV', + '000003': 'V-PASS', + '000004': 'VTS-AIS', + '000005': 'VTS-RADAR', + '000016': 'D-MF/HF', + }; + + return sourceNames[signalSourceCode] || signalSourceCode; +} diff --git a/src/tracking/utils/trackQueryLayerUtils.js b/src/tracking/utils/trackQueryLayerUtils.js new file mode 100644 index 00000000..0078fe4b --- /dev/null +++ b/src/tracking/utils/trackQueryLayerUtils.js @@ -0,0 +1,488 @@ +/** + * 항적조회 전용 deck.gl 레이어 생성 유틸리티 + * + * - PathLayer: 선종 색상 기반 항적 라인 + * - ScatterplotLayer: 포인트 표시 (클러스터링) + * - IconLayer: 가상 선박 아이콘 + * - TextLayer: 선명 라벨 + */ + +import { PathLayer, ScatterplotLayer, IconLayer, TextLayer } from '@deck.gl/layers'; +import { PathStyleExtension } from '@deck.gl/extensions'; +import { getShipIconUrl } from './shipIconUtil'; +import { getShipKindColor } from '../types/trackQuery.types'; +import useShipStore from '../../stores/shipStore'; +import { useMapStore, THEME_COLORS, THEME_TYPES } from '../../stores/mapStore'; +import { + ICON_ATLAS_MAPPING, + ICON_MAPPING_KIND_MOVING, +} from '../../types/constants'; +import atlasImg from '../../assets/img/icon/atlas.png'; + +/** 현재 테마 색상 가져오기 */ +function getCurrentThemeColors() { + const { getTheme } = useMapStore.getState(); + const theme = getTheme(); + return THEME_COLORS[theme] || THEME_COLORS[THEME_TYPES.LIGHT]; +} + +// ========== 줌 레벨별 포인트 밀도 설정 ========== + +const POINT_DENSITY_CONFIGS = { + 5: { gridSizeMultiplier: 400, maxPointsPerCell: 1, minPointRadius: 2, maxPointRadius: 3 }, + 6: { gridSizeMultiplier: 300, maxPointsPerCell: 2, minPointRadius: 2, maxPointRadius: 3 }, + 7: { gridSizeMultiplier: 200, maxPointsPerCell: 3, minPointRadius: 2, maxPointRadius: 4 }, + 8: { gridSizeMultiplier: 150, maxPointsPerCell: 5, minPointRadius: 2, maxPointRadius: 4 }, + 9: { gridSizeMultiplier: 100, maxPointsPerCell: 8, minPointRadius: 3, maxPointRadius: 5 }, + 10: { gridSizeMultiplier: 80, maxPointsPerCell: 12, minPointRadius: 3, maxPointRadius: 5 }, + 11: { gridSizeMultiplier: 80, maxPointsPerCell: 15, minPointRadius: 3, maxPointRadius: 6 }, + 12: { gridSizeMultiplier: 60, maxPointsPerCell: 20, minPointRadius: 4, maxPointRadius: 6 }, + 13: { gridSizeMultiplier: 30, maxPointsPerCell: Infinity, minPointRadius: 4, maxPointRadius: 7 }, + 14: { gridSizeMultiplier: 20, maxPointsPerCell: Infinity, minPointRadius: 4, maxPointRadius: 8 }, +}; + +const getPointDensityConfig = (zoomLevel) => { + const clampedZoom = Math.max(5, Math.min(14, Math.floor(zoomLevel))); + return POINT_DENSITY_CONFIGS[clampedZoom] || POINT_DENSITY_CONFIGS[10]; +}; + +// ========== 레이어 ID 상수 ========== + +export const LAYER_IDS = { + LOOP_SECTION_PATH: 'track-query-loop-section-path-layer', + PATH: 'track-query-path-layer', + POINTS: 'track-query-points-layer', + VIRTUAL_SHIP_GLOW: 'track-query-virtual-ship-glow-layer', + VIRTUAL_SHIP: 'track-query-virtual-ship-layer', + VIRTUAL_SHIP_LABEL: 'track-query-virtual-ship-label-layer', + LIVE_CONNECTION: 'track-query-live-connection-layer', + TOOLTIP: 'track-query-tooltip-layer', +}; + +// ========== 데이터 생성 함수 ========== + +/** 항적 라인 PathLayer 데이터 생성 */ +export function createPathLayerData(tracks) { + return tracks.map(track => ({ + path: track.geometry, + color: getShipKindColor(track.shipKindCode), + width: 3, + vesselId: track.vesselId, + shipKindCode: track.shipKindCode, + shipName: track.shipName, + })); +} + +/** 포인트 ScatterplotLayer 데이터 생성 (클러스터링 적용) */ +export function createPointsLayerData(tracks, zoomLevel) { + const points = []; + + tracks.forEach(track => { + const color = getShipKindColor(track.shipKindCode); + const pointColor = [ + Math.min(255, color[0] + 30), + Math.min(255, color[1] + 30), + Math.min(255, color[2] + 30), + 255, + ]; + + track.geometry.forEach((coord, index) => { + points.push({ + position: coord, + color: pointColor, + vesselId: track.vesselId, + timestamp: track.timestampsMs[index], + speed: track.speeds[index] || 0, + index, + }); + }); + }); + + if (zoomLevel === undefined || points.length < 500) { + return points; + } + + return clusterTrackPoints(points, zoomLevel); +} + +/** 항적 포인트 클러스터링 */ +function clusterTrackPoints(points, zoomLevel) { + const config = getPointDensityConfig(zoomLevel); + + if (config.maxPointsPerCell === Infinity) { + return points; + } + + const gridSize = Math.pow(2, -zoomLevel) * config.gridSizeMultiplier; + const gridCells = new Map(); + + for (const point of points) { + const gridX = Math.floor(point.position[0] / gridSize); + const gridY = Math.floor(point.position[1] / gridSize); + const gridKey = `${gridX},${gridY}`; + + if (!gridCells.has(gridKey)) { + gridCells.set(gridKey, []); + } + gridCells.get(gridKey).push(point); + } + + const result = []; + + gridCells.forEach(cellPoints => { + if (cellPoints.length <= config.maxPointsPerCell) { + result.push(...cellPoints); + } else { + cellPoints.sort((a, b) => a.timestamp - b.timestamp); + const step = cellPoints.length / config.maxPointsPerCell; + for (let i = 0; i < config.maxPointsPerCell; i++) { + const idx = Math.floor(i * step); + result.push(cellPoints[idx]); + } + } + }); + + return result; +} + +/** 가상 선박 IconLayer 데이터 생성 */ +export function createVirtualShipData(positions) { + return positions.map(pos => ({ + position: pos.position, + icon: ICON_MAPPING_KIND_MOVING[pos.shipKindCode] || 'etcImg', + size: 24, + vesselId: pos.vesselId, + heading: pos.heading, + shipName: pos.shipName, + speed: pos.speed, + shipKindCode: pos.shipKindCode, + })); +} + +/** 가상 선박 글로우 효과 데이터 생성 */ +export function createVirtualShipGlowData(positions) { + return positions.map(pos => { + const color = getShipKindColor(pos.shipKindCode); + return { + position: pos.position, + color: [color[0], color[1], color[2], 120], + vesselId: pos.vesselId, + }; + }); +} + +/** 유효한 선명인지 확인 */ +function isValidLabelShipName(name) { + if (!name) return false; + const trimmed = name.trim(); + if (!trimmed) return false; + if (trimmed === '-') return false; + if (/^\d+$/.test(trimmed)) return false; + return true; +} + +/** 선명 라벨 데이터 생성 */ +export function createVirtualShipLabelData(positions) { + return positions.map(pos => ({ + position: pos.position, + text: isValidLabelShipName(pos.shipName) ? pos.shipName : pos.targetId, + vesselId: pos.vesselId, + })); +} + +// ========== 레이어 생성 함수 ========== + +/** + * 경로 레이어 생성 + */ +export function createPathLayers(tracks, options, onPathHover) { + const layers = []; + const { updateTrigger, highlightedVesselId = null } = options; + + if (tracks.length === 0) return layers; + + const pathData = createPathLayerData(tracks); + + const pathLayer = new PathLayer({ + id: LAYER_IDS.PATH, + data: pathData, + getPath: d => d.path, + getColor: d => { + if (highlightedVesselId && highlightedVesselId === d.vesselId) { + return [255, 255, 0, 255]; + } + return d.color; + }, + getWidth: d => { + if (highlightedVesselId && highlightedVesselId === d.vesselId) { + return 6; + } + return d.width; + }, + widthMinPixels: 2, + widthMaxPixels: 8, + pickable: true, + autoHighlight: true, + highlightColor: [255, 255, 0, 220], + onHover: info => { + if (onPathHover) { + onPathHover(info.object?.vesselId ?? null); + } + }, + updateTriggers: { + getColor: [updateTrigger, highlightedVesselId], + getWidth: [updateTrigger, highlightedVesselId], + }, + }); + layers.push(pathLayer); + + return layers; +} + +/** + * 포인트 레이어 생성 (클러스터링 포함) + */ +export function createPointsLayerOnly(tracks, options, onPointHover) { + const { updateTrigger, zoomLevel } = options; + + if (tracks.length === 0) return null; + + const pointsData = createPointsLayerData(tracks, zoomLevel); + const pointConfig = zoomLevel ? getPointDensityConfig(zoomLevel) : { minPointRadius: 3, maxPointRadius: 6 }; + + return new ScatterplotLayer({ + id: LAYER_IDS.POINTS, + data: pointsData, + getPosition: d => d.position, + getFillColor: d => d.color, + getRadius: 4, + radiusMinPixels: pointConfig.minPointRadius, + radiusMaxPixels: pointConfig.maxPointRadius, + pickable: true, + onHover: info => { + if (onPointHover) { + if (info.object) { + const point = info.object; + onPointHover( + { + vesselId: point.vesselId, + position: point.position, + timestamp: point.timestamp, + speed: point.speed, + index: point.index, + }, + info.x, + info.y, + ); + } else { + onPointHover(null, 0, 0); + } + } + }, + updateTriggers: { + data: [updateTrigger, zoomLevel], + }, + }); +} + +/** + * 동적 레이어 생성 (가상 선박 아이콘, 라벨) + * currentTime 변경 시마다 재생성 + */ +export function createDynamicTrackLayers(positions, tracks, options, onIconHover) { + const layers = []; + const { showVirtualShip, showLabels = false, updateTrigger } = options; + + // 1. 가상 선박 글로우 효과 + if (showVirtualShip && positions.length > 0) { + const glowData = createVirtualShipGlowData(positions); + + const glowLayer = new ScatterplotLayer({ + id: LAYER_IDS.VIRTUAL_SHIP_GLOW, + data: glowData, + getPosition: d => d.position, + getFillColor: d => d.color, + getRadius: 20, + radiusMinPixels: 18, + radiusMaxPixels: 28, + pickable: false, + updateTriggers: { getPosition: updateTrigger }, + }); + layers.push(glowLayer); + + // 2. IconLayer - 가상 선박 + const iconData = createVirtualShipData(positions); + + const iconLayer = new IconLayer({ + id: LAYER_IDS.VIRTUAL_SHIP, + data: iconData, + iconAtlas: atlasImg, + iconMapping: ICON_ATLAS_MAPPING, + getIcon: d => d.icon, + getPosition: d => d.position, + getSize: d => d.size, + sizeUnits: 'pixels', + getAngle: d => d.heading || 0, + pickable: true, + onHover: info => { + if (onIconHover) { + if (info.object) { + onIconHover(info.object, info.x, info.y); + } else { + onIconHover(null, 0, 0); + } + } + }, + updateTriggers: { + getPosition: updateTrigger, + getAngle: updateTrigger, + }, + }); + layers.push(iconLayer); + + // 3. TextLayer - 선명 라벨 + if (showLabels) { + const labelData = createVirtualShipLabelData(positions); + const themeColors = getCurrentThemeColors(); + + const labelLayer = new TextLayer({ + id: LAYER_IDS.VIRTUAL_SHIP_LABEL, + data: labelData, + getPosition: d => d.position, + getText: d => d.text, + getSize: 12, + getColor: themeColors.shipLabel, + getAngle: 0, + getTextAnchor: 'start', + getAlignmentBaseline: 'center', + getPixelOffset: [14, 0], + fontFamily: 'sans-serif', + fontWeight: 'bold', + outlineWidth: 2, + outlineColor: themeColors.shipLabelOutline, + characterSet: 'auto', + pickable: false, + updateTriggers: { getPosition: updateTrigger }, + }); + layers.push(labelLayer); + } + } + + return layers; +} + +// ========== 포인트 정보 포맷팅 ========== + +/** 포인트 정보 포맷팅 (호버 툴팁용) */ +export function formatPointInfo(info) { + const date = new Date(info.timestamp); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); + const timeStr = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; + + return { + time: timeStr, + position: `${info.position[1].toFixed(6)}, ${info.position[0].toFixed(6)}`, + speed: `${info.speed.toFixed(1)} knots`, + }; +} + +// ========== 라이브 연결선 (항적 끝점 ↔ 라이브 선박) ========== + +/** + * 마지막 항적점과 라이브 선박 사이 연결 데이터 생성 + * 항적의 마지막 점과 실시간 선박 위치를 점선으로 연결 + * + * @param {Array} tracks - ProcessedTrack 배열 + * @returns {Array} 연결선 데이터 배열 [{ path, color, vesselId }] + */ +export function createLiveConnectionData(tracks) { + const { features } = useShipStore.getState(); + const connections = []; + + if (tracks.length === 0) return connections; + + tracks.forEach(track => { + if (track.geometry.length === 0) return; + + const lastPoint = track.geometry[track.geometry.length - 1]; + const color = getShipKindColor(track.shipKindCode); + + // 라이브 선박 좌표 조회: sigSrcCd + targetId = featureId + const featureKey = `${track.sigSrcCd}${track.targetId}`; + const liveShip = features.get(featureKey); + + if (!liveShip || !liveShip.longitude || !liveShip.latitude) return; + + const liveLon = Number(liveShip.longitude); + const liveLat = Number(liveShip.latitude); + + if (isNaN(liveLon) || isNaN(liveLat)) return; + + // 항적 마지막 점과 라이브 선박 사이 거리가 너무 가까우면 스킵 + const dx = liveLon - lastPoint[0]; + const dy = liveLat - lastPoint[1]; + if (Math.abs(dx) < 0.00001 && Math.abs(dy) < 0.00001) return; + + connections.push({ + path: [lastPoint, [liveLon, liveLat]], + color: [color[0], color[1], color[2], 180], + vesselId: track.vesselId, + }); + }); + + return connections; +} + +/** + * 라이브 선박 연결선 레이어 생성 (점선 스타일) + * 항적 마지막 점과 라이브 선박 위치를 점선으로 연결 + * + * @param {Array} tracks - ProcessedTrack 배열 + * @param {number} updateTrigger - 업데이트 트리거 + * @returns {PathLayer|null} 연결선 레이어 + */ +export function createLiveConnectionLayer(tracks, updateTrigger) { + if (tracks.length === 0) return null; + + const connectionData = createLiveConnectionData(tracks); + if (connectionData.length === 0) return null; + + return new PathLayer({ + id: LAYER_IDS.LIVE_CONNECTION, + data: connectionData, + getPath: d => d.path, + getColor: d => d.color, + getWidth: 2, + widthMinPixels: 2, + widthMaxPixels: 3, + getDashArray: [6, 4], + dashJustified: true, + extensions: [new PathStyleExtension({ dash: true })], + pickable: false, + updateTriggers: { + getPath: updateTrigger, + }, + }); +} + +// ========== 전역 레이어 레지스트리 ========== + +/** 전역 레이어 레지스트리에 레이어 등록 */ +export function registerTrackQueryLayers(layers) { + window.__trackQueryLayers__ = layers; +} + +/** 전역 레이어 레지스트리에서 레이어 제거 */ +export function unregisterTrackQueryLayers() { + window.__trackQueryLayers__ = []; +} + +/** 등록된 TrackQuery 레이어 가져오기 */ +export function getTrackQueryLayers() { + return window.__trackQueryLayers__ || []; +} diff --git a/src/tracking/utils/tracking.utils.js b/src/tracking/utils/tracking.utils.js new file mode 100644 index 00000000..511a02ca --- /dev/null +++ b/src/tracking/utils/tracking.utils.js @@ -0,0 +1,92 @@ +/** + * 선박 항적 조회 유틸리티 함수 + * + * 주요 기능: + * - 시간 기반 위치 보간 + * - 방향(heading) 계산 + * - 날짜시간 포맷팅 + */ + +/** + * 두 지점 사이의 선박 위치를 시간 기반으로 보간 + * + * @param {[number, number]} p1 시작 지점 좌표 [경도, 위도] + * @param {[number, number]} p2 종료 지점 좌표 [경도, 위도] + * @param {number} t1 시작 시간 (밀리초) + * @param {number} t2 종료 시간 (밀리초) + * @param {number} currentTime 현재 시간 (밀리초) + * @returns {[number, number]} 보간된 위치 [경도, 위도] + */ +export function interpolatePosition(p1, p2, t1, t2, currentTime) { + if (t1 === t2) return p1; + if (currentTime <= t1) return p1; + if (currentTime >= t2) return p2; + + const ratio = (currentTime - t1) / (t2 - t1); + const lon = p1[0] + (p2[0] - p1[0]) * ratio; + const lat = p1[1] + (p2[1] - p1[1]) * ratio; + + return [lon, lat]; +} + +/** + * 두 지점 간의 방향(heading) 계산 + * 북쪽을 0도로 하여 시계방향으로 각도를 계산 + * + * @param {[number, number]} p1 시작 지점 [경도, 위도] + * @param {[number, number]} p2 종료 지점 [경도, 위도] + * @returns {number} 방향 각도 (도 단위) + */ +export function calculateHeading(p1, p2) { + const [lon1, lat1] = p1; + const [lon2, lat2] = p2; + + const dx = lon2 - lon1; + const dy = lat2 - lat1; + + let angle = (Math.atan2(dx, dy) * 180) / Math.PI; + angle = -angle; + if (angle < 0) angle += 360; + + return angle; +} + +/** + * 날짜시간 포맷팅 (밀리초 -> "YYYY-MM-DD HH:mm:ss") + * + * @param {number} timestamp 타임스탬프 (밀리초) + * @returns {string} 포맷된 날짜시간 문자열 + */ +export function formatDateTime(timestamp) { + const date = new Date(timestamp); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; +} + +/** + * 시간 차이를 사람이 읽기 쉬운 형태로 변환 + * + * @param {number} milliseconds 밀리초 + * @returns {string} 포맷된 시간 문자열 (예: "2시간 30분") + */ +export function formatDuration(milliseconds) { + const seconds = Math.floor(milliseconds / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + return `${days}일 ${hours % 24}시간`; + } else if (hours > 0) { + return `${hours}시간 ${minutes % 60}분`; + } else if (minutes > 0) { + return `${minutes}분 ${seconds % 60}초`; + } else { + return `${seconds}초`; + } +}