feat: 항적조회 기능 구현
- tracking 패키지 TS→JS 변환 (stores, services, components, hooks, utils) - 모달 항적조회 + 우클릭 항적조회 - 라이브 연결선 (PathStyleExtension dash + 1초 인터벌) - TrackQueryModal, TrackQueryViewer, GlobalTrackQueryViewer - 항적 레이어 (trackLayer.js) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
부모
61dc5a0e4d
커밋
e74688a969
213
src/api/trackApi.js
Normal file
213
src/api/trackApi.js
Normal file
@ -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<Array>} 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)];
|
||||
}
|
||||
@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
495
src/components/ship/TrackQueryModal.jsx
Normal file
495
src/components/ship/TrackQueryModal.jsx
Normal file
@ -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 (
|
||||
<div
|
||||
className="track-query-viewer"
|
||||
style={{
|
||||
transform: `translate(calc(-50% + ${position.x}px), calc(${position.y}px))`,
|
||||
}}
|
||||
>
|
||||
{/* === Header === */}
|
||||
<div className="track-query-viewer__header" onMouseDown={handleDragStart}>
|
||||
<div className="track-query-viewer__header-info">
|
||||
<span className="track-query-viewer__ship-name">
|
||||
{shipName || targetId || '항적조회'}
|
||||
</span>
|
||||
{shipName && targetId && (
|
||||
<span className="track-query-viewer__badge track-query-viewer__badge--id">
|
||||
{targetId}
|
||||
</span>
|
||||
)}
|
||||
<span className="track-query-viewer__badge track-query-viewer__badge--kind">
|
||||
{kindLabel}
|
||||
</span>
|
||||
{sourceLabel && (
|
||||
<span className="track-query-viewer__badge track-query-viewer__badge--signal">
|
||||
{sourceLabel}
|
||||
</span>
|
||||
)}
|
||||
{isIntegrated && (
|
||||
<span className="track-query-viewer__badge track-query-viewer__badge--integrated">
|
||||
통합
|
||||
</span>
|
||||
)}
|
||||
{isLoading && (
|
||||
<span className="track-query-viewer__loading-badge">조회중</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="track-query-viewer__header-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="track-query-viewer__close-btn"
|
||||
onClick={handleClose}
|
||||
title="닫기"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* === Body === */}
|
||||
<div className="track-query-viewer__body">
|
||||
{/* 에러 */}
|
||||
{error && (
|
||||
<div className="track-query-viewer__error">{error}</div>
|
||||
)}
|
||||
|
||||
{/* 로딩 (데이터 없을 때) */}
|
||||
{isLoading && !hasTracks && (
|
||||
<div className="track-query-viewer__loading">항적 데이터 조회 중...</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 없음 */}
|
||||
{!isLoading && !hasTracks && !error && (
|
||||
<div className="track-query-viewer__empty">항적 데이터가 없습니다.</div>
|
||||
)}
|
||||
|
||||
{/* === Progress Section === */}
|
||||
{hasTracks && (
|
||||
<div className="track-query-viewer__progress-section">
|
||||
{/* 시간 정보 (3열) */}
|
||||
<div className="track-query-viewer__time-info">
|
||||
<span className="track-query-viewer__time-start">
|
||||
{formatShortDateTime(dataStartTime)}
|
||||
</span>
|
||||
<span className="track-query-viewer__time-current">
|
||||
{formatDateTime(currentTime)}
|
||||
</span>
|
||||
<span className="track-query-viewer__time-end">
|
||||
{formatShortDateTime(dataEndTime)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 프로그레스 바 */}
|
||||
<div
|
||||
className="track-query-viewer__progress-bar"
|
||||
onClick={handleProgressClick}
|
||||
>
|
||||
<div className="track-query-viewer__progress-track" />
|
||||
<div
|
||||
className="track-query-viewer__progress-fill"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
<div
|
||||
className="track-query-viewer__progress-handle"
|
||||
style={{ left: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 옵션 체크박스 */}
|
||||
<div className="track-query-viewer__options">
|
||||
<label className="track-query-viewer__option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showPoints}
|
||||
onChange={(e) => useTrackStore.getState().setShowPoints(e.target.checked)}
|
||||
/>
|
||||
<span>포인트</span>
|
||||
</label>
|
||||
<label className="track-query-viewer__option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showVirtualShip}
|
||||
onChange={(e) => useTrackStore.getState().setShowVirtualShip(e.target.checked)}
|
||||
/>
|
||||
<span>선박 아이콘</span>
|
||||
</label>
|
||||
<label className="track-query-viewer__option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showLabels}
|
||||
onChange={(e) => useTrackStore.getState().setShowLabels(e.target.checked)}
|
||||
/>
|
||||
<span>선명 표시</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* === Time Form (조회 기간) === */}
|
||||
<div className="track-query-viewer__time-form">
|
||||
<span className="track-query-viewer__form-label">조회 기간</span>
|
||||
<div className="track-query-viewer__time-inputs">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={startInput}
|
||||
onChange={handleStartChange}
|
||||
onBlur={handleStartBlur}
|
||||
className="track-query-viewer__datetime-input"
|
||||
/>
|
||||
<span className="track-query-viewer__time-separator">~</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={endInput}
|
||||
onChange={handleEndChange}
|
||||
onBlur={handleEndBlur}
|
||||
className="track-query-viewer__datetime-input"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="track-query-viewer__query-btn"
|
||||
onClick={handleRequery}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? '조회 중...' : '조회'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* === Equipment Filter (통합선박만) === */}
|
||||
{isIntegrated && (
|
||||
<EquipmentFilter ship={ship} />
|
||||
)}
|
||||
|
||||
{/* === 항적 통계 (단일 항적) === */}
|
||||
{hasTracks && tracks.length === 1 && (
|
||||
<div className="track-query-viewer__stats">
|
||||
<span>거리: {tracks[0].stats.totalDistance.toFixed(1)} NM</span>
|
||||
<span>평균: {tracks[0].stats.avgSpeed.toFixed(1)} kn</span>
|
||||
<span>최대: {tracks[0].stats.maxSpeed.toFixed(1)} kn</span>
|
||||
<span>포인트: {tracks[0].stats.pointCount}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* === 다중 항적 선박 목록 === */}
|
||||
{hasTracks && tracks.length > 1 && !isIntegrated && (
|
||||
<div className="track-query-viewer__vessel-list">
|
||||
{tracks.map((track) => (
|
||||
<VesselItem key={track.vesselId} track={track} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 장비 필터 (통합선박 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 (
|
||||
<div className="track-query-viewer__equipment-filter">
|
||||
<span className="track-query-viewer__filter-title">장비별 항적</span>
|
||||
<div className="track-query-viewer__filter-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="track-query-viewer__filter-btn"
|
||||
onClick={handleEnableAll}
|
||||
>
|
||||
전체
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="track-query-viewer__filter-btn"
|
||||
onClick={handleResetDefault}
|
||||
>
|
||||
기본
|
||||
</button>
|
||||
</div>
|
||||
<div className="track-query-viewer__equipment-badges">
|
||||
{SIGNAL_FLAG_CONFIGS.map((config) => {
|
||||
const hasData = availableSigSrcCds.has(config.signalSourceCode);
|
||||
const isEnabled = !disabledSigSrcCds.has(config.signalSourceCode);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={config.key}
|
||||
type="button"
|
||||
className={`track-query-viewer__equipment-badge${isEnabled && hasData ? ' active' : ''}${!hasData ? ' no-data' : ''}`}
|
||||
style={{
|
||||
backgroundColor: isEnabled && hasData ? config.activeColor : undefined,
|
||||
borderColor: hasData ? config.activeColor : config.inactiveColor,
|
||||
}}
|
||||
onClick={() => hasData && handleToggle(config.signalSourceCode)}
|
||||
disabled={!hasData}
|
||||
title={config.name}
|
||||
>
|
||||
{config.key}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 다중 선박 목록 아이템
|
||||
*/
|
||||
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 (
|
||||
<div
|
||||
className={`track-query-viewer__vessel-item${enabled ? '' : ' disabled'}`}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<span
|
||||
className="track-query-viewer__vessel-color"
|
||||
style={{ backgroundColor: rgbaStr }}
|
||||
/>
|
||||
<span className="track-query-viewer__vessel-name">
|
||||
{track.shipName || track.targetId}
|
||||
</span>
|
||||
<span className="track-query-viewer__vessel-kind">{kindLabel}</span>
|
||||
<span className="track-query-viewer__vessel-points">
|
||||
{track.stats.pointCount}pts
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
483
src/components/ship/TrackQueryModal.scss
Normal file
483
src/components/ship/TrackQueryModal.scss
Normal file
@ -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; }
|
||||
}
|
||||
220
src/map/layers/trackLayer.js
Normal file
220
src/map/layers/trackLayer.js
Normal file
@ -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;
|
||||
}
|
||||
443
src/stores/trackStore.js
Normal file
443
src/stores/trackStore.js
Normal file
@ -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;
|
||||
51
src/tracking/components/GlobalTrackQueryViewer.jsx
Normal file
51
src/tracking/components/GlobalTrackQueryViewer.jsx
Normal file
@ -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 <TrackQueryViewer onClose={handleClose} showPlayback={showPlayback} />;
|
||||
};
|
||||
|
||||
export default GlobalTrackQueryViewer;
|
||||
43
src/tracking/components/GlobalTrackQueryViewer.scss
Normal file
43
src/tracking/components/GlobalTrackQueryViewer.scss
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
234
src/tracking/components/QueryProgressPanel.scss
Normal file
234
src/tracking/components/QueryProgressPanel.scss
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
132
src/tracking/components/ReplayControlV2.scss
Normal file
132
src/tracking/components/ReplayControlV2.scss
Normal file
@ -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: "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
455
src/tracking/components/ReplayV2.scss
Normal file
455
src/tracking/components/ReplayV2.scss
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
308
src/tracking/components/TrackQueryTimeline.jsx
Normal file
308
src/tracking/components/TrackQueryTimeline.jsx
Normal file
@ -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 (
|
||||
<div className={`track-query-timeline ${compact ? 'compact' : ''} ${isPlaying ? 'playing' : ''}`}>
|
||||
<div className="timeline-controls">
|
||||
{/* 배속 선택 */}
|
||||
<div className="speed-selector" ref={speedMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="speed-btn"
|
||||
onClick={() => setShowSpeedMenu(!showSpeedMenu)}
|
||||
disabled={!hasData}
|
||||
>
|
||||
{playbackSpeed}x
|
||||
</button>
|
||||
{showSpeedMenu && (
|
||||
<div className="speed-menu">
|
||||
{PLAYBACK_SPEED_OPTIONS.map(speed => (
|
||||
<button
|
||||
key={speed}
|
||||
type="button"
|
||||
className={`speed-option ${playbackSpeed === speed ? 'active' : ''}`}
|
||||
onClick={() => handleSpeedChange(speed)}
|
||||
>
|
||||
{speed}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 재생/일시정지 버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
className={`control-btn play-btn ${isPlaying ? 'playing' : ''}`}
|
||||
onClick={handlePlayPause}
|
||||
disabled={!hasData}
|
||||
title={isPlaying ? '일시정지' : '재생'}
|
||||
>
|
||||
{isPlaying ? '❚❚' : '▶'}
|
||||
</button>
|
||||
|
||||
{/* 정지 버튼 */}
|
||||
<button type="button" className="control-btn stop-btn" onClick={handleStop} disabled={!hasData} title="정지">
|
||||
■
|
||||
</button>
|
||||
|
||||
{/* 슬라이더 */}
|
||||
<div className="timeline-slider-container" ref={sliderContainerRef}>
|
||||
<input
|
||||
type="range"
|
||||
className="timeline-slider"
|
||||
min={startTime}
|
||||
max={endTime}
|
||||
step={(endTime - startTime) / 1000}
|
||||
value={animationCurrentTime}
|
||||
onChange={handleSliderChange}
|
||||
disabled={!hasData}
|
||||
/>
|
||||
<div className="slider-progress" style={{ width: `${progress}%` }} />
|
||||
|
||||
{/* 구간반복 UI (반복 체크 시에만 표시) */}
|
||||
{loop && hasData && (
|
||||
<>
|
||||
{/* 구간반복 범위 하이라이트 */}
|
||||
<div
|
||||
className="loop-section-highlight"
|
||||
style={{
|
||||
left: `${loopProgress.start}%`,
|
||||
width: `${loopProgress.end - loopProgress.start}%`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 시작점 마커 (A) */}
|
||||
<div
|
||||
className={`loop-marker loop-marker-start ${draggingMarker === 'start' ? 'dragging' : ''} ${
|
||||
isPlaying ? 'disabled' : ''
|
||||
}`}
|
||||
style={{ left: `${loopProgress.start}%` }}
|
||||
onMouseDown={handleMarkerMouseDown('start')}
|
||||
title={`반복 시작: ${formatTime(loopStart)}`}
|
||||
>
|
||||
<span className="marker-label">A</span>
|
||||
</div>
|
||||
|
||||
{/* 종료점 마커 (B) */}
|
||||
<div
|
||||
className={`loop-marker loop-marker-end ${draggingMarker === 'end' ? 'dragging' : ''} ${
|
||||
isPlaying ? 'disabled' : ''
|
||||
}`}
|
||||
style={{ left: `${loopProgress.end}%` }}
|
||||
onMouseDown={handleMarkerMouseDown('end')}
|
||||
title={`반복 종료: ${formatTime(loopEnd)}`}
|
||||
>
|
||||
<span className="marker-label">B</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 현재 시간 */}
|
||||
<span className="current-time-display">{hasData ? formatTime(animationCurrentTime) : '--:--:--'}</span>
|
||||
|
||||
{/* 반복 토글 */}
|
||||
<label className="loop-toggle">
|
||||
<input type="checkbox" checked={loop} onChange={handleLoopToggle} disabled={!hasData} />
|
||||
<span>반복</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrackQueryTimeline;
|
||||
408
src/tracking/components/TrackQueryTimeline.scss
Normal file
408
src/tracking/components/TrackQueryTimeline.scss
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
949
src/tracking/components/TrackQueryViewer.jsx
Normal file
949
src/tracking/components/TrackQueryViewer.jsx
Normal file
@ -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,
|
||||
}) => (
|
||||
<div
|
||||
className={`ship-info-item ${isActive ? 'active' : ''} ${!isEnabled ? 'disabled' : ''} ${isHighlighted ? 'highlighted' : ''}`}
|
||||
onClick={() => onToggle(vesselId)}
|
||||
onMouseEnter={() => onMouseEnter(vesselId)}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onContextMenu={e => onContextMenu(vesselId, e)}
|
||||
title={isEnabled ? '좌클릭: 비활성화 | 우클릭: 위치로 이동' : '좌클릭: 활성화 | 우클릭: 위치로 이동'}
|
||||
>
|
||||
<span className="ship-name">{formatShipName(shipName, targetId)}</span>
|
||||
<span className="ship-kind">{getShipKindName(shipKindCode)}</span>
|
||||
<span className="ship-signal">{getSignalSourceName(sigSrcCd)}</span>
|
||||
{speed !== null ? (
|
||||
<span className="ship-speed">{speed.toFixed(1)} kn</span>
|
||||
) : (
|
||||
<span className="ship-status">{isEnabled ? '범위 외' : 'OFF'}</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
(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 (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`track-query-viewer ${compact ? 'compact' : ''} ${isSingleVessel ? 'single-vessel' : ''} ${modalMode ? 'modal-mode' : ''} ${isDraggingPanel ? 'dragging' : ''}`}
|
||||
style={panelStyle}
|
||||
>
|
||||
{/* 헤더 (선명, TargetId, 버튼) - 드래그 핸들 */}
|
||||
<div
|
||||
className="track-query-header draggable"
|
||||
onMouseDown={handlePanelDragStart}
|
||||
>
|
||||
<div className="header-info">
|
||||
{modalMode && hasTracks ? (
|
||||
<>
|
||||
<span className="vessel-name">{formatShipName(tracks[0]?.shipName)}</span>
|
||||
<span className="vessel-id">{tracks[0]?.targetId}</span>
|
||||
<span className="vessel-kind">{getShipKindName(tracks[0]?.shipKindCode)}</span>
|
||||
</>
|
||||
) : modalMode && !hasTracks ? (
|
||||
<span className="vessel-count">항적조회</span>
|
||||
) : singleVessel ? (
|
||||
<>
|
||||
<span className="vessel-name">{formatShipName(singleVessel.shipName)}</span>
|
||||
<span className="vessel-id">{singleVessel.targetId}</span>
|
||||
<span className="vessel-signal">{getSignalSourceName(singleVessel.sigSrcCd)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="vessel-count">항적조회 ({tracks.length}척)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`live-ship-toggle ${hideLiveShips ? 'off' : 'on'}`}
|
||||
onClick={handleToggleLiveShips}
|
||||
title={hideLiveShips ? '라이브 선박 표시' : '라이브 선박 숨김'}
|
||||
>
|
||||
{hideLiveShips ? '라이브 OFF' : '라이브 ON'}
|
||||
</button>
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
className="close-btn"
|
||||
onClick={handleClose}
|
||||
title="항적조회 닫기"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 프로그레스 바 섹션 (항적 데이터가 있을 때만) */}
|
||||
{hasTracks && (
|
||||
<div className="track-query-progress-section">
|
||||
{/* 기존 프로그레스 바 (showPlayback이 false일 때만 표시) */}
|
||||
{!showPlayback && (
|
||||
<>
|
||||
<div className="track-query-time-info">
|
||||
<span className="start-time">
|
||||
{formatDate(dataStartTime)} {formatTime(dataStartTime)}
|
||||
</span>
|
||||
<span className="current-time">
|
||||
{formatDate(currentTime)} {formatTime(currentTime)}
|
||||
</span>
|
||||
<span className="end-time">
|
||||
{formatDate(dataEndTime)} {formatTime(dataEndTime)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={progressBarRef}
|
||||
className="track-query-progress-bar"
|
||||
onMouseDown={handleProgressMouseDown}
|
||||
>
|
||||
<div className="progress-track">
|
||||
<div className="progress-fill" style={{ width: `${progress}%` }} />
|
||||
<div
|
||||
className="progress-handle"
|
||||
style={{ left: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 옵션 토글 */}
|
||||
<div className="track-query-options">
|
||||
<label className="option-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showPoints}
|
||||
onChange={e => setShowPoints(e.target.checked)}
|
||||
/>
|
||||
<span>포인트</span>
|
||||
</label>
|
||||
<label className="option-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showVirtualShip}
|
||||
onChange={e => setShowVirtualShip(e.target.checked)}
|
||||
/>
|
||||
<span>선박 아이콘</span>
|
||||
</label>
|
||||
{tracks.length > 1 && (
|
||||
<label className="option-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showLabels}
|
||||
onChange={e => setShowLabels(e.target.checked)}
|
||||
/>
|
||||
<span>선명 표시</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 재생 컨트롤 (showPlayback이 true일 때만 표시) */}
|
||||
{showPlayback && dataStartTime > 0 && dataEndTime > dataStartTime && (
|
||||
<TrackQueryTimeline
|
||||
startTime={dataStartTime}
|
||||
endTime={dataEndTime}
|
||||
compact={compact}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 선박 모달 모드: 시간 입력 폼 */}
|
||||
{modalMode && timeRange && onTimeRangeChange && (
|
||||
<div className="track-query-time-form">
|
||||
<div className="time-form-row">
|
||||
<label htmlFor="track-from-date">조회 기간</label>
|
||||
<div className="time-inputs">
|
||||
<input
|
||||
type="datetime-local"
|
||||
id="track-from-date"
|
||||
value={timeRange.fromDate}
|
||||
onChange={e => onTimeRangeChange({ ...timeRange, fromDate: e.target.value })}
|
||||
onBlur={() => validateAndAdjustTimeRange('from')}
|
||||
/>
|
||||
<span className="time-separator">~</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
id="track-to-date"
|
||||
value={timeRange.toDate}
|
||||
onChange={e => onTimeRangeChange({ ...timeRange, toDate: e.target.value })}
|
||||
onBlur={() => validateAndAdjustTimeRange('to')}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="query-btn"
|
||||
onClick={handleQueryClick}
|
||||
disabled={isQuerying}
|
||||
>
|
||||
{isQuerying ? '조회 중...' : '조회'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 모달 모드: 로딩/에러 상태 */}
|
||||
{modalMode && !hasTracks && (isQuerying || storeError) && (
|
||||
<div className="track-query-status">
|
||||
{isQuerying && <span className="status-loading">항적 데이터 조회 중...</span>}
|
||||
{!isQuerying && storeError && <span className="status-error">{storeError}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 통합선박 장비 필터 (modalMode + 다중 장비) */}
|
||||
{modalMode && isIntegrated && hasMultipleEquipments && (
|
||||
<div className="track-query-equipment-filter">
|
||||
<span className="filter-title">장비별 항적</span>
|
||||
<div className="filter-actions">
|
||||
<button type="button" className="filter-action-btn" onClick={enableAll} title="모두 선택">
|
||||
전체
|
||||
</button>
|
||||
<button type="button" className="filter-action-btn" onClick={resetToDefault} title="기본값">
|
||||
기본
|
||||
</button>
|
||||
</div>
|
||||
<div className="equipment-filter-list">
|
||||
{equipments.map(eq => (
|
||||
<button
|
||||
key={eq.signalSourceCode}
|
||||
type="button"
|
||||
className={`equipment-toggle ${eq.isEnabled ? 'enabled' : 'disabled'}`}
|
||||
onClick={() => toggleEquipment(eq.signalSourceCode)}
|
||||
title={`${eq.name} (${eq.trackCount}건)`}
|
||||
>
|
||||
<span
|
||||
className="equipment-badge"
|
||||
style={{ backgroundColor: eq.isEnabled ? eq.background : '#999' }}
|
||||
>
|
||||
{eq.key}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 선박 목록 (우클릭 모드에서 2척 이상일 때만 표시) */}
|
||||
{!modalMode && tracks.length > 1 && (
|
||||
<div className="track-query-ship-info">
|
||||
{tracks.map(track => (
|
||||
<TrackQueryShipItem
|
||||
key={`${track.integrationTargetId || 'standalone'}_${track.vesselId}`}
|
||||
vesselId={track.vesselId}
|
||||
shipName={track.shipName}
|
||||
targetId={track.targetId}
|
||||
shipKindCode={track.shipKindCode}
|
||||
sigSrcCd={track.sigSrcCd}
|
||||
isEnabled={!disabledVesselIds.has(track.vesselId)}
|
||||
isActive={!disabledVesselIds.has(track.vesselId) && activeVesselIds.has(track.vesselId)}
|
||||
isHighlighted={isHighlighted(track.vesselId)}
|
||||
speed={vesselSpeedMap.get(track.vesselId) ?? null}
|
||||
onToggle={toggleVesselEnabled}
|
||||
onMouseEnter={handleListItemHover}
|
||||
onMouseLeave={clearHighlight}
|
||||
onContextMenu={handleShipContextMenu}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 가상 선박 아이콘 호버 툴팁 */}
|
||||
{hoveredShip && createPortal(
|
||||
<ShipTooltip
|
||||
ship={hoveredShip}
|
||||
x={shipTooltipPosition.x}
|
||||
y={shipTooltipPosition.y}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* 포인트 호버 툴팁 */}
|
||||
{storeHoveredPoint && createPortal(
|
||||
<div
|
||||
className="track-query-tooltip"
|
||||
style={{
|
||||
left: storeHoveredPointPosition.x + 10,
|
||||
top: storeHoveredPointPosition.y - 10,
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const formatted = formatPointInfo(storeHoveredPoint);
|
||||
return (
|
||||
<>
|
||||
<div className="tooltip-row">
|
||||
<span className="label">시간:</span>
|
||||
<span className="value">{formatted.time}</span>
|
||||
</div>
|
||||
<div className="tooltip-row">
|
||||
<span className="label">위치:</span>
|
||||
<span className="value">{formatted.position}</span>
|
||||
</div>
|
||||
<div className="tooltip-row">
|
||||
<span className="label">속도:</span>
|
||||
<span className="value">{formatted.speed}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrackQueryViewer;
|
||||
610
src/tracking/components/TrackQueryViewer.scss
Normal file
610
src/tracking/components/TrackQueryViewer.scss
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
168
src/tracking/components/TrackTimelineBar.scss
Normal file
168
src/tracking/components/TrackTimelineBar.scss
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
359
src/tracking/components/TrackingTimeline.scss
Normal file
359
src/tracking/components/TrackingTimeline.scss
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
140
src/tracking/hooks/useEquipmentFilter.js
Normal file
140
src/tracking/hooks/useEquipmentFilter.js
Normal file
@ -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;
|
||||
48
src/tracking/hooks/useTrackHighlight.js
Normal file
48
src/tracking/hooks/useTrackHighlight.js
Normal file
@ -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,
|
||||
};
|
||||
};
|
||||
392
src/tracking/services/trackQueryApi.js
Normal file
392
src/tracking/services/trackQueryApi.js
Normal file
@ -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<Array>} 항적 데이터 배열
|
||||
*/
|
||||
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;
|
||||
});
|
||||
}
|
||||
165
src/tracking/stores/trackQueryAnimationStore.js
Normal file
165
src/tracking/stores/trackQueryAnimationStore.js
Normal file
@ -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];
|
||||
359
src/tracking/stores/trackQueryStore.js
Normal file
359
src/tracking/stores/trackQueryStore.js
Normal file
@ -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;
|
||||
60
src/tracking/types/trackQuery.types.js
Normal file
60
src/tracking/types/trackQuery.types.js
Normal file
@ -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';
|
||||
}
|
||||
29
src/tracking/utils/resetTrackQuery.js
Normal file
29
src/tracking/utils/resetTrackQuery.js
Normal file
@ -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;
|
||||
75
src/tracking/utils/shipIconUtil.js
Normal file
75
src/tracking/utils/shipIconUtil.js
Normal file
@ -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;
|
||||
}
|
||||
488
src/tracking/utils/trackQueryLayerUtils.js
Normal file
488
src/tracking/utils/trackQueryLayerUtils.js
Normal file
@ -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__ || [];
|
||||
}
|
||||
92
src/tracking/utils/tracking.utils.js
Normal file
92
src/tracking/utils/tracking.utils.js
Normal file
@ -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}초`;
|
||||
}
|
||||
}
|
||||
불러오는 중...
Reference in New Issue
Block a user