Merge branch 'feature/area-search' into 'develop'
feat: 항적분석(구역 검색) 기능 구현 See merge request mda/kcgv-react-frontend!1
This commit is contained in:
커밋
ecfc25edde
30
.env
Normal file
30
.env
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# ============================================
|
||||||
|
# 프로덕션 환경 (Production)
|
||||||
|
# - 빌드: npm run build:prod (또는 npm run build)
|
||||||
|
# - 실제 운영 서버 배포용
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# 배포 경로 (서브 경로 배포 시 설정)
|
||||||
|
# 반드시 '/'로 시작하고 '/'로 끝나야 함
|
||||||
|
VITE_BASE_URL=/kcgnv/
|
||||||
|
|
||||||
|
# API 서버 (프록시 타겟)
|
||||||
|
VITE_API_URL=https://mda.kcg.go.kr
|
||||||
|
|
||||||
|
# 지도 타일 서버
|
||||||
|
VITE_MAP_TILE_URL=https://mda.kcg.go.kr
|
||||||
|
|
||||||
|
# 선박 신호 WebSocket
|
||||||
|
VITE_SIGNAL_WS=wss://mda.kcg.go.kr/v3/connect
|
||||||
|
|
||||||
|
# 선박 신호 API
|
||||||
|
VITE_SIGNAL_API=https://mda.kcg.go.kr/signal-api
|
||||||
|
|
||||||
|
# 항적 조회 API
|
||||||
|
VITE_TRACK_API=https://mda.kcg.go.kr
|
||||||
|
|
||||||
|
# 항적 조회 WebSocket (STOMP)
|
||||||
|
VITE_TRACKING_WS=wss://mda.kcg.go.kr/ws-tracks/websocket
|
||||||
|
|
||||||
|
# 선박 데이터 쓰로틀링 (ms, 위성망 대역폭 절약)
|
||||||
|
VITE_SHIP_THROTTLE=30
|
||||||
29
.env.dev
Normal file
29
.env.dev
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# ============================================
|
||||||
|
# 개발 서버 배포 환경 (Development Server)
|
||||||
|
# - 빌드: yarn build:dev (또는 npm run build:dev)
|
||||||
|
# - 개발 서버 /kcgv 경로 배포용
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# 배포 경로 (개발서버 서브 경로)
|
||||||
|
VITE_BASE_URL=/kcgnv/
|
||||||
|
|
||||||
|
# API 서버 (개발서버)
|
||||||
|
VITE_API_URL=http://10.26.252.39:9090
|
||||||
|
|
||||||
|
# 지도 타일 서버
|
||||||
|
VITE_MAP_TILE_URL=http://10.26.252.39:9090
|
||||||
|
|
||||||
|
# 선박 신호 WebSocket
|
||||||
|
VITE_SIGNAL_WS=ws://10.26.252.39:9090/connect
|
||||||
|
|
||||||
|
# 선박 신호 API
|
||||||
|
VITE_SIGNAL_API=http://10.26.252.39:9090/signal-api
|
||||||
|
|
||||||
|
# 항적 조회 API (별도 서버)
|
||||||
|
VITE_TRACK_API=http://10.26.252.51:8090
|
||||||
|
|
||||||
|
# 항적 조회 WebSocket (STOMP)
|
||||||
|
VITE_TRACKING_WS=ws://10.26.252.51:8090/ws-tracks/websocket
|
||||||
|
|
||||||
|
# 선박 데이터 쓰로틀링 (ms)
|
||||||
|
VITE_SHIP_THROTTLE=30
|
||||||
29
.env.development
Normal file
29
.env.development
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# ============================================
|
||||||
|
# 로컬 개발 환경 (Local Development)
|
||||||
|
# - 서버: yarn dev
|
||||||
|
# - 로컬 개발 전용
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# 배포 경로 (로컬 개발은 루트)
|
||||||
|
VITE_BASE_URL=/
|
||||||
|
|
||||||
|
# API 서버 (프록시 타겟)
|
||||||
|
VITE_API_URL=http://10.26.252.39:9090
|
||||||
|
|
||||||
|
# 지도 타일 서버
|
||||||
|
VITE_MAP_TILE_URL=http://10.26.252.39:9090
|
||||||
|
|
||||||
|
# 선박 신호 WebSocket
|
||||||
|
VITE_SIGNAL_WS=ws://10.26.252.39:9090/connect
|
||||||
|
|
||||||
|
# 선박 신호 API
|
||||||
|
VITE_SIGNAL_API=http://10.26.252.39:9090/signal-api
|
||||||
|
|
||||||
|
# 항적 조회 API (별도 서버)
|
||||||
|
VITE_TRACK_API=http://10.26.252.51:8090
|
||||||
|
|
||||||
|
# 항적 조회 WebSocket (STOMP)
|
||||||
|
VITE_TRACKING_WS=ws://10.26.252.51:8090/ws-tracks/websocket
|
||||||
|
|
||||||
|
# 선박 데이터 쓰로틀링 (ms, 0=무제한)
|
||||||
|
VITE_SHIP_THROTTLE=0
|
||||||
29
.env.qa
Normal file
29
.env.qa
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# ============================================
|
||||||
|
# QA 환경 (Quality Assurance)
|
||||||
|
# - 빌드: npm run build:qa
|
||||||
|
# - QA/스테이징 서버 배포용
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# 배포 경로 (QA 환경 서브 경로)
|
||||||
|
VITE_BASE_URL=/kcgv/
|
||||||
|
|
||||||
|
# API 서버 (QA 서버)
|
||||||
|
VITE_API_URL=http://10.188.141.123:9090
|
||||||
|
|
||||||
|
# 지도 타일 서버
|
||||||
|
VITE_MAP_TILE_URL=http://10.188.141.123:9090
|
||||||
|
|
||||||
|
# 선박 신호 WebSocket (프로덕션 서버 사용)
|
||||||
|
VITE_SIGNAL_WS=wss://mda.kcg.go.kr/v3/connect
|
||||||
|
|
||||||
|
# 선박 신호 API (프로덕션 서버 사용)
|
||||||
|
VITE_SIGNAL_API=https://mda.kcg.go.kr/signal-api
|
||||||
|
|
||||||
|
# 항적 조회 API (QA 서버)
|
||||||
|
VITE_TRACK_API=http://10.188.141.123:9090
|
||||||
|
|
||||||
|
# 항적 조회 WebSocket (QA 서버)
|
||||||
|
VITE_TRACKING_WS=ws://10.188.141.123:9090/ws-tracks/websocket
|
||||||
|
|
||||||
|
# 선박 데이터 쓰로틀링 (ms)
|
||||||
|
VITE_SHIP_THROTTLE=30
|
||||||
383
src/areaSearch/components/AreaSearchPage.jsx
Normal file
383
src/areaSearch/components/AreaSearchPage.jsx
Normal file
@ -0,0 +1,383 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import './AreaSearchPage.scss';
|
||||||
|
import { useAreaSearchStore } from '../stores/areaSearchStore';
|
||||||
|
import { useAreaSearchAnimationStore } from '../stores/areaSearchAnimationStore';
|
||||||
|
import { fetchAreaSearch } from '../services/areaSearchApi';
|
||||||
|
import {
|
||||||
|
SEARCH_MODES,
|
||||||
|
SEARCH_MODE_LABELS,
|
||||||
|
QUERY_MAX_DAYS,
|
||||||
|
getQueryDateRange,
|
||||||
|
} from '../types/areaSearch.types';
|
||||||
|
import { getShipKindColor, getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types';
|
||||||
|
import { showToast } from '../../components/common/Toast';
|
||||||
|
import { hideLiveShips, showLiveShips } from '../../utils/liveControl';
|
||||||
|
import ZoneDrawPanel from './ZoneDrawPanel';
|
||||||
|
import { exportSearchResultToCSV } from '../utils/csvExport';
|
||||||
|
|
||||||
|
const DAYS_TO_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function toKstISOString(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())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AreaSearchPage({ isOpen, onToggle }) {
|
||||||
|
const [startDate, setStartDate] = useState('');
|
||||||
|
const [startTime, setStartTime] = useState('00:00');
|
||||||
|
const [endDate, setEndDate] = useState('');
|
||||||
|
const [endTime, setEndTime] = useState('23:59');
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
|
||||||
|
const zones = useAreaSearchStore((s) => s.zones);
|
||||||
|
const searchMode = useAreaSearchStore((s) => s.searchMode);
|
||||||
|
const tracks = useAreaSearchStore((s) => s.tracks);
|
||||||
|
const hitDetails = useAreaSearchStore((s) => s.hitDetails);
|
||||||
|
const summary = useAreaSearchStore((s) => s.summary);
|
||||||
|
const isLoading = useAreaSearchStore((s) => s.isLoading);
|
||||||
|
const queryCompleted = useAreaSearchStore((s) => s.queryCompleted);
|
||||||
|
const disabledVesselIds = useAreaSearchStore((s) => s.disabledVesselIds);
|
||||||
|
const highlightedVesselId = useAreaSearchStore((s) => s.highlightedVesselId);
|
||||||
|
|
||||||
|
const setSearchMode = useAreaSearchStore((s) => s.setSearchMode);
|
||||||
|
|
||||||
|
const setTimeRange = useAreaSearchAnimationStore((s) => s.setTimeRange);
|
||||||
|
|
||||||
|
// 기간 초기화 (D-7 ~ D-1)
|
||||||
|
useEffect(() => {
|
||||||
|
const { startDate: sDate, endDate: eDate } = getQueryDateRange();
|
||||||
|
setStartDate(sDate.toISOString().split('T')[0]);
|
||||||
|
setStartTime('00:00');
|
||||||
|
setEndDate(eDate.toISOString().split('T')[0]);
|
||||||
|
setEndTime('23:59');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 패널 닫힘 시 정리
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
const { queryCompleted: completed } = useAreaSearchStore.getState();
|
||||||
|
if (completed) {
|
||||||
|
useAreaSearchStore.getState().reset();
|
||||||
|
useAreaSearchAnimationStore.getState().reset();
|
||||||
|
showLiveShips();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleStartDateChange = useCallback((newStartDate) => {
|
||||||
|
setStartDate(newStartDate);
|
||||||
|
const start = new Date(`${newStartDate}T${startTime}:00`);
|
||||||
|
const end = new Date(`${endDate}T${endTime}:00`);
|
||||||
|
const diffDays = (end - start) / DAYS_TO_MS;
|
||||||
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
|
|
||||||
|
if (diffDays < 0) {
|
||||||
|
const adjusted = new Date(start.getTime() + QUERY_MAX_DAYS * DAYS_TO_MS);
|
||||||
|
setEndDate(adjusted.toISOString().split('T')[0]);
|
||||||
|
setEndTime(`${pad(adjusted.getHours())}:${pad(adjusted.getMinutes())}`);
|
||||||
|
showToast('종료일이 시작일보다 앞서 자동 조정되었습니다.');
|
||||||
|
} else if (diffDays > QUERY_MAX_DAYS) {
|
||||||
|
const adjusted = new Date(start.getTime() + QUERY_MAX_DAYS * DAYS_TO_MS);
|
||||||
|
setEndDate(adjusted.toISOString().split('T')[0]);
|
||||||
|
setEndTime(`${pad(adjusted.getHours())}:${pad(adjusted.getMinutes())}`);
|
||||||
|
showToast(`최대 조회기간 ${QUERY_MAX_DAYS}일로 자동 설정됩니다.`);
|
||||||
|
}
|
||||||
|
}, [startTime, endDate, endTime]);
|
||||||
|
|
||||||
|
const handleEndDateChange = useCallback((newEndDate) => {
|
||||||
|
setEndDate(newEndDate);
|
||||||
|
const start = new Date(`${startDate}T${startTime}:00`);
|
||||||
|
const end = new Date(`${newEndDate}T${endTime}:00`);
|
||||||
|
const diffDays = (end - start) / DAYS_TO_MS;
|
||||||
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
|
|
||||||
|
if (diffDays < 0) {
|
||||||
|
const adjusted = new Date(end.getTime() - QUERY_MAX_DAYS * DAYS_TO_MS);
|
||||||
|
setStartDate(adjusted.toISOString().split('T')[0]);
|
||||||
|
setStartTime(`${pad(adjusted.getHours())}:${pad(adjusted.getMinutes())}`);
|
||||||
|
showToast('시작일이 종료일보다 뒤서 자동 조정되었습니다.');
|
||||||
|
} else if (diffDays > QUERY_MAX_DAYS) {
|
||||||
|
const adjusted = new Date(end.getTime() - QUERY_MAX_DAYS * DAYS_TO_MS);
|
||||||
|
setStartDate(adjusted.toISOString().split('T')[0]);
|
||||||
|
setStartTime(`${pad(adjusted.getHours())}:${pad(adjusted.getMinutes())}`);
|
||||||
|
showToast(`최대 조회기간 ${QUERY_MAX_DAYS}일로 자동 설정됩니다.`);
|
||||||
|
}
|
||||||
|
}, [startDate, startTime, endTime]);
|
||||||
|
|
||||||
|
const executeQuery = useCallback(async () => {
|
||||||
|
const from = new Date(`${startDate}T${startTime}:00`);
|
||||||
|
const to = new Date(`${endDate}T${endTime}:00`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setErrorMessage('');
|
||||||
|
useAreaSearchStore.getState().setLoading(true);
|
||||||
|
|
||||||
|
const polygons = zones.map((z) => ({
|
||||||
|
id: z.id,
|
||||||
|
name: z.name,
|
||||||
|
coordinates: z.coordinates,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await fetchAreaSearch({
|
||||||
|
startTime: toKstISOString(from),
|
||||||
|
endTime: toKstISOString(to),
|
||||||
|
mode: searchMode,
|
||||||
|
polygons,
|
||||||
|
});
|
||||||
|
|
||||||
|
useAreaSearchStore.getState().setTracks(result.tracks);
|
||||||
|
useAreaSearchStore.getState().setHitDetails(result.hitDetails);
|
||||||
|
useAreaSearchStore.getState().setSummary(result.summary);
|
||||||
|
|
||||||
|
if (result.tracks.length > 0) {
|
||||||
|
let minTime = Infinity;
|
||||||
|
let maxTime = -Infinity;
|
||||||
|
result.tracks.forEach((t) => {
|
||||||
|
if (t.timestampsMs.length > 0) {
|
||||||
|
minTime = Math.min(minTime, t.timestampsMs[0]);
|
||||||
|
maxTime = Math.max(maxTime, t.timestampsMs[t.timestampsMs.length - 1]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setTimeRange(minTime, maxTime);
|
||||||
|
hideLiveShips();
|
||||||
|
}
|
||||||
|
|
||||||
|
useAreaSearchStore.getState().setLoading(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AreaSearch] 조회 실패:', error);
|
||||||
|
useAreaSearchStore.getState().setLoading(false);
|
||||||
|
setErrorMessage(`조회 실패: ${error.message}`);
|
||||||
|
}
|
||||||
|
}, [startDate, startTime, endDate, endTime, zones, searchMode, setTimeRange]);
|
||||||
|
|
||||||
|
const handleQuery = useCallback(async () => {
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
showToast('조회 기간을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (zones.length === 0) {
|
||||||
|
showToast('구역을 1개 이상 설정해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = new Date(`${startDate}T${startTime}:00`);
|
||||||
|
const to = new Date(`${endDate}T${endTime}:00`);
|
||||||
|
|
||||||
|
if (isNaN(from.getTime()) || isNaN(to.getTime())) {
|
||||||
|
showToast('올바른 날짜/시간을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (from >= to) {
|
||||||
|
showToast('종료 시간은 시작 시간보다 이후여야 합니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 조회 결과가 있으면 초기화 확인
|
||||||
|
const { queryCompleted: hasExisting } = useAreaSearchStore.getState();
|
||||||
|
if (hasExisting) {
|
||||||
|
const confirmed = window.confirm('이전 조회 정보가 초기화됩니다.\n새로운 조건으로 다시 조회하시겠습니까?');
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
// 기존 결과 즉시 클리어 (queryCompleted: false → 레이어 해제 + 타임라인 숨김)
|
||||||
|
useAreaSearchStore.getState().clearResults();
|
||||||
|
useAreaSearchAnimationStore.getState().reset();
|
||||||
|
// showLiveShips() 호출하지 않음 - 라이브 비활성 유지
|
||||||
|
}
|
||||||
|
|
||||||
|
executeQuery();
|
||||||
|
}, [startDate, startTime, endDate, endTime, zones, searchMode, executeQuery]);
|
||||||
|
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
useAreaSearchStore.getState().reset();
|
||||||
|
useAreaSearchAnimationStore.getState().reset();
|
||||||
|
showLiveShips();
|
||||||
|
setErrorMessage('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleVessel = useCallback((vesselId) => {
|
||||||
|
useAreaSearchStore.getState().toggleVesselEnabled(vesselId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleHighlightVessel = useCallback((vesselId) => {
|
||||||
|
useAreaSearchStore.getState().setHighlightedVesselId(vesselId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleExportCSV = useCallback(() => {
|
||||||
|
exportSearchResultToCSV(tracks, hitDetails, zones);
|
||||||
|
}, [tracks, hitDetails, zones]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className={`slidePanel area-search-panel ${isOpen ? '' : 'is-closed'}`}>
|
||||||
|
<button type="button" className="toogle" onClick={onToggle} aria-label="패널 토글">
|
||||||
|
<span className="blind">패널 토글</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="panelHeader">
|
||||||
|
<h2 className="panelTitle">항적 분석</h2>
|
||||||
|
{queryCompleted && (
|
||||||
|
<button type="button" className="btn-reset" onClick={handleReset}>초기화</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panelBody">
|
||||||
|
{/* 조회 기간 */}
|
||||||
|
<div className="query-section">
|
||||||
|
<h3 className="section-title">조회 기간</h3>
|
||||||
|
<p className="section-desc">D-7 ~ D-1 (인메모리 캐시 기반)</p>
|
||||||
|
|
||||||
|
<div className="query-row">
|
||||||
|
<label htmlFor="area-start-date" className="query-label">시작</label>
|
||||||
|
<div className="datetime-inputs">
|
||||||
|
<input
|
||||||
|
id="area-start-date"
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => handleStartDateChange(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="input-date"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
id="area-start-time"
|
||||||
|
type="time"
|
||||||
|
value={startTime}
|
||||||
|
onChange={(e) => setStartTime(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="input-time"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="query-row">
|
||||||
|
<label htmlFor="area-end-date" className="query-label">종료</label>
|
||||||
|
<div className="datetime-inputs">
|
||||||
|
<input
|
||||||
|
id="area-end-date"
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => handleEndDateChange(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="input-date"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
id="area-end-time"
|
||||||
|
type="time"
|
||||||
|
value={endTime}
|
||||||
|
onChange={(e) => setEndTime(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="input-time"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 구역 설정 */}
|
||||||
|
<ZoneDrawPanel disabled={isLoading} />
|
||||||
|
|
||||||
|
{/* 검색 모드 */}
|
||||||
|
<div className="mode-section">
|
||||||
|
<h3 className="section-title">검색 조건</h3>
|
||||||
|
<div className="mode-options">
|
||||||
|
{Object.entries(SEARCH_MODE_LABELS).map(([mode, label]) => (
|
||||||
|
<label key={mode} className="mode-radio">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="searchMode"
|
||||||
|
value={mode}
|
||||||
|
checked={searchMode === mode}
|
||||||
|
onChange={() => setSearchMode(mode)}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<span>{label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 조회 버튼 */}
|
||||||
|
<div className="btnBox">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-query"
|
||||||
|
onClick={handleQuery}
|
||||||
|
disabled={isLoading || zones.length === 0}
|
||||||
|
>
|
||||||
|
{isLoading ? '조회 중...' : '조회'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 결과 영역 */}
|
||||||
|
<div className="result-section">
|
||||||
|
{errorMessage && <div className="error-message">{errorMessage}</div>}
|
||||||
|
|
||||||
|
{isLoading && <div className="loading-message">데이터를 불러오는 중입니다...</div>}
|
||||||
|
|
||||||
|
{queryCompleted && tracks.length > 0 && (
|
||||||
|
<div className="result-content">
|
||||||
|
{summary && (
|
||||||
|
<div className="result-summary">
|
||||||
|
<span>검색 결과: {summary.totalVessels ?? tracks.length}척</span>
|
||||||
|
{summary.processingTimeMs != null && (
|
||||||
|
<span className="processing-time">
|
||||||
|
({(summary.processingTimeMs / 1000).toFixed(2)}초)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!summary && (
|
||||||
|
<div className="result-summary">
|
||||||
|
<span>검색 결과: {tracks.length}척</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="button" className="btn-csv" onClick={handleExportCSV}>
|
||||||
|
CSV 내보내기
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ul className="vessel-list">
|
||||||
|
{tracks.map((track) => {
|
||||||
|
const isDisabled = disabledVesselIds.has(track.vesselId);
|
||||||
|
const isHighlighted = highlightedVesselId === track.vesselId;
|
||||||
|
const color = getShipKindColor(track.shipKindCode);
|
||||||
|
const rgbStr = `rgb(${color[0]}, ${color[1]}, ${color[2]})`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={track.vesselId}
|
||||||
|
className={`vessel-item ${isDisabled ? 'disabled' : ''} ${isHighlighted ? 'highlighted' : ''}`}
|
||||||
|
onMouseEnter={() => handleHighlightVessel(track.vesselId)}
|
||||||
|
onMouseLeave={() => handleHighlightVessel(null)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="vessel-toggle"
|
||||||
|
onClick={() => handleToggleVessel(track.vesselId)}
|
||||||
|
>
|
||||||
|
<span className="vessel-color" style={{ backgroundColor: rgbStr }} />
|
||||||
|
<span className="vessel-name">
|
||||||
|
{track.shipName || track.targetId}
|
||||||
|
</span>
|
||||||
|
<span className="vessel-info">
|
||||||
|
{getShipKindName(track.shipKindCode)} / {getSignalSourceName(track.sigSrcCd)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{queryCompleted && tracks.length === 0 && !errorMessage && (
|
||||||
|
<div className="empty-message">조건에 맞는 선박이 없습니다.</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !queryCompleted && !errorMessage && (
|
||||||
|
<div className="empty-message">
|
||||||
|
구역을 설정하고 조회 버튼을 클릭하세요.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
314
src/areaSearch/components/AreaSearchPage.scss
Normal file
314
src/areaSearch/components/AreaSearchPage.scss
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
.area-search-panel {
|
||||||
|
.panelHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 2rem;
|
||||||
|
|
||||||
|
.panelTitle {
|
||||||
|
padding: 1.7rem 0;
|
||||||
|
font-size: var(--fs-ml, 1.4rem);
|
||||||
|
font-weight: var(--fw-bold, 700);
|
||||||
|
color: var(--white, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reset {
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
border: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.2));
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--tertiary4, #ccc);
|
||||||
|
font-size: var(--fs-xs, 1.1rem);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--primary1, rgba(255, 255, 255, 0.5));
|
||||||
|
color: var(--white, #fff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelBody {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0 2rem 2rem 2rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
// 조회 기간
|
||||||
|
.query-section {
|
||||||
|
background-color: var(--secondary1, rgba(255, 255, 255, 0.05));
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: var(--fs-m, 1.3rem);
|
||||||
|
font-weight: var(--fw-bold, 700);
|
||||||
|
color: var(--white, #fff);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-desc {
|
||||||
|
font-size: var(--fs-xs, 1.1rem);
|
||||||
|
color: var(--tertiary4, #999);
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
|
||||||
|
.query-label {
|
||||||
|
min-width: 5rem;
|
||||||
|
font-size: var(--fs-s, 1.2rem);
|
||||||
|
color: var(--tertiary4, #ccc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datetime-inputs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.8rem;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.input-date,
|
||||||
|
.input-time {
|
||||||
|
flex: 1;
|
||||||
|
height: 3.2rem;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
background-color: var(--tertiary1, rgba(0, 0, 0, 0.3));
|
||||||
|
border: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.2));
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
color: var(--white, #fff);
|
||||||
|
font-size: var(--fs-s, 1.2rem);
|
||||||
|
font-family: inherit;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary1, rgba(255, 255, 255, 0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-calendar-picker-indicator {
|
||||||
|
filter: invert(1);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-date {
|
||||||
|
min-width: 14rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-time {
|
||||||
|
min-width: 10rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검색 모드
|
||||||
|
.mode-section {
|
||||||
|
background-color: var(--secondary1, rgba(255, 255, 255, 0.05));
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: var(--fs-m, 1.3rem);
|
||||||
|
font-weight: var(--fw-bold, 700);
|
||||||
|
color: var(--white, #fff);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.8rem;
|
||||||
|
|
||||||
|
.mode-radio {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--fs-s, 1.2rem);
|
||||||
|
color: var(--tertiary4, #ccc);
|
||||||
|
|
||||||
|
input[type='radio'] {
|
||||||
|
accent-color: var(--primary1, #4a9eff);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has(input:checked) span {
|
||||||
|
color: var(--white, #fff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 조회 버튼
|
||||||
|
.btnBox {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
min-width: 12rem;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
font-size: var(--fs-s, 1.2rem);
|
||||||
|
font-weight: var(--fw-semibold, 600);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
&.btn-primary {
|
||||||
|
background-color: var(--primary1, #4a9eff);
|
||||||
|
color: var(--white, #fff);
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background-color: var(--primary2, #3a8eef);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background-color: var(--secondary3, #555);
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-query {
|
||||||
|
min-width: 14rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 결과 영역
|
||||||
|
.result-section {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 20rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--tertiary1, rgba(0, 0, 0, 0.2));
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
&:has(> .loading-message),
|
||||||
|
&:has(> .empty-message),
|
||||||
|
&:has(> .error-message) {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-message,
|
||||||
|
.empty-message,
|
||||||
|
.error-message {
|
||||||
|
font-size: var(--fs-m, 1.3rem);
|
||||||
|
color: var(--tertiary4, #999);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-message {
|
||||||
|
color: var(--primary1, #4a9eff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-csv {
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
border: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.2));
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--tertiary4, #ccc);
|
||||||
|
font-size: var(--fs-xs, 1.1rem);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--primary1, #4a9eff);
|
||||||
|
color: var(--white, #fff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-content {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.result-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
font-size: var(--fs-m, 1.3rem);
|
||||||
|
font-weight: var(--fw-bold, 700);
|
||||||
|
color: var(--white, #fff);
|
||||||
|
|
||||||
|
.processing-time {
|
||||||
|
font-size: var(--fs-xs, 1.1rem);
|
||||||
|
font-weight: normal;
|
||||||
|
color: var(--tertiary4, #999);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vessel-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
.vessel-item {
|
||||||
|
border-bottom: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.1));
|
||||||
|
|
||||||
|
&.highlighted {
|
||||||
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vessel-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem 0.4rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
color: inherit;
|
||||||
|
|
||||||
|
.vessel-color {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vessel-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: var(--fs-s, 1.2rem);
|
||||||
|
color: var(--white, #fff);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vessel-info {
|
||||||
|
font-size: var(--fs-xs, 1.1rem);
|
||||||
|
color: var(--tertiary4, #999);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
245
src/areaSearch/components/AreaSearchTimeline.jsx
Normal file
245
src/areaSearch/components/AreaSearchTimeline.jsx
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
/**
|
||||||
|
* 항적분석 타임라인 재생 컨트롤
|
||||||
|
* 참조: src/replay/components/ReplayTimeline.jsx (간소화)
|
||||||
|
*
|
||||||
|
* - 재생/일시정지/정지
|
||||||
|
* - 배속 조절 (1x ~ 1000x)
|
||||||
|
* - 프로그레스 바 (range slider)
|
||||||
|
* - 드래그 가능한 헤더
|
||||||
|
*/
|
||||||
|
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
|
||||||
|
import { useAreaSearchAnimationStore } from '../stores/areaSearchAnimationStore';
|
||||||
|
import { useAreaSearchStore } from '../stores/areaSearchStore';
|
||||||
|
import { unregisterAreaSearchLayers } from '../utils/areaSearchLayerRegistry';
|
||||||
|
import { showLiveShips } from '../../utils/liveControl';
|
||||||
|
import { shipBatchRenderer } from '../../map/ShipBatchRenderer';
|
||||||
|
import { PLAYBACK_SPEED_OPTIONS } from '../types/areaSearch.types';
|
||||||
|
import './AreaSearchTimeline.scss';
|
||||||
|
|
||||||
|
const PATH_LABEL = '항적';
|
||||||
|
const TRAIL_LABEL = '궤적';
|
||||||
|
|
||||||
|
function formatDateTime(ms) {
|
||||||
|
if (!ms || ms <= 0) 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 AreaSearchTimeline() {
|
||||||
|
const isPlaying = useAreaSearchAnimationStore((s) => s.isPlaying);
|
||||||
|
const currentTime = useAreaSearchAnimationStore((s) => s.currentTime);
|
||||||
|
const startTime = useAreaSearchAnimationStore((s) => s.startTime);
|
||||||
|
const endTime = useAreaSearchAnimationStore((s) => s.endTime);
|
||||||
|
const playbackSpeed = useAreaSearchAnimationStore((s) => s.playbackSpeed);
|
||||||
|
|
||||||
|
const play = useAreaSearchAnimationStore((s) => s.play);
|
||||||
|
const pause = useAreaSearchAnimationStore((s) => s.pause);
|
||||||
|
const stop = useAreaSearchAnimationStore((s) => s.stop);
|
||||||
|
const setCurrentTime = useAreaSearchAnimationStore((s) => s.setCurrentTime);
|
||||||
|
const setPlaybackSpeed = useAreaSearchAnimationStore((s) => s.setPlaybackSpeed);
|
||||||
|
|
||||||
|
const showPaths = useAreaSearchStore((s) => s.showPaths);
|
||||||
|
const showTrail = useAreaSearchStore((s) => s.showTrail);
|
||||||
|
const setShowPaths = useAreaSearchStore((s) => s.setShowPaths);
|
||||||
|
const setShowTrail = useAreaSearchStore((s) => s.setShowTrail);
|
||||||
|
|
||||||
|
const progress = useMemo(() => {
|
||||||
|
if (endTime <= startTime || startTime <= 0) return 0;
|
||||||
|
return ((currentTime - startTime) / (endTime - startTime)) * 100;
|
||||||
|
}, [currentTime, startTime, endTime]);
|
||||||
|
|
||||||
|
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
|
||||||
|
const speedMenuRef = useRef(null);
|
||||||
|
|
||||||
|
// 드래그
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [hasDragged, setHasDragged] = useState(false);
|
||||||
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||||
|
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
|
||||||
|
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 handleMouseDown = useCallback((e) => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
|
const parent = containerRef.current.parentElement;
|
||||||
|
if (!parent) return;
|
||||||
|
const parentRect = parent.getBoundingClientRect();
|
||||||
|
|
||||||
|
setDragOffset({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
||||||
|
if (!hasDragged) {
|
||||||
|
setPosition({ x: rect.left - parentRect.left, y: rect.top - parentRect.top });
|
||||||
|
setHasDragged(true);
|
||||||
|
}
|
||||||
|
setIsDragging(true);
|
||||||
|
}, [hasDragged]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseMove = (e) => {
|
||||||
|
if (!isDragging || !containerRef.current) return;
|
||||||
|
const parent = containerRef.current.parentElement;
|
||||||
|
if (!parent) return;
|
||||||
|
const parentRect = parent.getBoundingClientRect();
|
||||||
|
const containerRect = containerRef.current.getBoundingClientRect();
|
||||||
|
|
||||||
|
let newX = e.clientX - parentRect.left - dragOffset.x;
|
||||||
|
let newY = e.clientY - parentRect.top - dragOffset.y;
|
||||||
|
newX = Math.max(0, Math.min(newX, parentRect.width - containerRect.width));
|
||||||
|
newY = Math.max(0, Math.min(newY, parentRect.height - containerRect.height));
|
||||||
|
setPosition({ x: newX, y: newY });
|
||||||
|
};
|
||||||
|
const handleMouseUp = () => setIsDragging(false);
|
||||||
|
|
||||||
|
if (isDragging) {
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [isDragging, dragOffset]);
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
setCurrentTime(parseFloat(e.target.value));
|
||||||
|
}, [setCurrentTime]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
useAreaSearchStore.getState().reset();
|
||||||
|
useAreaSearchAnimationStore.getState().reset();
|
||||||
|
unregisterAreaSearchLayers();
|
||||||
|
showLiveShips();
|
||||||
|
shipBatchRenderer.immediateRender();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasData = endTime > startTime && startTime > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`area-search-timeline ${isPlaying ? 'playing' : ''} ${isDragging ? 'dragging' : ''}`}
|
||||||
|
style={hasDragged ? {
|
||||||
|
left: `${position.x}px`,
|
||||||
|
top: `${position.y}px`,
|
||||||
|
bottom: 'auto',
|
||||||
|
transform: 'none',
|
||||||
|
} : undefined}
|
||||||
|
>
|
||||||
|
<div className="timeline-header" onMouseDown={handleMouseDown}>
|
||||||
|
<div className="header-content">
|
||||||
|
<span className="header-title">항적 분석</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="header-close-btn" onClick={handleClose} title="닫기">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 ? '\u275A\u275A' : '\u25B6'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="control-btn stop-btn"
|
||||||
|
onClick={handleStop}
|
||||||
|
disabled={!hasData}
|
||||||
|
title="정지"
|
||||||
|
>
|
||||||
|
■
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="timeline-slider-container">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="timeline-slider"
|
||||||
|
min={startTime}
|
||||||
|
max={endTime}
|
||||||
|
step={(endTime - startTime) / 1000 || 1}
|
||||||
|
value={currentTime}
|
||||||
|
onChange={handleSliderChange}
|
||||||
|
disabled={!hasData}
|
||||||
|
style={{ '--progress': `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="current-time-display">
|
||||||
|
{hasData ? formatDateTime(currentTime) : '--:--:--'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<label className="filter-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showPaths}
|
||||||
|
onChange={() => setShowPaths(!showPaths)}
|
||||||
|
disabled={!hasData}
|
||||||
|
/>
|
||||||
|
<span>{PATH_LABEL}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="filter-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showTrail}
|
||||||
|
onChange={() => setShowTrail(!showTrail)}
|
||||||
|
disabled={!hasData}
|
||||||
|
/>
|
||||||
|
<span>{TRAIL_LABEL}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
362
src/areaSearch/components/AreaSearchTimeline.scss
Normal file
362
src/areaSearch/components/AreaSearchTimeline.scss
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
.area-search-timeline {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 40px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 100;
|
||||||
|
|
||||||
|
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: area-search-pulse 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 26px;
|
||||||
|
background: linear-gradient(135deg, rgba(255, 152, 0, 0.3), rgba(255, 183, 77, 0.2));
|
||||||
|
border-bottom: 1px solid rgba(255, 152, 0, 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-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 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-selector {
|
||||||
|
position: relative;
|
||||||
|
z-index: 100;
|
||||||
|
|
||||||
|
.speed-btn {
|
||||||
|
background: rgba(255, 152, 0, 0.2);
|
||||||
|
border: 1px solid rgba(255, 152, 0, 0.4);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #ffb74d;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 5px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
min-width: 50px;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: rgba(255, 152, 0, 0.3);
|
||||||
|
border-color: #ffb74d;
|
||||||
|
}
|
||||||
|
|
||||||
|
&: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(255, 152, 0, 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(255, 152, 0, 0.3);
|
||||||
|
border-color: rgba(255, 152, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: rgba(255, 152, 0, 0.5);
|
||||||
|
border-color: #ffb74d;
|
||||||
|
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;
|
||||||
|
padding: 0 7px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 7px;
|
||||||
|
right: 7px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
height: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-slider {
|
||||||
|
--progress: 0%;
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
&::-webkit-slider-runnable-track {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
#ffb74d 0%,
|
||||||
|
#ff9800 var(--progress),
|
||||||
|
transparent var(--progress),
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 2px solid #ff9800;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: grab;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
margin-top: -4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-moz-range-track {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
#ffb74d 0%,
|
||||||
|
#ff9800 var(--progress),
|
||||||
|
transparent var(--progress),
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-moz-range-thumb {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 2px solid #ff9800;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-time-display {
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #ffb74d;
|
||||||
|
min-width: 130px;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
input[type='checkbox'] {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: #ff9800;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has(input:not(:checked)) {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has(input:checked) {
|
||||||
|
background: rgba(255, 152, 0, 0.2);
|
||||||
|
border-color: rgba(255, 152, 0, 0.6);
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: #ff9800;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes area-search-pulse {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
115
src/areaSearch/components/AreaSearchTooltip.jsx
Normal file
115
src/areaSearch/components/AreaSearchTooltip.jsx
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* 항적분석 호버 툴팁 컴포넌트
|
||||||
|
* - 선박 기본 정보 (선종, 선명, 신호원)
|
||||||
|
* - 구역별 진입/진출 시간 및 위치
|
||||||
|
*/
|
||||||
|
import { useAreaSearchStore } from '../stores/areaSearchStore';
|
||||||
|
import { ZONE_COLORS } from '../types/areaSearch.types';
|
||||||
|
import { getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types';
|
||||||
|
import './AreaSearchTooltip.scss';
|
||||||
|
|
||||||
|
const OFFSET_X = 14;
|
||||||
|
const OFFSET_Y = -20;
|
||||||
|
|
||||||
|
/** nationalCode → 국기 SVG URL */
|
||||||
|
function getNationalFlagUrl(nationalCode) {
|
||||||
|
if (!nationalCode) return null;
|
||||||
|
return `/ship/image/small/${nationalCode}.svg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(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())}:${pad(d.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPosition(pos) {
|
||||||
|
if (!pos || pos.length < 2) return null;
|
||||||
|
const lon = pos[0];
|
||||||
|
const lat = pos[1];
|
||||||
|
const latDir = lat >= 0 ? 'N' : 'S';
|
||||||
|
const lonDir = lon >= 0 ? 'E' : 'W';
|
||||||
|
return `${Math.abs(lat).toFixed(4)}\u00B0${latDir} ${Math.abs(lon).toFixed(4)}\u00B0${lonDir}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AreaSearchTooltip() {
|
||||||
|
const tooltip = useAreaSearchStore((s) => s.areaSearchTooltip);
|
||||||
|
const tracks = useAreaSearchStore((s) => s.tracks);
|
||||||
|
const hitDetails = useAreaSearchStore((s) => s.hitDetails);
|
||||||
|
const zones = useAreaSearchStore((s) => s.zones);
|
||||||
|
|
||||||
|
if (!tooltip) return null;
|
||||||
|
|
||||||
|
const { vesselId, x, y } = tooltip;
|
||||||
|
const track = tracks.find((t) => t.vesselId === vesselId);
|
||||||
|
if (!track) return null;
|
||||||
|
|
||||||
|
const hits = hitDetails[vesselId] || [];
|
||||||
|
const kindName = getShipKindName(track.shipKindCode);
|
||||||
|
const sourceName = getSignalSourceName(track.sigSrcCd);
|
||||||
|
const flagUrl = getNationalFlagUrl(track.nationalCode);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="area-search-tooltip"
|
||||||
|
style={{ left: x + OFFSET_X, top: y + OFFSET_Y }}
|
||||||
|
>
|
||||||
|
<div className="area-search-tooltip__header">
|
||||||
|
<span className="area-search-tooltip__kind">{kindName}</span>
|
||||||
|
{flagUrl && (
|
||||||
|
<span className="area-search-tooltip__flag">
|
||||||
|
<img
|
||||||
|
src={flagUrl}
|
||||||
|
alt="국기"
|
||||||
|
onError={(e) => { e.target.style.display = 'none'; }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="area-search-tooltip__name">
|
||||||
|
{track.shipName || track.targetId || '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="area-search-tooltip__info">
|
||||||
|
<span>{sourceName}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{zones.length > 0 && hits.length > 0 && (
|
||||||
|
<div className="area-search-tooltip__zones">
|
||||||
|
{zones.map((zone) => {
|
||||||
|
const hit = hits.find((h) => h.polygonId === zone.id);
|
||||||
|
if (!hit) return null;
|
||||||
|
const zoneColor = ZONE_COLORS[zone.colorIndex]?.label || '#ffd43b';
|
||||||
|
const entryPos = formatPosition(hit.entryPosition);
|
||||||
|
const exitPos = formatPosition(hit.exitPosition);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={zone.id} className="area-search-tooltip__zone">
|
||||||
|
<span
|
||||||
|
className="area-search-tooltip__zone-name"
|
||||||
|
style={{ color: zoneColor }}
|
||||||
|
>
|
||||||
|
{zone.name}
|
||||||
|
</span>
|
||||||
|
<div className="area-search-tooltip__zone-row">
|
||||||
|
<span className="area-search-tooltip__zone-label">IN</span>
|
||||||
|
<span>{formatTimestamp(hit.entryTimestamp)}</span>
|
||||||
|
{entryPos && (
|
||||||
|
<span className="area-search-tooltip__pos">{entryPos}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="area-search-tooltip__zone-row">
|
||||||
|
<span className="area-search-tooltip__zone-label">OUT</span>
|
||||||
|
<span>{formatTimestamp(hit.exitTimestamp)}</span>
|
||||||
|
{exitPos && (
|
||||||
|
<span className="area-search-tooltip__pos">{exitPos}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
src/areaSearch/components/AreaSearchTooltip.scss
Normal file
101
src/areaSearch/components/AreaSearchTooltip.scss
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
.area-search-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 200;
|
||||||
|
pointer-events: none;
|
||||||
|
background: rgba(20, 24, 32, 0.95);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
min-width: 180px;
|
||||||
|
max-width: 340px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__kind {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 5px;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #adb5bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__flag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 16px;
|
||||||
|
height: 12px;
|
||||||
|
object-fit: contain;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: #ced4da;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sep {
|
||||||
|
color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__zones {
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 5px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__zone {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__zone-name {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 11px;
|
||||||
|
margin-bottom: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__zone-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
color: #ced4da;
|
||||||
|
font-size: 11px;
|
||||||
|
padding-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__zone-label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 9px;
|
||||||
|
color: #868e96;
|
||||||
|
min-width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__pos {
|
||||||
|
color: #74b9ff;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
135
src/areaSearch/components/ZoneDrawPanel.jsx
Normal file
135
src/areaSearch/components/ZoneDrawPanel.jsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import './ZoneDrawPanel.scss';
|
||||||
|
import { useAreaSearchStore } from '../stores/areaSearchStore';
|
||||||
|
import {
|
||||||
|
MAX_ZONES,
|
||||||
|
ZONE_DRAW_TYPES,
|
||||||
|
ZONE_COLORS,
|
||||||
|
} from '../types/areaSearch.types';
|
||||||
|
|
||||||
|
export default function ZoneDrawPanel({ disabled }) {
|
||||||
|
const zones = useAreaSearchStore((s) => s.zones);
|
||||||
|
const activeDrawType = useAreaSearchStore((s) => s.activeDrawType);
|
||||||
|
const setActiveDrawType = useAreaSearchStore((s) => s.setActiveDrawType);
|
||||||
|
const removeZone = useAreaSearchStore((s) => s.removeZone);
|
||||||
|
const reorderZones = useAreaSearchStore((s) => s.reorderZones);
|
||||||
|
|
||||||
|
const canAddZone = zones.length < MAX_ZONES;
|
||||||
|
|
||||||
|
const handleDrawClick = useCallback((type) => {
|
||||||
|
if (!canAddZone || disabled) return;
|
||||||
|
setActiveDrawType(activeDrawType === type ? null : type);
|
||||||
|
}, [canAddZone, disabled, activeDrawType, setActiveDrawType]);
|
||||||
|
|
||||||
|
// 드래그 순서 변경 (ref 기반 - dataTransfer보다 안정적)
|
||||||
|
const dragIndexRef = useRef(null);
|
||||||
|
const [dragOverIndex, setDragOverIndex] = useState(null);
|
||||||
|
|
||||||
|
const handleDragStart = useCallback((e, index) => {
|
||||||
|
dragIndexRef.current = index;
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', '');
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
e.target.classList.add('dragging');
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e, index) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
if (dragIndexRef.current !== null && dragIndexRef.current !== index) {
|
||||||
|
setDragOverIndex(index);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e, toIndex) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const fromIndex = dragIndexRef.current;
|
||||||
|
if (fromIndex !== null && fromIndex !== toIndex) {
|
||||||
|
reorderZones(fromIndex, toIndex);
|
||||||
|
}
|
||||||
|
dragIndexRef.current = null;
|
||||||
|
setDragOverIndex(null);
|
||||||
|
}, [reorderZones]);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(() => {
|
||||||
|
dragIndexRef.current = null;
|
||||||
|
setDragOverIndex(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="zone-draw-panel">
|
||||||
|
<h3 className="section-title">구역 설정</h3>
|
||||||
|
<p className="section-desc">{zones.length}/{MAX_ZONES}개 설정됨</p>
|
||||||
|
|
||||||
|
{/* 그리기 버튼 */}
|
||||||
|
<div className="draw-buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`draw-btn ${activeDrawType === ZONE_DRAW_TYPES.POLYGON ? 'active' : ''}`}
|
||||||
|
onClick={() => handleDrawClick(ZONE_DRAW_TYPES.POLYGON)}
|
||||||
|
disabled={!canAddZone || disabled}
|
||||||
|
title="폴리곤"
|
||||||
|
>
|
||||||
|
폴리곤
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`draw-btn ${activeDrawType === ZONE_DRAW_TYPES.BOX ? 'active' : ''}`}
|
||||||
|
onClick={() => handleDrawClick(ZONE_DRAW_TYPES.BOX)}
|
||||||
|
disabled={!canAddZone || disabled}
|
||||||
|
title="사각형"
|
||||||
|
>
|
||||||
|
사각형
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`draw-btn ${activeDrawType === ZONE_DRAW_TYPES.CIRCLE ? 'active' : ''}`}
|
||||||
|
onClick={() => handleDrawClick(ZONE_DRAW_TYPES.CIRCLE)}
|
||||||
|
disabled={!canAddZone || disabled}
|
||||||
|
title="원"
|
||||||
|
>
|
||||||
|
원
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeDrawType && (
|
||||||
|
<p className="draw-hint">지도에서 구역을 그려주세요. (ESC: 취소)</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 구역 목록 */}
|
||||||
|
{zones.length > 0 && (
|
||||||
|
<ul className="zone-list">
|
||||||
|
{zones.map((zone, index) => {
|
||||||
|
const color = ZONE_COLORS[zone.colorIndex] || ZONE_COLORS[0];
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={zone.id}
|
||||||
|
className={`zone-item${dragIndexRef.current === index ? ' dragging' : ''}${dragOverIndex === index ? ' drag-over' : ''}`}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleDragStart(e, index)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, index)}
|
||||||
|
onDrop={(e) => handleDrop(e, index)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<span className="drag-handle" title="드래그하여 순서 변경">≡</span>
|
||||||
|
<span className="zone-color" style={{ backgroundColor: color.label }} />
|
||||||
|
<span className="zone-name">구역 {zone.name}</span>
|
||||||
|
<span className="zone-type">{zone.type}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="zone-delete"
|
||||||
|
onClick={() => removeZone(zone.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
title="구역 삭제"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
src/areaSearch/components/ZoneDrawPanel.scss
Normal file
136
src/areaSearch/components/ZoneDrawPanel.scss
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
.zone-draw-panel {
|
||||||
|
background-color: var(--secondary1, rgba(255, 255, 255, 0.05));
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: var(--fs-m, 1.3rem);
|
||||||
|
font-weight: var(--fw-bold, 700);
|
||||||
|
color: var(--white, #fff);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-desc {
|
||||||
|
font-size: var(--fs-xs, 1.1rem);
|
||||||
|
color: var(--tertiary4, #999);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
.draw-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.7rem 0;
|
||||||
|
border: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.2));
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--tertiary4, #ccc);
|
||||||
|
font-size: var(--fs-s, 1.2rem);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: var(--primary1, #4a9eff);
|
||||||
|
color: var(--white, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color: var(--primary1, #4a9eff);
|
||||||
|
border-color: var(--primary1, #4a9eff);
|
||||||
|
color: var(--white, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-hint {
|
||||||
|
font-size: var(--fs-xs, 1.1rem);
|
||||||
|
color: var(--primary1, #4a9eff);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
.zone-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.8rem 0.4rem;
|
||||||
|
border-bottom: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.1));
|
||||||
|
cursor: grab;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.drag-over {
|
||||||
|
border-top: 2px solid var(--primary1, #4a9eff);
|
||||||
|
padding-top: calc(0.8rem - 2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
color: var(--tertiary4, #999);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-color {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-name {
|
||||||
|
font-size: var(--fs-s, 1.2rem);
|
||||||
|
color: var(--white, #fff);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-type {
|
||||||
|
font-size: var(--fs-xs, 1.1rem);
|
||||||
|
color: var(--tertiary4, #999);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-delete {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--tertiary4, #999);
|
||||||
|
font-size: 1.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 0.4rem;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
205
src/areaSearch/hooks/useAreaSearchLayer.js
Normal file
205
src/areaSearch/hooks/useAreaSearchLayer.js
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
/**
|
||||||
|
* 항적분석 Deck.gl 레이어 관리 훅
|
||||||
|
* 구조: 리플레이(useReplayLayer) 패턴 적용
|
||||||
|
*
|
||||||
|
* - React hook으로 currentTime 구독 → 매 프레임 리렌더
|
||||||
|
* - immediateRender()로 즉시 반영
|
||||||
|
* - TripsLayer GPU 기반 궤적 표시
|
||||||
|
* - 정적(PathLayer) / 동적(IconLayer, TextLayer) 레이어 분리
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { TripsLayer } from '@deck.gl/geo-layers';
|
||||||
|
import { useAreaSearchStore } from '../stores/areaSearchStore';
|
||||||
|
import { useAreaSearchAnimationStore } from '../stores/areaSearchAnimationStore';
|
||||||
|
import { AREA_SEARCH_LAYER_IDS } from '../types/areaSearch.types';
|
||||||
|
import {
|
||||||
|
registerAreaSearchLayers,
|
||||||
|
unregisterAreaSearchLayers,
|
||||||
|
} from '../utils/areaSearchLayerRegistry';
|
||||||
|
import { createStaticTrackLayers, createVirtualShipLayers } from '../../map/layers/trackLayer';
|
||||||
|
import { shipBatchRenderer } from '../../map/ShipBatchRenderer';
|
||||||
|
import { SIGNAL_KIND_CODE_NORMAL } from '../../types/constants';
|
||||||
|
|
||||||
|
const TRAIL_LENGTH_MS = 3600000; // 궤적 길이 1시간
|
||||||
|
|
||||||
|
export default function useAreaSearchLayer() {
|
||||||
|
const tripsDataRef = useRef([]);
|
||||||
|
const startTimeRef = useRef(0);
|
||||||
|
|
||||||
|
// React hook 구독 (매 프레임 리렌더)
|
||||||
|
const queryCompleted = useAreaSearchStore((s) => s.queryCompleted);
|
||||||
|
const tracks = useAreaSearchStore((s) => s.tracks);
|
||||||
|
const disabledVesselIds = useAreaSearchStore((s) => s.disabledVesselIds);
|
||||||
|
const highlightedVesselId = useAreaSearchStore((s) => s.highlightedVesselId);
|
||||||
|
const showPaths = useAreaSearchStore((s) => s.showPaths);
|
||||||
|
const showTrail = useAreaSearchStore((s) => s.showTrail);
|
||||||
|
const shipKindCodeFilter = useAreaSearchStore((s) => s.shipKindCodeFilter);
|
||||||
|
|
||||||
|
const currentTime = useAreaSearchAnimationStore((s) => s.currentTime);
|
||||||
|
const startTime = useAreaSearchAnimationStore((s) => s.startTime);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 트랙 필터링 (선종 + 개별 비활성화)
|
||||||
|
*/
|
||||||
|
const getFilteredTracks = useCallback(() => {
|
||||||
|
return tracks.filter((t) =>
|
||||||
|
!disabledVesselIds.has(t.vesselId) && shipKindCodeFilter.has(t.shipKindCode),
|
||||||
|
);
|
||||||
|
}, [tracks, disabledVesselIds, shipKindCodeFilter]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 위치 필터링 (선종 필터 적용)
|
||||||
|
*/
|
||||||
|
const getFilteredPositions = useCallback((positions) => {
|
||||||
|
return positions.filter((p) => shipKindCodeFilter.has(p.shipKindCode));
|
||||||
|
}, [shipKindCodeFilter]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TripsLayer 데이터 빌드 (queryCompleted 후 1회)
|
||||||
|
*/
|
||||||
|
const buildTripsData = useCallback(() => {
|
||||||
|
if (tracks.length === 0) {
|
||||||
|
tripsDataRef.current = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sTime = useAreaSearchAnimationStore.getState().startTime;
|
||||||
|
startTimeRef.current = sTime;
|
||||||
|
|
||||||
|
tripsDataRef.current = tracks
|
||||||
|
.filter((t) => t.geometry.length >= 2)
|
||||||
|
.map((track) => ({
|
||||||
|
vesselId: track.vesselId,
|
||||||
|
shipKindCode: track.shipKindCode,
|
||||||
|
path: track.geometry,
|
||||||
|
timestamps: track.timestampsMs.map((t) => t - sTime),
|
||||||
|
}));
|
||||||
|
}, [tracks]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이어 렌더링 (리플레이 requestAnimatedRender 패턴)
|
||||||
|
*/
|
||||||
|
const requestAnimatedRender = useCallback(() => {
|
||||||
|
if (!queryCompleted || tracks.length === 0) return;
|
||||||
|
|
||||||
|
// 현재 위치 계산
|
||||||
|
const allPositions = useAreaSearchStore.getState().getCurrentPositions(currentTime);
|
||||||
|
const filteredPositions = getFilteredPositions(allPositions);
|
||||||
|
|
||||||
|
// 선종별 카운트 → ShipLegend용 (replayStore 패턴)
|
||||||
|
// ShipLegend는 areaSearchStore.tracks를 직접 참조하므로 별도 저장 불필요
|
||||||
|
|
||||||
|
const layers = [];
|
||||||
|
|
||||||
|
// 1. TripsLayer 궤적 표시
|
||||||
|
if (showTrail && tripsDataRef.current.length > 0) {
|
||||||
|
const iconVesselIds = new Set(filteredPositions.map((p) => p.vesselId));
|
||||||
|
const filteredTripsData = tripsDataRef.current.filter(
|
||||||
|
(d) => iconVesselIds.has(d.vesselId),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (filteredTripsData.length > 0) {
|
||||||
|
const relativeCurrentTime = currentTime - startTimeRef.current;
|
||||||
|
|
||||||
|
layers.push(
|
||||||
|
new TripsLayer({
|
||||||
|
id: AREA_SEARCH_LAYER_IDS.TRIPS_TRAIL,
|
||||||
|
data: filteredTripsData,
|
||||||
|
getPath: (d) => d.path,
|
||||||
|
getTimestamps: (d) => d.timestamps,
|
||||||
|
getColor: [120, 120, 120, 180],
|
||||||
|
widthMinPixels: 2,
|
||||||
|
widthMaxPixels: 3,
|
||||||
|
jointRounded: true,
|
||||||
|
capRounded: true,
|
||||||
|
fadeTrail: true,
|
||||||
|
trailLength: TRAIL_LENGTH_MS,
|
||||||
|
currentTime: relativeCurrentTime,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 정적 항적 레이어 (PathLayer)
|
||||||
|
if (showPaths) {
|
||||||
|
const filteredTracks = getFilteredTracks();
|
||||||
|
|
||||||
|
const staticLayers = createStaticTrackLayers({
|
||||||
|
tracks: filteredTracks,
|
||||||
|
showPoints: false,
|
||||||
|
highlightedVesselId,
|
||||||
|
onPathHover: (vesselId) => {
|
||||||
|
useAreaSearchStore.getState().setHighlightedVesselId(vesselId);
|
||||||
|
},
|
||||||
|
layerIds: {
|
||||||
|
path: AREA_SEARCH_LAYER_IDS.PATH,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
layers.push(...staticLayers);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 동적 가상 선박 레이어 (IconLayer + TextLayer)
|
||||||
|
const dynamicLayers = createVirtualShipLayers({
|
||||||
|
currentPositions: filteredPositions,
|
||||||
|
showVirtualShip: filteredPositions.length > 0,
|
||||||
|
showLabels: filteredPositions.length > 0,
|
||||||
|
onIconHover: (shipData, x, y) => {
|
||||||
|
if (shipData) {
|
||||||
|
useAreaSearchStore.getState().setHighlightedVesselId(shipData.vesselId);
|
||||||
|
} else {
|
||||||
|
useAreaSearchStore.getState().setHighlightedVesselId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPathHover: (vesselId) => {
|
||||||
|
useAreaSearchStore.getState().setHighlightedVesselId(vesselId);
|
||||||
|
},
|
||||||
|
layerIds: {
|
||||||
|
icon: AREA_SEARCH_LAYER_IDS.VIRTUAL_SHIP,
|
||||||
|
label: AREA_SEARCH_LAYER_IDS.VIRTUAL_SHIP_LABEL,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
layers.push(...dynamicLayers);
|
||||||
|
|
||||||
|
registerAreaSearchLayers(layers);
|
||||||
|
shipBatchRenderer.immediateRender();
|
||||||
|
}, [queryCompleted, tracks, currentTime, showPaths, showTrail, highlightedVesselId, getFilteredTracks, getFilteredPositions]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 쿼리 완료 시 TripsLayer 데이터 빌드
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!queryCompleted) {
|
||||||
|
unregisterAreaSearchLayers();
|
||||||
|
tripsDataRef.current = [];
|
||||||
|
shipBatchRenderer.immediateRender();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
buildTripsData();
|
||||||
|
}, [queryCompleted, buildTripsData]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* currentTime 변경 시 애니메이션 렌더링 (매 프레임)
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!queryCompleted) return;
|
||||||
|
requestAnimatedRender();
|
||||||
|
}, [currentTime, queryCompleted, requestAnimatedRender]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필터 변경 시 재렌더링
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!queryCompleted) return;
|
||||||
|
requestAnimatedRender();
|
||||||
|
}, [showPaths, showTrail, shipKindCodeFilter, disabledVesselIds, highlightedVesselId, queryCompleted, requestAnimatedRender]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컴포넌트 언마운트 시 클린업
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
unregisterAreaSearchLayers();
|
||||||
|
tripsDataRef.current = [];
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
258
src/areaSearch/hooks/useZoneDraw.js
Normal file
258
src/areaSearch/hooks/useZoneDraw.js
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
/**
|
||||||
|
* 구역 그리기 OpenLayers Draw 인터랙션 훅
|
||||||
|
*
|
||||||
|
* - activeDrawType 변경 시 Draw 인터랙션 활성화
|
||||||
|
* - Polygon / Box / Circle 그리기
|
||||||
|
* - drawend → EPSG:3857→4326 변환 → addZone()
|
||||||
|
* - ESC 키로 그리기 취소
|
||||||
|
* - 구역별 색상 스타일 (ZONE_COLORS)
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
|
import VectorSource from 'ol/source/Vector';
|
||||||
|
import VectorLayer from 'ol/layer/Vector';
|
||||||
|
import { Draw } from 'ol/interaction';
|
||||||
|
import { createBox } from 'ol/interaction/Draw';
|
||||||
|
import { Style, Fill, Stroke } from 'ol/style';
|
||||||
|
import { transform } from 'ol/proj';
|
||||||
|
import { fromCircle } from 'ol/geom/Polygon';
|
||||||
|
import { useMapStore } from '../../stores/mapStore';
|
||||||
|
import { useAreaSearchStore } from '../stores/areaSearchStore';
|
||||||
|
import { ZONE_COLORS, ZONE_DRAW_TYPES } from '../types/areaSearch.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3857 좌표를 4326 좌표로 변환하고 폐곡선 보장
|
||||||
|
*/
|
||||||
|
function toWgs84Polygon(coords3857) {
|
||||||
|
const coords4326 = coords3857.map((c) => transform(c, 'EPSG:3857', 'EPSG:4326'));
|
||||||
|
// 폐곡선 보장 (첫점 == 끝점)
|
||||||
|
if (coords4326.length > 0) {
|
||||||
|
const first = coords4326[0];
|
||||||
|
const last = coords4326[coords4326.length - 1];
|
||||||
|
if (first[0] !== last[0] || first[1] !== last[1]) {
|
||||||
|
coords4326.push([...first]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return coords4326;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 구역 인덱스에 맞는 OL 스타일 생성
|
||||||
|
*/
|
||||||
|
function createZoneStyle(index) {
|
||||||
|
const color = ZONE_COLORS[index] || ZONE_COLORS[0];
|
||||||
|
return new Style({
|
||||||
|
fill: new Fill({ color: `rgba(${color.fill.join(',')})` }),
|
||||||
|
stroke: new Stroke({ color: `rgba(${color.stroke.join(',')})`, width: 2 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useZoneDraw() {
|
||||||
|
const map = useMapStore((s) => s.map);
|
||||||
|
const sourceRef = useRef(null);
|
||||||
|
const layerRef = useRef(null);
|
||||||
|
const drawRef = useRef(null);
|
||||||
|
const mapRef = useRef(null);
|
||||||
|
|
||||||
|
// map ref 동기화 (클린업에서 사용)
|
||||||
|
useEffect(() => {
|
||||||
|
mapRef.current = map;
|
||||||
|
}, [map]);
|
||||||
|
|
||||||
|
// 맵 준비 시 레이어 설정
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const source = new VectorSource({ wrapX: false });
|
||||||
|
const layer = new VectorLayer({
|
||||||
|
source,
|
||||||
|
zIndex: 55,
|
||||||
|
});
|
||||||
|
map.addLayer(layer);
|
||||||
|
sourceRef.current = source;
|
||||||
|
layerRef.current = layer;
|
||||||
|
|
||||||
|
// 기존 zones가 있으면 동기화
|
||||||
|
const { zones } = useAreaSearchStore.getState();
|
||||||
|
zones.forEach((zone) => {
|
||||||
|
if (!zone.olFeature) return;
|
||||||
|
zone.olFeature.setStyle(createZoneStyle(zone.colorIndex));
|
||||||
|
source.addFeature(zone.olFeature);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (drawRef.current) {
|
||||||
|
map.removeInteraction(drawRef.current);
|
||||||
|
drawRef.current = null;
|
||||||
|
}
|
||||||
|
map.removeLayer(layer);
|
||||||
|
sourceRef.current = null;
|
||||||
|
layerRef.current = null;
|
||||||
|
};
|
||||||
|
}, [map]);
|
||||||
|
|
||||||
|
// 스토어의 zones 변경 → OL feature 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = useAreaSearchStore.subscribe(
|
||||||
|
(s) => s.zones,
|
||||||
|
(zones) => {
|
||||||
|
const source = sourceRef.current;
|
||||||
|
if (!source) return;
|
||||||
|
source.clear();
|
||||||
|
|
||||||
|
zones.forEach((zone) => {
|
||||||
|
if (!zone.olFeature) return;
|
||||||
|
zone.olFeature.setStyle(createZoneStyle(zone.colorIndex));
|
||||||
|
source.addFeature(zone.olFeature);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return unsub;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// showZones 변경 → 레이어 표시/숨김
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = useAreaSearchStore.subscribe(
|
||||||
|
(s) => s.showZones,
|
||||||
|
(show) => {
|
||||||
|
if (layerRef.current) layerRef.current.setVisible(show);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return unsub;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Draw 인터랙션 생성 함수
|
||||||
|
const setupDraw = useCallback((currentMap, drawType) => {
|
||||||
|
// 기존 인터랙션 제거
|
||||||
|
if (drawRef.current) {
|
||||||
|
currentMap.removeInteraction(drawRef.current);
|
||||||
|
drawRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!drawType) return;
|
||||||
|
|
||||||
|
const source = sourceRef.current;
|
||||||
|
if (!source) return;
|
||||||
|
|
||||||
|
// source를 Draw에 전달하지 않음
|
||||||
|
// OL Draw는 drawend 이벤트 후에 source.addFeature()를 자동 호출하는데,
|
||||||
|
// 우리 drawend 핸들러의 addZone() → subscription → source.addFeature() 와 충돌하여
|
||||||
|
// "feature already added" 에러 발생. source를 제거하면 자동 추가가 비활성화됨.
|
||||||
|
let draw;
|
||||||
|
if (drawType === ZONE_DRAW_TYPES.BOX) {
|
||||||
|
draw = new Draw({ type: 'Circle', geometryFunction: createBox() });
|
||||||
|
} else if (drawType === ZONE_DRAW_TYPES.CIRCLE) {
|
||||||
|
draw = new Draw({ type: 'Circle' });
|
||||||
|
} else {
|
||||||
|
draw = new Draw({ type: 'Polygon' });
|
||||||
|
}
|
||||||
|
|
||||||
|
draw.on('drawend', (evt) => {
|
||||||
|
const feature = evt.feature;
|
||||||
|
let geom = feature.getGeometry();
|
||||||
|
const typeName = drawType;
|
||||||
|
|
||||||
|
// Circle → Polygon 변환
|
||||||
|
if (drawType === ZONE_DRAW_TYPES.CIRCLE) {
|
||||||
|
const polyGeom = fromCircle(geom, 64);
|
||||||
|
feature.setGeometry(polyGeom);
|
||||||
|
geom = polyGeom;
|
||||||
|
}
|
||||||
|
|
||||||
|
// EPSG:3857 → 4326 좌표 추출
|
||||||
|
const coords3857 = geom.getCoordinates()[0];
|
||||||
|
const coordinates = toWgs84Polygon(coords3857);
|
||||||
|
|
||||||
|
// 최소 4점 확인
|
||||||
|
if (coordinates.length < 4) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { zones } = useAreaSearchStore.getState();
|
||||||
|
const index = zones.length;
|
||||||
|
const style = createZoneStyle(index);
|
||||||
|
feature.setStyle(style);
|
||||||
|
|
||||||
|
// source에 직접 추가 (즉시 표시, Draw의 자동 추가를 대체)
|
||||||
|
source.addFeature(feature);
|
||||||
|
|
||||||
|
// 상태 업데이트를 다음 틱으로 지연
|
||||||
|
// drawend 이벤트 처리 중에 Draw를 동기적으로 제거하면,
|
||||||
|
// OL 내부 이벤트 체인이 완료되기 전에 DragPan이 이벤트를 가로채서
|
||||||
|
// 지도가 마우스를 따라 움직이는 문제가 발생함.
|
||||||
|
// setTimeout으로 OL 이벤트 처리가 완료된 후 안전하게 제거.
|
||||||
|
setTimeout(() => {
|
||||||
|
useAreaSearchStore.getState().addZone({
|
||||||
|
type: typeName,
|
||||||
|
source: 'draw',
|
||||||
|
coordinates,
|
||||||
|
olFeature: feature,
|
||||||
|
});
|
||||||
|
// addZone → activeDrawType: null → subscription → removeInteraction
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
currentMap.addInteraction(draw);
|
||||||
|
drawRef.current = draw;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// activeDrawType 변경 → Draw 인터랙션 설정
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const unsub = useAreaSearchStore.subscribe(
|
||||||
|
(s) => s.activeDrawType,
|
||||||
|
(drawType) => {
|
||||||
|
setupDraw(map, drawType);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 현재 activeDrawType이 이미 설정되어 있으면 즉시 적용
|
||||||
|
const { activeDrawType } = useAreaSearchStore.getState();
|
||||||
|
if (activeDrawType) {
|
||||||
|
setupDraw(map, activeDrawType);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsub();
|
||||||
|
// 구독 해제 시 Draw 인터랙션도 제거
|
||||||
|
if (drawRef.current && mapRef.current) {
|
||||||
|
mapRef.current.removeInteraction(drawRef.current);
|
||||||
|
drawRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [map, setupDraw]);
|
||||||
|
|
||||||
|
// ESC 키로 그리기 취소
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
const { activeDrawType } = useAreaSearchStore.getState();
|
||||||
|
if (activeDrawType) {
|
||||||
|
useAreaSearchStore.getState().setActiveDrawType(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 구역 삭제 시 OL feature도 source에서 제거 (zones 감소)
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = useAreaSearchStore.subscribe(
|
||||||
|
(s) => s.zones,
|
||||||
|
(zones, prevZones) => {
|
||||||
|
if (!prevZones || zones.length >= prevZones.length) return;
|
||||||
|
const source = sourceRef.current;
|
||||||
|
if (!source) return;
|
||||||
|
|
||||||
|
const currentIds = new Set(zones.map((z) => z.id));
|
||||||
|
prevZones.forEach((z) => {
|
||||||
|
if (!currentIds.has(z.id) && z.olFeature) {
|
||||||
|
try { source.removeFeature(z.olFeature); } catch { /* already removed */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return unsub;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
112
src/areaSearch/services/areaSearchApi.js
Normal file
112
src/areaSearch/services/areaSearchApi.js
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* 항적분석(구역 검색) REST API 서비스
|
||||||
|
*
|
||||||
|
* POST /api/v2/tracks/area-search
|
||||||
|
* 응답 변환: trackQueryApi.convertToProcessedTracks() 재사용
|
||||||
|
*/
|
||||||
|
import { convertToProcessedTracks } from '../../tracking/services/trackQueryApi';
|
||||||
|
|
||||||
|
const API_ENDPOINT = '/api/v2/tracks/area-search';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 타임스탬프 기반 위치 보간 (이진 탐색)
|
||||||
|
* track의 timestampsMs/geometry에서 targetTime 시점의 [lon, lat]을 계산
|
||||||
|
*/
|
||||||
|
function interpolatePositionAtTime(track, targetTime) {
|
||||||
|
const { timestampsMs, geometry } = track;
|
||||||
|
if (!timestampsMs || timestampsMs.length === 0 || !targetTime) return null;
|
||||||
|
|
||||||
|
const first = timestampsMs[0];
|
||||||
|
const last = timestampsMs[timestampsMs.length - 1];
|
||||||
|
if (targetTime <= first) return geometry[0];
|
||||||
|
if (targetTime >= last) return geometry[geometry.length - 1];
|
||||||
|
|
||||||
|
// 이진 탐색
|
||||||
|
let left = 0;
|
||||||
|
let right = timestampsMs.length - 1;
|
||||||
|
while (left < right) {
|
||||||
|
const mid = Math.floor((left + right) / 2);
|
||||||
|
if (timestampsMs[mid] < targetTime) left = mid + 1;
|
||||||
|
else right = mid;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx1 = Math.max(0, left - 1);
|
||||||
|
const idx2 = Math.min(timestampsMs.length - 1, left);
|
||||||
|
|
||||||
|
if (idx1 === idx2 || timestampsMs[idx1] === timestampsMs[idx2]) {
|
||||||
|
return geometry[idx1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratio = (targetTime - timestampsMs[idx1]) / (timestampsMs[idx2] - timestampsMs[idx1]);
|
||||||
|
return [
|
||||||
|
geometry[idx1][0] + (geometry[idx2][0] - geometry[idx1][0]) * ratio,
|
||||||
|
geometry[idx1][1] + (geometry[idx2][1] - geometry[idx1][1]) * ratio,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 구역 기반 항적 검색
|
||||||
|
*
|
||||||
|
* @param {Object} params
|
||||||
|
* @param {string} params.startTime ISO 8601 시작 시간
|
||||||
|
* @param {string} params.endTime ISO 8601 종료 시간
|
||||||
|
* @param {string} params.mode 'ANY' | 'ALL' | 'SEQUENTIAL'
|
||||||
|
* @param {Array<{id: string, name: string, coordinates: number[][]}>} params.polygons
|
||||||
|
* @returns {Promise<{tracks: Array, hitDetails: Object, summary: Object}>}
|
||||||
|
*/
|
||||||
|
export async function fetchAreaSearch(params) {
|
||||||
|
const request = {
|
||||||
|
startTime: params.startTime,
|
||||||
|
endTime: params.endTime,
|
||||||
|
mode: params.mode,
|
||||||
|
polygons: params.polygons,
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
const rawTracks = Array.isArray(result.tracks) ? result.tracks : [];
|
||||||
|
const tracks = convertToProcessedTracks(rawTracks);
|
||||||
|
|
||||||
|
// vesselId → track 빠른 조회용
|
||||||
|
const trackMap = new Map(tracks.map((t) => [t.vesselId, t]));
|
||||||
|
|
||||||
|
// hitDetails: timestamp 초→밀리초 변환 + 진입/진출 위치 보간
|
||||||
|
const rawHitDetails = result.hitDetails || {};
|
||||||
|
const hitDetails = {};
|
||||||
|
for (const [vesselId, hits] of Object.entries(rawHitDetails)) {
|
||||||
|
const track = trackMap.get(vesselId);
|
||||||
|
hitDetails[vesselId] = (Array.isArray(hits) ? hits : []).map((hit) => {
|
||||||
|
const toMs = (ts) => {
|
||||||
|
if (!ts) return null;
|
||||||
|
const num = typeof ts === 'number' ? ts : parseInt(ts, 10);
|
||||||
|
return num < 10000000000 ? num * 1000 : num;
|
||||||
|
};
|
||||||
|
const entryMs = toMs(hit.entryTimestamp);
|
||||||
|
const exitMs = toMs(hit.exitTimestamp);
|
||||||
|
return {
|
||||||
|
...hit,
|
||||||
|
entryTimestamp: entryMs,
|
||||||
|
exitTimestamp: exitMs,
|
||||||
|
entryPosition: track ? interpolatePositionAtTime(track, entryMs) : null,
|
||||||
|
exitPosition: track ? interpolatePositionAtTime(track, exitMs) : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tracks,
|
||||||
|
hitDetails,
|
||||||
|
summary: result.summary || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
117
src/areaSearch/stores/areaSearchAnimationStore.js
Normal file
117
src/areaSearch/stores/areaSearchAnimationStore.js
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* 항적분석 전용 애니메이션 스토어
|
||||||
|
* 참조: src/tracking/stores/trackQueryAnimationStore.js
|
||||||
|
*
|
||||||
|
* - 재생/일시정지/정지
|
||||||
|
* - 배속 조절 (1x ~ 1000x)
|
||||||
|
* - requestAnimationFrame 기반 애니메이션
|
||||||
|
*/
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { subscribeWithSelector } from 'zustand/middleware';
|
||||||
|
|
||||||
|
let animationFrameId = null;
|
||||||
|
let lastFrameTime = null;
|
||||||
|
|
||||||
|
export const useAreaSearchAnimationStore = create(subscribeWithSelector((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;
|
||||||
|
|
||||||
|
if (newTime >= state.endTime) {
|
||||||
|
set({ currentTime: state.endTime, isPlaying: false });
|
||||||
|
animationFrameId = null;
|
||||||
|
lastFrameTime = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ currentTime: newTime });
|
||||||
|
animationFrameId = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isPlaying: false,
|
||||||
|
currentTime: 0,
|
||||||
|
startTime: 0,
|
||||||
|
endTime: 0,
|
||||||
|
playbackSpeed: 1,
|
||||||
|
|
||||||
|
play: () => {
|
||||||
|
const state = get();
|
||||||
|
if (state.endTime <= state.startTime) return;
|
||||||
|
|
||||||
|
lastFrameTime = null;
|
||||||
|
|
||||||
|
if (state.currentTime >= state.endTime) {
|
||||||
|
set({ isPlaying: true, currentTime: state.startTime });
|
||||||
|
} 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();
|
||||||
|
set({ currentTime: Math.max(startTime, Math.min(endTime, time)) });
|
||||||
|
},
|
||||||
|
|
||||||
|
setPlaybackSpeed: (speed) => set({ playbackSpeed: speed }),
|
||||||
|
|
||||||
|
setTimeRange: (start, end) => {
|
||||||
|
set({
|
||||||
|
startTime: start,
|
||||||
|
endTime: end,
|
||||||
|
currentTime: start,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getProgress: () => {
|
||||||
|
const { currentTime, startTime, endTime } = get();
|
||||||
|
if (endTime <= startTime) return 0;
|
||||||
|
return ((currentTime - startTime) / (endTime - startTime)) * 100;
|
||||||
|
},
|
||||||
|
|
||||||
|
reset: () => {
|
||||||
|
if (animationFrameId) {
|
||||||
|
cancelAnimationFrame(animationFrameId);
|
||||||
|
animationFrameId = null;
|
||||||
|
}
|
||||||
|
lastFrameTime = null;
|
||||||
|
set({
|
||||||
|
isPlaying: false,
|
||||||
|
currentTime: 0,
|
||||||
|
startTime: 0,
|
||||||
|
endTime: 0,
|
||||||
|
playbackSpeed: 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}));
|
||||||
240
src/areaSearch/stores/areaSearchStore.js
Normal file
240
src/areaSearch/stores/areaSearchStore.js
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* 항적분석(구역 검색) 메인 상태 관리 스토어
|
||||||
|
*
|
||||||
|
* - 구역 관리 (추가/삭제/순서변경, 최대 3개)
|
||||||
|
* - 검색 조건 (모드, 기간)
|
||||||
|
* - 결과 데이터 (항적, hitDetails, summary)
|
||||||
|
* - UI 상태
|
||||||
|
*/
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { subscribeWithSelector } from 'zustand/middleware';
|
||||||
|
import { SEARCH_MODES, MAX_ZONES, ZONE_NAMES, ALL_SHIP_KIND_CODES } from '../types/areaSearch.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 두 지점 사이 선박 위치를 시간 기반 보간
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
return [p1[0] + (p2[0] - p1[0]) * ratio, p1[1] + (p2[1] - p1[1]) * ratio];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 두 지점 간 방향(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;
|
||||||
|
if (angle < 0) angle += 360;
|
||||||
|
return angle;
|
||||||
|
}
|
||||||
|
|
||||||
|
let zoneIdCounter = 0;
|
||||||
|
|
||||||
|
export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({
|
||||||
|
// 검색 조건
|
||||||
|
zones: [],
|
||||||
|
searchMode: SEARCH_MODES.ANY,
|
||||||
|
|
||||||
|
// 검색 결과
|
||||||
|
tracks: [],
|
||||||
|
hitDetails: {},
|
||||||
|
summary: null,
|
||||||
|
|
||||||
|
// UI 상태
|
||||||
|
isLoading: false,
|
||||||
|
queryCompleted: false,
|
||||||
|
disabledVesselIds: new Set(),
|
||||||
|
highlightedVesselId: null,
|
||||||
|
showZones: true,
|
||||||
|
activeDrawType: null,
|
||||||
|
areaSearchTooltip: null,
|
||||||
|
|
||||||
|
// 필터 상태
|
||||||
|
showPaths: true,
|
||||||
|
showTrail: false,
|
||||||
|
shipKindCodeFilter: new Set(ALL_SHIP_KIND_CODES),
|
||||||
|
|
||||||
|
// ========== 구역 관리 ==========
|
||||||
|
|
||||||
|
addZone: (zone) => {
|
||||||
|
const { zones } = get();
|
||||||
|
if (zones.length >= MAX_ZONES) return;
|
||||||
|
const idx = zones.length;
|
||||||
|
const newZone = {
|
||||||
|
...zone,
|
||||||
|
id: `zone-${++zoneIdCounter}`,
|
||||||
|
name: ZONE_NAMES[idx] || `${idx + 1}`,
|
||||||
|
colorIndex: idx,
|
||||||
|
};
|
||||||
|
set({ zones: [...zones, newZone], activeDrawType: null });
|
||||||
|
},
|
||||||
|
|
||||||
|
removeZone: (zoneId) => {
|
||||||
|
const { zones } = get();
|
||||||
|
const filtered = zones.filter(z => z.id !== zoneId);
|
||||||
|
set({ zones: filtered });
|
||||||
|
},
|
||||||
|
|
||||||
|
clearZones: () => set({ zones: [] }),
|
||||||
|
|
||||||
|
reorderZones: (fromIndex, toIndex) => {
|
||||||
|
const { zones } = get();
|
||||||
|
if (fromIndex < 0 || fromIndex >= zones.length) return;
|
||||||
|
if (toIndex < 0 || toIndex >= zones.length) return;
|
||||||
|
const newZones = [...zones];
|
||||||
|
const [moved] = newZones.splice(fromIndex, 1);
|
||||||
|
newZones.splice(toIndex, 0, moved);
|
||||||
|
set({ zones: newZones });
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== 검색 조건 ==========
|
||||||
|
|
||||||
|
setSearchMode: (mode) => set({ searchMode: mode }),
|
||||||
|
setActiveDrawType: (type) => set({ activeDrawType: type }),
|
||||||
|
setShowZones: (show) => set({ showZones: show }),
|
||||||
|
|
||||||
|
// ========== 검색 결과 ==========
|
||||||
|
|
||||||
|
setTracks: (tracks) => {
|
||||||
|
if (tracks.length === 0) {
|
||||||
|
set({ tracks: [], queryCompleted: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set({ tracks, queryCompleted: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
setHitDetails: (hitDetails) => set({ hitDetails }),
|
||||||
|
setSummary: (summary) => set({ summary }),
|
||||||
|
setLoading: (loading) => set({ isLoading: loading }),
|
||||||
|
|
||||||
|
// ========== 선박 토글 ==========
|
||||||
|
|
||||||
|
toggleVesselEnabled: (vesselId) => {
|
||||||
|
const { disabledVesselIds } = get();
|
||||||
|
const newDisabled = new Set(disabledVesselIds);
|
||||||
|
if (newDisabled.has(vesselId)) {
|
||||||
|
newDisabled.delete(vesselId);
|
||||||
|
} else {
|
||||||
|
newDisabled.add(vesselId);
|
||||||
|
}
|
||||||
|
set({ disabledVesselIds: newDisabled });
|
||||||
|
},
|
||||||
|
|
||||||
|
setHighlightedVesselId: (vesselId) => set({ highlightedVesselId: vesselId }),
|
||||||
|
setAreaSearchTooltip: (tooltip) => set({ areaSearchTooltip: tooltip }),
|
||||||
|
|
||||||
|
// ========== 필터 토글 ==========
|
||||||
|
|
||||||
|
setShowPaths: (show) => set({ showPaths: show }),
|
||||||
|
setShowTrail: (show) => set({ showTrail: show }),
|
||||||
|
|
||||||
|
toggleShipKindCode: (code) => {
|
||||||
|
const { shipKindCodeFilter } = get();
|
||||||
|
const newFilter = new Set(shipKindCodeFilter);
|
||||||
|
if (newFilter.has(code)) newFilter.delete(code);
|
||||||
|
else newFilter.add(code);
|
||||||
|
set({ shipKindCodeFilter: newFilter });
|
||||||
|
},
|
||||||
|
|
||||||
|
getEnabledTracks: () => {
|
||||||
|
const { tracks, disabledVesselIds } = get();
|
||||||
|
return tracks.filter(t => !disabledVesselIds.has(t.vesselId));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 시간의 모든 선박 위치 계산 (이진 탐색 + 선형 보간)
|
||||||
|
*/
|
||||||
|
getCurrentPositions: (currentTime) => {
|
||||||
|
const { tracks, 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, heading, 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,
|
||||||
|
lon: position[0], lat: position[1],
|
||||||
|
heading, speed, timestamp: currentTime,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return positions;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== 초기화 ==========
|
||||||
|
|
||||||
|
clearResults: () => {
|
||||||
|
set({
|
||||||
|
tracks: [],
|
||||||
|
hitDetails: {},
|
||||||
|
summary: null,
|
||||||
|
queryCompleted: false,
|
||||||
|
disabledVesselIds: new Set(),
|
||||||
|
highlightedVesselId: null,
|
||||||
|
areaSearchTooltip: null,
|
||||||
|
showPaths: true,
|
||||||
|
showTrail: false,
|
||||||
|
shipKindCodeFilter: new Set(ALL_SHIP_KIND_CODES),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
reset: () => {
|
||||||
|
set({
|
||||||
|
zones: [],
|
||||||
|
searchMode: SEARCH_MODES.ANY,
|
||||||
|
tracks: [],
|
||||||
|
hitDetails: {},
|
||||||
|
summary: null,
|
||||||
|
isLoading: false,
|
||||||
|
queryCompleted: false,
|
||||||
|
disabledVesselIds: new Set(),
|
||||||
|
highlightedVesselId: null,
|
||||||
|
showZones: true,
|
||||||
|
activeDrawType: null,
|
||||||
|
areaSearchTooltip: null,
|
||||||
|
showPaths: true,
|
||||||
|
showTrail: false,
|
||||||
|
shipKindCodeFilter: new Set(ALL_SHIP_KIND_CODES),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
})));
|
||||||
|
|
||||||
|
export default useAreaSearchStore;
|
||||||
94
src/areaSearch/types/areaSearch.types.js
Normal file
94
src/areaSearch/types/areaSearch.types.js
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* 항적분석(구역 검색) 상수 및 타입 정의
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ========== 검색 모드 ==========
|
||||||
|
|
||||||
|
export const SEARCH_MODES = {
|
||||||
|
ANY: 'ANY',
|
||||||
|
ALL: 'ALL',
|
||||||
|
SEQUENTIAL: 'SEQUENTIAL',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SEARCH_MODE_LABELS = {
|
||||||
|
[SEARCH_MODES.ANY]: 'ANY (합집합)',
|
||||||
|
[SEARCH_MODES.ALL]: 'ALL (교집합)',
|
||||||
|
[SEARCH_MODES.SEQUENTIAL]: 'SEQUENTIAL (순차통과)',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== 구역 설정 ==========
|
||||||
|
|
||||||
|
export const MAX_ZONES = 3;
|
||||||
|
|
||||||
|
export const ZONE_DRAW_TYPES = {
|
||||||
|
POLYGON: 'Polygon',
|
||||||
|
BOX: 'Box',
|
||||||
|
CIRCLE: 'Circle',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ZONE_NAMES = ['A', 'B', 'C'];
|
||||||
|
|
||||||
|
export const ZONE_COLORS = [
|
||||||
|
{ fill: [255, 59, 48, 50], stroke: [255, 59, 48, 200], label: '#FF3B30' },
|
||||||
|
{ fill: [0, 199, 190, 50], stroke: [0, 199, 190, 200], label: '#00C7BE' },
|
||||||
|
{ fill: [255, 204, 0, 50], stroke: [255, 204, 0, 200], label: '#FFCC00' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ========== 조회기간 제약 ==========
|
||||||
|
|
||||||
|
export const QUERY_MAX_DAYS = 7;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조회 가능 기간 계산 (D-7 ~ D-1)
|
||||||
|
* 인메모리 캐시 기반, 오늘 데이터 없음
|
||||||
|
*/
|
||||||
|
export function getQueryDateRange() {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const endDate = new Date(now);
|
||||||
|
endDate.setDate(endDate.getDate() - 1);
|
||||||
|
endDate.setHours(23, 59, 59, 0);
|
||||||
|
|
||||||
|
const startDate = new Date(now);
|
||||||
|
startDate.setDate(startDate.getDate() - QUERY_MAX_DAYS);
|
||||||
|
startDate.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
return { startDate, endDate };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 배속 옵션 ==========
|
||||||
|
|
||||||
|
export const PLAYBACK_SPEED_OPTIONS = [1, 5, 10, 50, 100, 1000];
|
||||||
|
|
||||||
|
// ========== 선종 코드 전체 목록 (필터 초기값) ==========
|
||||||
|
|
||||||
|
import {
|
||||||
|
SIGNAL_KIND_CODE_FISHING,
|
||||||
|
SIGNAL_KIND_CODE_KCGV,
|
||||||
|
SIGNAL_KIND_CODE_PASSENGER,
|
||||||
|
SIGNAL_KIND_CODE_CARGO,
|
||||||
|
SIGNAL_KIND_CODE_TANKER,
|
||||||
|
SIGNAL_KIND_CODE_GOV,
|
||||||
|
SIGNAL_KIND_CODE_NORMAL,
|
||||||
|
SIGNAL_KIND_CODE_BUOY,
|
||||||
|
} from '../../types/constants';
|
||||||
|
|
||||||
|
export const ALL_SHIP_KIND_CODES = [
|
||||||
|
SIGNAL_KIND_CODE_FISHING,
|
||||||
|
SIGNAL_KIND_CODE_KCGV,
|
||||||
|
SIGNAL_KIND_CODE_PASSENGER,
|
||||||
|
SIGNAL_KIND_CODE_CARGO,
|
||||||
|
SIGNAL_KIND_CODE_TANKER,
|
||||||
|
SIGNAL_KIND_CODE_GOV,
|
||||||
|
SIGNAL_KIND_CODE_NORMAL,
|
||||||
|
SIGNAL_KIND_CODE_BUOY,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ========== 레이어 ID ==========
|
||||||
|
|
||||||
|
export const AREA_SEARCH_LAYER_IDS = {
|
||||||
|
PATH: 'area-search-path-layer',
|
||||||
|
TRIPS_TRAIL: 'area-search-trips-trail',
|
||||||
|
VIRTUAL_SHIP: 'area-search-virtual-ship-layer',
|
||||||
|
VIRTUAL_SHIP_LABEL: 'area-search-virtual-ship-label-layer',
|
||||||
|
};
|
||||||
19
src/areaSearch/utils/areaSearchLayerRegistry.js
Normal file
19
src/areaSearch/utils/areaSearchLayerRegistry.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* 항적분석 레이어 전역 레지스트리
|
||||||
|
* 참조: src/replay/utils/replayLayerRegistry.js
|
||||||
|
*
|
||||||
|
* useAreaSearchLayer 훅이 레이어를 등록하면
|
||||||
|
* useShipLayer의 handleBatchRender에서 가져와 deck.gl에 병합
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function registerAreaSearchLayers(layers) {
|
||||||
|
window.__areaSearchLayers__ = layers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAreaSearchLayers() {
|
||||||
|
return window.__areaSearchLayers__ || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterAreaSearchLayers() {
|
||||||
|
window.__areaSearchLayers__ = [];
|
||||||
|
}
|
||||||
112
src/areaSearch/utils/csvExport.js
Normal file
112
src/areaSearch/utils/csvExport.js
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* 항적분석 검색 결과 CSV 내보내기
|
||||||
|
* BOM + UTF-8 인코딩 (한글 엑셀 호환)
|
||||||
|
*/
|
||||||
|
import { getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types';
|
||||||
|
import { getCountryIsoCode } from '../../replay/components/VesselListManager/utils/countryCodeUtils';
|
||||||
|
|
||||||
|
function formatTimestamp(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())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPosition(pos) {
|
||||||
|
if (!pos || pos.length < 2) return '';
|
||||||
|
const lon = pos[0];
|
||||||
|
const lat = pos[1];
|
||||||
|
const latDir = lat >= 0 ? 'N' : 'S';
|
||||||
|
const lonDir = lon >= 0 ? 'E' : 'W';
|
||||||
|
return `${Math.abs(lat).toFixed(4)}\u00B0${latDir} ${Math.abs(lon).toFixed(4)}\u00B0${lonDir}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeCsvField(value) {
|
||||||
|
const str = String(value ?? '');
|
||||||
|
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
||||||
|
return `"${str.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 검색 결과를 CSV로 내보내기
|
||||||
|
*
|
||||||
|
* @param {Array} tracks ProcessedTrack 배열
|
||||||
|
* @param {Object} hitDetails { vesselId: [{ polygonId, entryTimestamp, exitTimestamp }] }
|
||||||
|
* @param {Array} zones 구역 배열
|
||||||
|
*/
|
||||||
|
export function exportSearchResultToCSV(tracks, hitDetails, zones) {
|
||||||
|
const zoneNames = zones.map((z) => z.name);
|
||||||
|
|
||||||
|
// 헤더 구성
|
||||||
|
const baseHeaders = [
|
||||||
|
'신호원', '식별번호', '선박명', '선종', '국적',
|
||||||
|
'포인트수', '이동거리(NM)', '평균속도(kn)', '최대속도(kn)',
|
||||||
|
];
|
||||||
|
|
||||||
|
const zoneHeaders = [];
|
||||||
|
zoneNames.forEach((name) => {
|
||||||
|
zoneHeaders.push(
|
||||||
|
`구역${name}_진입시각`, `구역${name}_진입위치`,
|
||||||
|
`구역${name}_진출시각`, `구역${name}_진출위치`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = [...baseHeaders, ...zoneHeaders];
|
||||||
|
|
||||||
|
// 데이터 행 생성
|
||||||
|
const rows = tracks.map((track) => {
|
||||||
|
const baseRow = [
|
||||||
|
getSignalSourceName(track.sigSrcCd),
|
||||||
|
track.targetId || '',
|
||||||
|
track.shipName || '',
|
||||||
|
getShipKindName(track.shipKindCode),
|
||||||
|
track.nationalCode ? getCountryIsoCode(track.nationalCode) : '',
|
||||||
|
track.stats?.pointCount ?? track.geometry.length,
|
||||||
|
track.stats?.totalDistance != null ? track.stats.totalDistance.toFixed(2) : '',
|
||||||
|
track.stats?.avgSpeed != null ? track.stats.avgSpeed.toFixed(1) : '',
|
||||||
|
track.stats?.maxSpeed != null ? track.stats.maxSpeed.toFixed(1) : '',
|
||||||
|
];
|
||||||
|
|
||||||
|
const hits = hitDetails[track.vesselId] || [];
|
||||||
|
const zoneData = [];
|
||||||
|
zones.forEach((zone) => {
|
||||||
|
const hit = hits.find((h) => h.polygonId === zone.id);
|
||||||
|
if (hit) {
|
||||||
|
zoneData.push(
|
||||||
|
formatTimestamp(hit.entryTimestamp),
|
||||||
|
formatPosition(hit.entryPosition),
|
||||||
|
formatTimestamp(hit.exitTimestamp),
|
||||||
|
formatPosition(hit.exitPosition),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
zoneData.push('', '', '', '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...baseRow, ...zoneData];
|
||||||
|
});
|
||||||
|
|
||||||
|
// CSV 문자열 생성
|
||||||
|
const csvLines = [
|
||||||
|
headers.map(escapeCsvField).join(','),
|
||||||
|
...rows.map((row) => row.map(escapeCsvField).join(',')),
|
||||||
|
];
|
||||||
|
const csvContent = csvLines.join('\n');
|
||||||
|
|
||||||
|
// BOM + UTF-8 Blob 생성 및 다운로드
|
||||||
|
const BOM = '\uFEFF';
|
||||||
|
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
|
const filename = `항적분석_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}.csv`;
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
@ -11,7 +11,7 @@ const gnbList = [
|
|||||||
{ key: 'gnb5', className: 'gnb5', label: '타임라인', path: 'timeline' },
|
{ key: 'gnb5', className: 'gnb5', label: '타임라인', path: 'timeline' },
|
||||||
// { key: 'gnb6', className: 'gnb6', label: 'AI모드', path: 'ai' },
|
// { key: 'gnb6', className: 'gnb6', label: 'AI모드', path: 'ai' },
|
||||||
{ key: 'gnb7', className: 'gnb7', label: '리플레이', path: 'replay' },
|
{ key: 'gnb7', className: 'gnb7', label: '리플레이', path: 'replay' },
|
||||||
// { key: 'gnb8', className: 'gnb8', label: '항적조회', path: 'tracking' },
|
{ key: 'gnb8', className: 'gnb8', label: '항적분석', path: 'area-search' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// 필터/레이어 버튼 비활성화 — 선박(gnb1) 버튼에서 DisplayComponent로 통합
|
// 필터/레이어 버튼 비활성화 — 선박(gnb1) 버튼에서 DisplayComponent로 통합
|
||||||
@ -67,7 +67,7 @@ export const keyToPath = {
|
|||||||
gnb5: 'timeline',
|
gnb5: 'timeline',
|
||||||
gnb6: 'ai',
|
gnb6: 'ai',
|
||||||
gnb7: 'replay',
|
gnb7: 'replay',
|
||||||
gnb8: 'tracking',
|
gnb8: 'area-search',
|
||||||
filter: 'filter',
|
filter: 'filter',
|
||||||
layer: 'layer',
|
layer: 'layer',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -18,6 +18,7 @@ const Panel8Component = getPanel('Panel8Component');
|
|||||||
import DisplayComponent from '../../component/wrap/side/DisplayComponent';
|
import DisplayComponent from '../../component/wrap/side/DisplayComponent';
|
||||||
// 구현된 페이지
|
// 구현된 페이지
|
||||||
import ReplayPage from '../../pages/ReplayPage';
|
import ReplayPage from '../../pages/ReplayPage';
|
||||||
|
import AreaSearchPage from '../../areaSearch/components/AreaSearchPage';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 사이드바 컴포넌트
|
* 사이드바 컴포넌트
|
||||||
@ -69,7 +70,7 @@ export default function Sidebar() {
|
|||||||
gnb5: Panel5Component ? <Panel5Component {...panelProps} /> : null,
|
gnb5: Panel5Component ? <Panel5Component {...panelProps} /> : null,
|
||||||
gnb6: Panel6Component ? <Panel6Component {...panelProps} /> : null,
|
gnb6: Panel6Component ? <Panel6Component {...panelProps} /> : null,
|
||||||
gnb7: <ReplayPage {...panelProps} />,
|
gnb7: <ReplayPage {...panelProps} />,
|
||||||
gnb8: Panel8Component ? <Panel8Component {...panelProps} /> : null,
|
gnb8: <AreaSearchPage {...panelProps} />,
|
||||||
filter: DisplayComponent ? <DisplayComponent {...panelProps} initialTab="filter" /> : null,
|
filter: DisplayComponent ? <DisplayComponent {...panelProps} initialTab="filter" /> : null,
|
||||||
layer: DisplayComponent ? <DisplayComponent {...panelProps} initialTab="layer" /> : null,
|
layer: DisplayComponent ? <DisplayComponent {...panelProps} initialTab="layer" /> : null,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,10 +3,12 @@
|
|||||||
* - 선박 종류별 아이콘 및 카운트 표시
|
* - 선박 종류별 아이콘 및 카운트 표시
|
||||||
* - 선박 표시 On/Off 토글
|
* - 선박 표시 On/Off 토글
|
||||||
* - 선박 종류별 필터 토글
|
* - 선박 종류별 필터 토글
|
||||||
|
* - 항적분석 활성 시 결과 카운트 표시
|
||||||
*/
|
*/
|
||||||
import { memo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import { shallow } from 'zustand/shallow';
|
import { shallow } from 'zustand/shallow';
|
||||||
import useShipStore from '../../stores/shipStore';
|
import useShipStore from '../../stores/shipStore';
|
||||||
|
import { useAreaSearchStore } from '../../areaSearch/stores/areaSearchStore';
|
||||||
import {
|
import {
|
||||||
SIGNAL_KIND_CODE_FISHING,
|
SIGNAL_KIND_CODE_FISHING,
|
||||||
SIGNAL_KIND_CODE_KCGV,
|
SIGNAL_KIND_CODE_KCGV,
|
||||||
@ -101,22 +103,58 @@ const ShipLegend = memo(() => {
|
|||||||
const toggleKindVisibility = useShipStore((state) => state.toggleKindVisibility);
|
const toggleKindVisibility = useShipStore((state) => state.toggleKindVisibility);
|
||||||
const toggleShipVisible = useShipStore((state) => state.toggleShipVisible);
|
const toggleShipVisible = useShipStore((state) => state.toggleShipVisible);
|
||||||
|
|
||||||
|
// 항적분석 활성 시 결과 카운트
|
||||||
|
const areaSearchCompleted = useAreaSearchStore((s) => s.queryCompleted);
|
||||||
|
const areaSearchTracks = useAreaSearchStore((s) => s.tracks);
|
||||||
|
const areaSearchDisabledIds = useAreaSearchStore((s) => s.disabledVesselIds);
|
||||||
|
const areaSearchKindFilter = useAreaSearchStore((s) => s.shipKindCodeFilter);
|
||||||
|
const toggleAreaSearchKind = useAreaSearchStore((s) => s.toggleShipKindCode);
|
||||||
|
|
||||||
|
const areaSearchCounts = useMemo(() => {
|
||||||
|
if (!areaSearchCompleted || areaSearchTracks.length === 0) return null;
|
||||||
|
const counts = {};
|
||||||
|
let total = 0;
|
||||||
|
areaSearchTracks.forEach((track) => {
|
||||||
|
if (areaSearchDisabledIds.has(track.vesselId)) return;
|
||||||
|
if (!areaSearchKindFilter.has(track.shipKindCode)) return;
|
||||||
|
const code = track.shipKindCode || SIGNAL_KIND_CODE_NORMAL;
|
||||||
|
counts[code] = (counts[code] || 0) + 1;
|
||||||
|
total += 1;
|
||||||
|
});
|
||||||
|
return { counts, total };
|
||||||
|
}, [areaSearchCompleted, areaSearchTracks, areaSearchDisabledIds, areaSearchKindFilter]);
|
||||||
|
|
||||||
|
const isAreaSearchMode = areaSearchCounts !== null;
|
||||||
|
const displayCounts = isAreaSearchMode ? areaSearchCounts.counts : kindCounts;
|
||||||
|
const displayTotal = isAreaSearchMode ? areaSearchCounts.total : totalCount;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="ship-legend">
|
<article className="ship-legend">
|
||||||
{/* 헤더 - 전체 On/Off */}
|
{/* 헤더 - 전체 On/Off */}
|
||||||
<div className="legend-header">
|
<div className="legend-header">
|
||||||
<div className="legend-title">
|
<div className="legend-title">
|
||||||
<span className={`connection-status ${isConnected ? 'connected' : ''}`} />
|
{isAreaSearchMode ? (
|
||||||
<span>선박 현황</span>
|
<>
|
||||||
|
<span className="connection-status connected" style={{ backgroundColor: '#ff9800' }} />
|
||||||
|
<span>항적 분석</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className={`connection-status ${isConnected ? 'connected' : ''}`} />
|
||||||
|
<span>선박 현황</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<label className="toggle-switch">
|
{!isAreaSearchMode && (
|
||||||
<input
|
<label className="toggle-switch">
|
||||||
type="checkbox"
|
<input
|
||||||
checked={isShipVisible}
|
type="checkbox"
|
||||||
onChange={toggleShipVisible}
|
checked={isShipVisible}
|
||||||
/>
|
onChange={toggleShipVisible}
|
||||||
<span className="slider" />
|
/>
|
||||||
</label>
|
<span className="slider" />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 선박 종류별 목록 */}
|
{/* 선박 종류별 목록 */}
|
||||||
@ -126,10 +164,10 @@ const ShipLegend = memo(() => {
|
|||||||
key={item.code}
|
key={item.code}
|
||||||
code={item.code}
|
code={item.code}
|
||||||
label={item.label}
|
label={item.label}
|
||||||
count={kindCounts[item.code] || 0}
|
count={displayCounts[item.code] || 0}
|
||||||
icon={SHIP_KIND_ICONS[item.code]}
|
icon={SHIP_KIND_ICONS[item.code]}
|
||||||
isVisible={kindVisibility[item.code]}
|
isVisible={isAreaSearchMode ? areaSearchKindFilter.has(item.code) : kindVisibility[item.code]}
|
||||||
onToggle={toggleKindVisibility}
|
onToggle={isAreaSearchMode ? toggleAreaSearchKind : toggleKindVisibility}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@ -137,7 +175,7 @@ const ShipLegend = memo(() => {
|
|||||||
{/* 푸터 - 전체 카운트 */}
|
{/* 푸터 - 전체 카운트 */}
|
||||||
<div className="legend-footer">
|
<div className="legend-footer">
|
||||||
<span>전체</span>
|
<span>전체</span>
|
||||||
<span className="total-count">{totalCount}</span>
|
<span className="total-count">{displayTotal}</span>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import useTrackingModeStore from '../stores/trackingModeStore';
|
|||||||
import { useMapStore } from '../stores/mapStore';
|
import { useMapStore } from '../stores/mapStore';
|
||||||
import { getTrackQueryLayers } from '../tracking/utils/trackQueryLayerUtils';
|
import { getTrackQueryLayers } from '../tracking/utils/trackQueryLayerUtils';
|
||||||
import { getReplayLayers } from '../replay/utils/replayLayerRegistry';
|
import { getReplayLayers } from '../replay/utils/replayLayerRegistry';
|
||||||
|
import { getAreaSearchLayers } from '../areaSearch/utils/areaSearchLayerRegistry';
|
||||||
import { shipBatchRenderer } from '../map/ShipBatchRenderer';
|
import { shipBatchRenderer } from '../map/ShipBatchRenderer';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -139,9 +140,12 @@ export default function useShipLayer(map) {
|
|||||||
// 리플레이 레이어 (전역 레지스트리)
|
// 리플레이 레이어 (전역 레지스트리)
|
||||||
const replayLayers = getReplayLayers();
|
const replayLayers = getReplayLayers();
|
||||||
|
|
||||||
// 병합: 선박 + 항적 + 리플레이 레이어
|
// 항적분석 레이어 (전역 레지스트리)
|
||||||
|
const areaSearchLayers = getAreaSearchLayers();
|
||||||
|
|
||||||
|
// 병합: 선박 + 항적 + 리플레이 + 항적분석 레이어
|
||||||
deckRef.current.setProps({
|
deckRef.current.setProps({
|
||||||
layers: [...shipLayers, ...trackLayers, ...replayLayers],
|
layers: [...shipLayers, ...trackLayers, ...replayLayers, ...areaSearchLayers],
|
||||||
});
|
});
|
||||||
}, [map, getSelectedShips]);
|
}, [map, getSelectedShips]);
|
||||||
|
|
||||||
|
|||||||
@ -29,6 +29,14 @@ import { showLiveShips } from '../utils/liveControl';
|
|||||||
import { useTrackQueryStore } from '../tracking/stores/trackQueryStore';
|
import { useTrackQueryStore } from '../tracking/stores/trackQueryStore';
|
||||||
import { LAYER_IDS as TRACK_QUERY_LAYER_IDS } from '../tracking/utils/trackQueryLayerUtils';
|
import { LAYER_IDS as TRACK_QUERY_LAYER_IDS } from '../tracking/utils/trackQueryLayerUtils';
|
||||||
|
|
||||||
|
import useAreaSearchLayer from '../areaSearch/hooks/useAreaSearchLayer';
|
||||||
|
import useZoneDraw from '../areaSearch/hooks/useZoneDraw';
|
||||||
|
import { useAreaSearchStore } from '../areaSearch/stores/areaSearchStore';
|
||||||
|
import { useAreaSearchAnimationStore } from '../areaSearch/stores/areaSearchAnimationStore';
|
||||||
|
import { unregisterAreaSearchLayers } from '../areaSearch/utils/areaSearchLayerRegistry';
|
||||||
|
import { AREA_SEARCH_LAYER_IDS } from '../areaSearch/types/areaSearch.types';
|
||||||
|
import AreaSearchTimeline from '../areaSearch/components/AreaSearchTimeline';
|
||||||
|
import AreaSearchTooltip from '../areaSearch/components/AreaSearchTooltip';
|
||||||
import useMeasure from './measure/useMeasure';
|
import useMeasure from './measure/useMeasure';
|
||||||
import useTrackingMode from '../hooks/useTrackingMode';
|
import useTrackingMode from '../hooks/useTrackingMode';
|
||||||
import './measure/measure.scss';
|
import './measure/measure.scss';
|
||||||
@ -64,6 +72,12 @@ export default function MapContainer() {
|
|||||||
// 리플레이 레이어
|
// 리플레이 레이어
|
||||||
useReplayLayer();
|
useReplayLayer();
|
||||||
|
|
||||||
|
// 항적분석 레이어 + 구역 그리기
|
||||||
|
useAreaSearchLayer();
|
||||||
|
useZoneDraw();
|
||||||
|
|
||||||
|
const areaSearchCompleted = useAreaSearchStore((s) => s.queryCompleted);
|
||||||
|
|
||||||
// 측정 도구
|
// 측정 도구
|
||||||
useMeasure();
|
useMeasure();
|
||||||
|
|
||||||
@ -133,6 +147,8 @@ export default function MapContainer() {
|
|||||||
useShipStore.getState().setHoverInfo(null);
|
useShipStore.getState().setHoverInfo(null);
|
||||||
useTrackQueryStore.getState().setHighlightedVesselId(null);
|
useTrackQueryStore.getState().setHighlightedVesselId(null);
|
||||||
useReplayStore.getState().setHighlightedVesselId(null);
|
useReplayStore.getState().setHighlightedVesselId(null);
|
||||||
|
useAreaSearchStore.getState().setAreaSearchTooltip(null);
|
||||||
|
useAreaSearchStore.getState().setHighlightedVesselId(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -152,12 +168,21 @@ export default function MapContainer() {
|
|||||||
useTrackQueryStore.getState().setHighlightedVesselId(null);
|
useTrackQueryStore.getState().setHighlightedVesselId(null);
|
||||||
useTrackQueryStore.getState().clearHoveredPoint();
|
useTrackQueryStore.getState().clearHoveredPoint();
|
||||||
useReplayStore.getState().setHighlightedVesselId(null);
|
useReplayStore.getState().setHighlightedVesselId(null);
|
||||||
|
useAreaSearchStore.getState().setAreaSearchTooltip(null);
|
||||||
|
useAreaSearchStore.getState().setHighlightedVesselId(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const layerId = pickResult.layer.id;
|
const layerId = pickResult.layer.id;
|
||||||
const obj = pickResult.object;
|
const obj = pickResult.object;
|
||||||
|
|
||||||
|
// area search가 아닌 레이어에서는 area search 상태 클리어
|
||||||
|
if (layerId !== AREA_SEARCH_LAYER_IDS.PATH &&
|
||||||
|
layerId !== AREA_SEARCH_LAYER_IDS.VIRTUAL_SHIP) {
|
||||||
|
useAreaSearchStore.getState().setAreaSearchTooltip(null);
|
||||||
|
useAreaSearchStore.getState().setHighlightedVesselId(null);
|
||||||
|
}
|
||||||
|
|
||||||
// 라이브 선박
|
// 라이브 선박
|
||||||
if (layerId === 'ship-icon-layer') {
|
if (layerId === 'ship-icon-layer') {
|
||||||
useShipStore.getState().setHoverInfo({ ship: obj, x: clientX, y: clientY });
|
useShipStore.getState().setHoverInfo({ ship: obj, x: clientX, y: clientY });
|
||||||
@ -212,6 +237,32 @@ export default function MapContainer() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 항적분석 경로 (PathLayer)
|
||||||
|
if (layerId === AREA_SEARCH_LAYER_IDS.PATH) {
|
||||||
|
useShipStore.getState().setHoverInfo(null);
|
||||||
|
useTrackQueryStore.getState().setHighlightedVesselId(null);
|
||||||
|
useTrackQueryStore.getState().clearHoveredPoint();
|
||||||
|
useReplayStore.getState().setHighlightedVesselId(null);
|
||||||
|
useAreaSearchStore.getState().setHighlightedVesselId(obj?.vesselId || null);
|
||||||
|
useAreaSearchStore.getState().setAreaSearchTooltip(
|
||||||
|
obj ? { vesselId: obj.vesselId, x: clientX, y: clientY } : null,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 항적분석 가상 선박 아이콘
|
||||||
|
if (layerId === AREA_SEARCH_LAYER_IDS.VIRTUAL_SHIP) {
|
||||||
|
useShipStore.getState().setHoverInfo(null);
|
||||||
|
useTrackQueryStore.getState().setHighlightedVesselId(null);
|
||||||
|
useTrackQueryStore.getState().clearHoveredPoint();
|
||||||
|
useReplayStore.getState().setHighlightedVesselId(null);
|
||||||
|
useAreaSearchStore.getState().setHighlightedVesselId(obj?.vesselId || null);
|
||||||
|
useAreaSearchStore.getState().setAreaSearchTooltip(
|
||||||
|
obj ? { vesselId: obj.vesselId, x: clientX, y: clientY } : null,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 리플레이 경로 (PathLayer)
|
// 리플레이 경로 (PathLayer)
|
||||||
if (layerId === 'track-path-layer') {
|
if (layerId === 'track-path-layer') {
|
||||||
useShipStore.getState().setHoverInfo(null);
|
useShipStore.getState().setHoverInfo(null);
|
||||||
@ -266,6 +317,8 @@ export default function MapContainer() {
|
|||||||
useTrackQueryStore.getState().setHighlightedVesselId(null);
|
useTrackQueryStore.getState().setHighlightedVesselId(null);
|
||||||
useTrackQueryStore.getState().clearHoveredPoint();
|
useTrackQueryStore.getState().clearHoveredPoint();
|
||||||
useReplayStore.getState().setHighlightedVesselId(null);
|
useReplayStore.getState().setHighlightedVesselId(null);
|
||||||
|
useAreaSearchStore.getState().setAreaSearchTooltip(null);
|
||||||
|
useAreaSearchStore.getState().setHighlightedVesselId(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -452,6 +505,8 @@ export default function MapContainer() {
|
|||||||
<ShipContextMenu />
|
<ShipContextMenu />
|
||||||
<GlobalTrackQueryViewer />
|
<GlobalTrackQueryViewer />
|
||||||
<ReplayLoadingOverlay />
|
<ReplayLoadingOverlay />
|
||||||
|
{areaSearchCompleted && <AreaSearchTooltip />}
|
||||||
|
{areaSearchCompleted && <AreaSearchTimeline />}
|
||||||
{replayCompleted && (
|
{replayCompleted && (
|
||||||
<ReplayTimeline
|
<ReplayTimeline
|
||||||
fromDate={replayQuery?.startTime}
|
fromDate={replayQuery?.startTime}
|
||||||
|
|||||||
@ -40,10 +40,13 @@ const MAX_POINTS_PER_TRACK = 800;
|
|||||||
* @param {string} [params.highlightedVesselId] - 하이라이트할 선박 ID
|
* @param {string} [params.highlightedVesselId] - 하이라이트할 선박 ID
|
||||||
* @param {Function} [params.onPathHover] - 항적 호버 콜백
|
* @param {Function} [params.onPathHover] - 항적 호버 콜백
|
||||||
*/
|
*/
|
||||||
export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselId, onPathHover }) {
|
export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselId, onPathHover, layerIds }) {
|
||||||
const layers = [];
|
const layers = [];
|
||||||
if (!tracks || tracks.length === 0) return layers;
|
if (!tracks || tracks.length === 0) return layers;
|
||||||
|
|
||||||
|
const pathId = layerIds?.path || 'track-path-layer';
|
||||||
|
const pointId = layerIds?.point || 'track-point-layer';
|
||||||
|
|
||||||
// 1. PathLayer - 전체 경로 (시간 무관)
|
// 1. PathLayer - 전체 경로 (시간 무관)
|
||||||
const pathData = tracks.map((track) => ({
|
const pathData = tracks.map((track) => ({
|
||||||
path: track.geometry,
|
path: track.geometry,
|
||||||
@ -53,7 +56,7 @@ export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselI
|
|||||||
|
|
||||||
layers.push(
|
layers.push(
|
||||||
new PathLayer({
|
new PathLayer({
|
||||||
id: 'track-path-layer',
|
id: pathId,
|
||||||
data: pathData,
|
data: pathData,
|
||||||
getPath: (d) => d.path,
|
getPath: (d) => d.path,
|
||||||
getColor: (d) => {
|
getColor: (d) => {
|
||||||
@ -71,7 +74,7 @@ export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselI
|
|||||||
return 2;
|
return 2;
|
||||||
},
|
},
|
||||||
widthUnits: 'pixels',
|
widthUnits: 'pixels',
|
||||||
widthMinPixels: 1,
|
widthMinPixels: 4,
|
||||||
widthMaxPixels: 8,
|
widthMaxPixels: 8,
|
||||||
jointRounded: true,
|
jointRounded: true,
|
||||||
capRounded: true,
|
capRounded: true,
|
||||||
@ -118,7 +121,7 @@ export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselI
|
|||||||
|
|
||||||
layers.push(
|
layers.push(
|
||||||
new ScatterplotLayer({
|
new ScatterplotLayer({
|
||||||
id: 'track-point-layer',
|
id: pointId,
|
||||||
data: pointData,
|
data: pointData,
|
||||||
getPosition: (d) => d.position,
|
getPosition: (d) => d.position,
|
||||||
getFillColor: (d) => d.color,
|
getFillColor: (d) => d.color,
|
||||||
@ -146,16 +149,19 @@ export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselI
|
|||||||
* @param {Function} [params.onPathHover] - 하이라이트 설정용 콜백
|
* @param {Function} [params.onPathHover] - 하이라이트 설정용 콜백
|
||||||
* @returns {Array} Deck.gl Layer 배열
|
* @returns {Array} Deck.gl Layer 배열
|
||||||
*/
|
*/
|
||||||
export function createVirtualShipLayers({ currentPositions, showVirtualShip, showLabels, onIconHover, onPathHover }) {
|
export function createVirtualShipLayers({ currentPositions, showVirtualShip, showLabels, onIconHover, onPathHover, layerIds }) {
|
||||||
const layers = [];
|
const layers = [];
|
||||||
|
|
||||||
if (!currentPositions || currentPositions.length === 0) return layers;
|
if (!currentPositions || currentPositions.length === 0) return layers;
|
||||||
|
|
||||||
|
const iconId = layerIds?.icon || 'track-virtual-ship-layer';
|
||||||
|
const labelId = layerIds?.label || 'track-label-layer';
|
||||||
|
|
||||||
// 1. IconLayer - 가상 선박 아이콘
|
// 1. IconLayer - 가상 선박 아이콘
|
||||||
if (showVirtualShip) {
|
if (showVirtualShip) {
|
||||||
layers.push(
|
layers.push(
|
||||||
new IconLayer({
|
new IconLayer({
|
||||||
id: 'track-virtual-ship-layer',
|
id: iconId,
|
||||||
data: currentPositions,
|
data: currentPositions,
|
||||||
iconAtlas: atlasImg,
|
iconAtlas: atlasImg,
|
||||||
iconMapping: ICON_ATLAS_MAPPING,
|
iconMapping: ICON_ATLAS_MAPPING,
|
||||||
@ -197,7 +203,7 @@ export function createVirtualShipLayers({ currentPositions, showVirtualShip, sho
|
|||||||
|
|
||||||
layers.push(
|
layers.push(
|
||||||
new TextLayer({
|
new TextLayer({
|
||||||
id: 'track-label-layer',
|
id: labelId,
|
||||||
data: labelData,
|
data: labelData,
|
||||||
getPosition: (d) => [d.lon, d.lat],
|
getPosition: (d) => [d.lon, d.lat],
|
||||||
getText: (d) => d.shipName,
|
getText: (d) => d.shipName,
|
||||||
|
|||||||
@ -304,6 +304,61 @@ export const MMSI_COUNTRY_NAMES = {
|
|||||||
'999': '기타',
|
'999': '기타',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// MMSI MID → ISO 3166-1 alpha-2 매핑
|
||||||
|
const MMSI_TO_ISO = {
|
||||||
|
'201': 'AL', '205': 'BE', '206': 'BY', '207': 'BG', '209': 'CY',
|
||||||
|
'210': 'CY', '211': 'DE', '212': 'CY', '213': 'GE', '214': 'MD',
|
||||||
|
'215': 'MT', '216': 'AM', '218': 'DE', '219': 'DK', '220': 'DK',
|
||||||
|
'224': 'ES', '225': 'ES', '226': 'FR', '227': 'FR', '228': 'FR',
|
||||||
|
'230': 'FI', '232': 'GB', '233': 'GB', '234': 'GB', '235': 'GB',
|
||||||
|
'237': 'GR', '238': 'HR', '239': 'GR', '240': 'GR', '241': 'GR',
|
||||||
|
'242': 'MA', '243': 'HU', '244': 'NL', '245': 'NL', '246': 'NL',
|
||||||
|
'247': 'IT', '248': 'MT', '249': 'MT', '250': 'IE', '251': 'IS',
|
||||||
|
'256': 'MT', '257': 'NO', '258': 'NO', '259': 'NO', '261': 'PL',
|
||||||
|
'263': 'PT', '264': 'RO', '265': 'SE', '266': 'SE', '271': 'TR',
|
||||||
|
'272': 'UA', '273': 'RU', '275': 'LV', '276': 'EE', '277': 'LT',
|
||||||
|
'278': 'SI', '279': 'RS',
|
||||||
|
'304': 'AG', '305': 'AG', '308': 'BS', '309': 'BS', '311': 'BS',
|
||||||
|
'312': 'BZ', '314': 'BB', '316': 'CA', '319': 'KY', '321': 'CR',
|
||||||
|
'323': 'CU', '325': 'DM', '327': 'DO', '330': 'GD', '332': 'GT',
|
||||||
|
'334': 'HN', '336': 'HT', '338': 'US', '339': 'JM', '345': 'MX',
|
||||||
|
'351': 'PA', '352': 'PA', '353': 'PA', '354': 'PA', '355': 'PA',
|
||||||
|
'356': 'PA', '357': 'PA', '359': 'SV', '362': 'TT',
|
||||||
|
'366': 'US', '367': 'US', '368': 'US', '369': 'US',
|
||||||
|
'370': 'PA', '371': 'PA', '372': 'PA', '373': 'PA', '374': 'PA',
|
||||||
|
'375': 'VC', '376': 'VC', '377': 'VC',
|
||||||
|
'401': 'AF', '403': 'SA', '405': 'BD', '408': 'BH', '410': 'BT',
|
||||||
|
'412': 'CN', '413': 'CN', '414': 'CN', '416': 'TW', '417': 'LK',
|
||||||
|
'419': 'IN', '422': 'IR', '425': 'IQ', '428': 'IL',
|
||||||
|
'431': 'JP', '432': 'JP', '436': 'KZ',
|
||||||
|
'438': 'JO', '440': 'KR', '441': 'KR', '445': 'KP',
|
||||||
|
'447': 'KW', '450': 'LB', '453': 'MO', '455': 'MV', '457': 'MN',
|
||||||
|
'461': 'OM', '463': 'PK', '466': 'QA', '468': 'SY',
|
||||||
|
'470': 'AE', '473': 'YE', '475': 'YE', '477': 'HK',
|
||||||
|
'503': 'AU', '506': 'MM', '508': 'BN', '512': 'NZ', '514': 'KH',
|
||||||
|
'515': 'KH', '525': 'ID', '533': 'MY', '538': 'MH',
|
||||||
|
'548': 'PH', '563': 'SG', '564': 'SG', '565': 'SG', '566': 'SG',
|
||||||
|
'567': 'TH', '574': 'VN', '576': 'VU', '577': 'VU',
|
||||||
|
'601': 'ZA', '605': 'DZ', '612': 'CF', '613': 'CM', '620': 'EG',
|
||||||
|
'625': 'GH', '632': 'LR', '633': 'LR', '634': 'LR',
|
||||||
|
'635': 'LR', '636': 'LR', '637': 'LR', '657': 'NG',
|
||||||
|
'668': 'ZA', '669': 'ZA',
|
||||||
|
'701': 'AR', '710': 'BR', '720': 'BO', '725': 'CL', '730': 'CO',
|
||||||
|
'735': 'EC', '750': 'GY', '755': 'PY', '760': 'PE',
|
||||||
|
'770': 'UY', '775': 'VE',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MMSI MID 코드 → ISO alpha-2 국가코드 변환
|
||||||
|
* @param {string} nationalCode MMSI MID 코드 (3자리)
|
||||||
|
* @returns {string} ISO alpha-2 코드 또는 원본 코드
|
||||||
|
*/
|
||||||
|
export const getCountryIsoCode = (nationalCode) => {
|
||||||
|
if (!nationalCode) return '';
|
||||||
|
const code = String(nationalCode);
|
||||||
|
return MMSI_TO_ISO[code] || code;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MMSI MID 코드로부터 한글 국가명을 반환
|
* MMSI MID 코드로부터 한글 국가명을 반환
|
||||||
* @param {string} nationalCode MMSI MID 코드 (3자리 문자열)
|
* @param {string} nationalCode MMSI MID 코드 (3자리 문자열)
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user