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:
HeungTak Lee 2026-02-05 06:36:57 +09:00
부모 61dc5a0e4d
커밋 e74688a969
28개의 변경된 파일7430개의 추가작업 그리고 6개의 파일을 삭제

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 () => {

파일 보기

@ -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="닫기"
>
&#10005;
</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>
);
}

파일 보기

@ -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; }
}

파일 보기

@ -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
파일 보기

@ -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;

파일 보기

@ -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;

파일 보기

@ -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;
}
}
}

파일 보기

@ -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;
}
}

파일 보기

@ -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: "";
}
}
}
}
}

파일 보기

@ -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;
}
}

파일 보기

@ -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;

파일 보기

@ -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);
}
}

파일 보기

@ -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;

파일 보기

@ -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;
}
}
}
}

파일 보기

@ -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);
}
}

파일 보기

@ -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;
}
}
}

파일 보기

@ -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;

파일 보기

@ -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,
};
};

파일 보기

@ -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;
});
}

파일 보기

@ -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];

파일 보기

@ -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;

파일 보기

@ -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';
}

파일 보기

@ -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;

파일 보기

@ -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;
}

파일 보기

@ -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__ || [];
}

파일 보기

@ -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}`;
}
}