feat: 항적분석(구역 검색) 기능 구현
구역 기반 선박 항적 검색 기능 추가. 사용자가 지도에 최대 3개 구역을 그리고 ANY/ALL/SEQUENTIAL 조건으로 해당 구역을 통과한 선박의 항적을 조회·재생할 수 있다. 신규 패키지 (src/areaSearch/): - stores: areaSearchStore, areaSearchAnimationStore (재생 제어) - services: areaSearchApi (REST API + hitDetails 타임스탬프/위치 보간) - components: AreaSearchPage, ZoneDrawPanel, AreaSearchTimeline, AreaSearchTooltip - hooks: useAreaSearchLayer (Deck.gl 레이어), useZoneDraw (OL Draw) - utils: areaSearchLayerRegistry, csvExport (BOM+UTF-8 엑셀 호환) - types: areaSearch.types (상수, 색상, 모드) 주요 기능: - 폴리곤/사각형/원 구역 그리기 + 드래그 순서 변경 - 구역별 색상 구분 (빨강/청록/황색) - 시간 기반 애니메이션 재생 (TripsLayer 궤적 + 가상선박 이동) - 선종/개별 선박 필터링, 항적 표시/궤적 표시 토글 - 호버 툴팁 (국기 SVG, 구역별 진입/진출 시각·위치) - CSV 내보내기 (신호원, 식별번호, 국적 ISO 변환, 구역 통과 정보) 기존 파일 수정: - SideNav/Sidebar: gnb8 '항적분석' 메뉴 활성화 - useShipLayer: areaSearch 레이어 병합 - MapContainer: useAreaSearchLayer 훅 + 호버 핸들러 + 타임라인 렌더링 - trackLayer: layerIds 파라미터 추가 (area search/track query 레이어 ID 분리) - ShipLegend: 항적분석 모드 선종 카운트 지원 - countryCodeUtils: MMSI MID→ISO alpha-2 매핑 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
부모
e45be93e71
커밋
dcf24e96d2
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: 'gnb6', className: 'gnb6', label: 'AI모드', path: 'ai' },
|
||||
{ key: 'gnb7', className: 'gnb7', label: '리플레이', path: 'replay' },
|
||||
// { key: 'gnb8', className: 'gnb8', label: '항적조회', path: 'tracking' },
|
||||
{ key: 'gnb8', className: 'gnb8', label: '항적분석', path: 'area-search' },
|
||||
];
|
||||
|
||||
// 필터/레이어 버튼 비활성화 — 선박(gnb1) 버튼에서 DisplayComponent로 통합
|
||||
@ -67,7 +67,7 @@ export const keyToPath = {
|
||||
gnb5: 'timeline',
|
||||
gnb6: 'ai',
|
||||
gnb7: 'replay',
|
||||
gnb8: 'tracking',
|
||||
gnb8: 'area-search',
|
||||
filter: 'filter',
|
||||
layer: 'layer',
|
||||
};
|
||||
|
||||
@ -18,6 +18,7 @@ const Panel8Component = getPanel('Panel8Component');
|
||||
import DisplayComponent from '../../component/wrap/side/DisplayComponent';
|
||||
// 구현된 페이지
|
||||
import ReplayPage from '../../pages/ReplayPage';
|
||||
import AreaSearchPage from '../../areaSearch/components/AreaSearchPage';
|
||||
|
||||
/**
|
||||
* 사이드바 컴포넌트
|
||||
@ -69,7 +70,7 @@ export default function Sidebar() {
|
||||
gnb5: Panel5Component ? <Panel5Component {...panelProps} /> : null,
|
||||
gnb6: Panel6Component ? <Panel6Component {...panelProps} /> : null,
|
||||
gnb7: <ReplayPage {...panelProps} />,
|
||||
gnb8: Panel8Component ? <Panel8Component {...panelProps} /> : null,
|
||||
gnb8: <AreaSearchPage {...panelProps} />,
|
||||
filter: DisplayComponent ? <DisplayComponent {...panelProps} initialTab="filter" /> : null,
|
||||
layer: DisplayComponent ? <DisplayComponent {...panelProps} initialTab="layer" /> : null,
|
||||
};
|
||||
|
||||
@ -3,10 +3,12 @@
|
||||
* - 선박 종류별 아이콘 및 카운트 표시
|
||||
* - 선박 표시 On/Off 토글
|
||||
* - 선박 종류별 필터 토글
|
||||
* - 항적분석 활성 시 결과 카운트 표시
|
||||
*/
|
||||
import { memo } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import useShipStore from '../../stores/shipStore';
|
||||
import { useAreaSearchStore } from '../../areaSearch/stores/areaSearchStore';
|
||||
import {
|
||||
SIGNAL_KIND_CODE_FISHING,
|
||||
SIGNAL_KIND_CODE_KCGV,
|
||||
@ -101,14 +103,49 @@ const ShipLegend = memo(() => {
|
||||
const toggleKindVisibility = useShipStore((state) => state.toggleKindVisibility);
|
||||
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 (
|
||||
<article className="ship-legend">
|
||||
{/* 헤더 - 전체 On/Off */}
|
||||
<div className="legend-header">
|
||||
<div className="legend-title">
|
||||
{isAreaSearchMode ? (
|
||||
<>
|
||||
<span className="connection-status connected" style={{ backgroundColor: '#ff9800' }} />
|
||||
<span>항적 분석</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className={`connection-status ${isConnected ? 'connected' : ''}`} />
|
||||
<span>선박 현황</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isAreaSearchMode && (
|
||||
<label className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
@ -117,6 +154,7 @@ const ShipLegend = memo(() => {
|
||||
/>
|
||||
<span className="slider" />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 선박 종류별 목록 */}
|
||||
@ -126,10 +164,10 @@ const ShipLegend = memo(() => {
|
||||
key={item.code}
|
||||
code={item.code}
|
||||
label={item.label}
|
||||
count={kindCounts[item.code] || 0}
|
||||
count={displayCounts[item.code] || 0}
|
||||
icon={SHIP_KIND_ICONS[item.code]}
|
||||
isVisible={kindVisibility[item.code]}
|
||||
onToggle={toggleKindVisibility}
|
||||
isVisible={isAreaSearchMode ? areaSearchKindFilter.has(item.code) : kindVisibility[item.code]}
|
||||
onToggle={isAreaSearchMode ? toggleAreaSearchKind : toggleKindVisibility}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@ -137,7 +175,7 @@ const ShipLegend = memo(() => {
|
||||
{/* 푸터 - 전체 카운트 */}
|
||||
<div className="legend-footer">
|
||||
<span>전체</span>
|
||||
<span className="total-count">{totalCount}</span>
|
||||
<span className="total-count">{displayTotal}</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
|
||||
@ -17,6 +17,7 @@ import useTrackingModeStore from '../stores/trackingModeStore';
|
||||
import { useMapStore } from '../stores/mapStore';
|
||||
import { getTrackQueryLayers } from '../tracking/utils/trackQueryLayerUtils';
|
||||
import { getReplayLayers } from '../replay/utils/replayLayerRegistry';
|
||||
import { getAreaSearchLayers } from '../areaSearch/utils/areaSearchLayerRegistry';
|
||||
import { shipBatchRenderer } from '../map/ShipBatchRenderer';
|
||||
|
||||
/**
|
||||
@ -139,9 +140,12 @@ export default function useShipLayer(map) {
|
||||
// 리플레이 레이어 (전역 레지스트리)
|
||||
const replayLayers = getReplayLayers();
|
||||
|
||||
// 병합: 선박 + 항적 + 리플레이 레이어
|
||||
// 항적분석 레이어 (전역 레지스트리)
|
||||
const areaSearchLayers = getAreaSearchLayers();
|
||||
|
||||
// 병합: 선박 + 항적 + 리플레이 + 항적분석 레이어
|
||||
deckRef.current.setProps({
|
||||
layers: [...shipLayers, ...trackLayers, ...replayLayers],
|
||||
layers: [...shipLayers, ...trackLayers, ...replayLayers, ...areaSearchLayers],
|
||||
});
|
||||
}, [map, getSelectedShips]);
|
||||
|
||||
|
||||
@ -29,6 +29,14 @@ import { showLiveShips } from '../utils/liveControl';
|
||||
import { useTrackQueryStore } from '../tracking/stores/trackQueryStore';
|
||||
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 useTrackingMode from '../hooks/useTrackingMode';
|
||||
import './measure/measure.scss';
|
||||
@ -64,6 +72,12 @@ export default function MapContainer() {
|
||||
// 리플레이 레이어
|
||||
useReplayLayer();
|
||||
|
||||
// 항적분석 레이어 + 구역 그리기
|
||||
useAreaSearchLayer();
|
||||
useZoneDraw();
|
||||
|
||||
const areaSearchCompleted = useAreaSearchStore((s) => s.queryCompleted);
|
||||
|
||||
// 측정 도구
|
||||
useMeasure();
|
||||
|
||||
@ -133,6 +147,8 @@ export default function MapContainer() {
|
||||
useShipStore.getState().setHoverInfo(null);
|
||||
useTrackQueryStore.getState().setHighlightedVesselId(null);
|
||||
useReplayStore.getState().setHighlightedVesselId(null);
|
||||
useAreaSearchStore.getState().setAreaSearchTooltip(null);
|
||||
useAreaSearchStore.getState().setHighlightedVesselId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -152,12 +168,21 @@ export default function MapContainer() {
|
||||
useTrackQueryStore.getState().setHighlightedVesselId(null);
|
||||
useTrackQueryStore.getState().clearHoveredPoint();
|
||||
useReplayStore.getState().setHighlightedVesselId(null);
|
||||
useAreaSearchStore.getState().setAreaSearchTooltip(null);
|
||||
useAreaSearchStore.getState().setHighlightedVesselId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const layerId = pickResult.layer.id;
|
||||
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') {
|
||||
useShipStore.getState().setHoverInfo({ ship: obj, x: clientX, y: clientY });
|
||||
@ -212,6 +237,32 @@ export default function MapContainer() {
|
||||
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)
|
||||
if (layerId === 'track-path-layer') {
|
||||
useShipStore.getState().setHoverInfo(null);
|
||||
@ -266,6 +317,8 @@ export default function MapContainer() {
|
||||
useTrackQueryStore.getState().setHighlightedVesselId(null);
|
||||
useTrackQueryStore.getState().clearHoveredPoint();
|
||||
useReplayStore.getState().setHighlightedVesselId(null);
|
||||
useAreaSearchStore.getState().setAreaSearchTooltip(null);
|
||||
useAreaSearchStore.getState().setHighlightedVesselId(null);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
@ -452,6 +505,8 @@ export default function MapContainer() {
|
||||
<ShipContextMenu />
|
||||
<GlobalTrackQueryViewer />
|
||||
<ReplayLoadingOverlay />
|
||||
{areaSearchCompleted && <AreaSearchTooltip />}
|
||||
{areaSearchCompleted && <AreaSearchTimeline />}
|
||||
{replayCompleted && (
|
||||
<ReplayTimeline
|
||||
fromDate={replayQuery?.startTime}
|
||||
|
||||
@ -40,10 +40,13 @@ const MAX_POINTS_PER_TRACK = 800;
|
||||
* @param {string} [params.highlightedVesselId] - 하이라이트할 선박 ID
|
||||
* @param {Function} [params.onPathHover] - 항적 호버 콜백
|
||||
*/
|
||||
export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselId, onPathHover }) {
|
||||
export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselId, onPathHover, layerIds }) {
|
||||
const layers = [];
|
||||
if (!tracks || tracks.length === 0) return layers;
|
||||
|
||||
const pathId = layerIds?.path || 'track-path-layer';
|
||||
const pointId = layerIds?.point || 'track-point-layer';
|
||||
|
||||
// 1. PathLayer - 전체 경로 (시간 무관)
|
||||
const pathData = tracks.map((track) => ({
|
||||
path: track.geometry,
|
||||
@ -53,7 +56,7 @@ export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselI
|
||||
|
||||
layers.push(
|
||||
new PathLayer({
|
||||
id: 'track-path-layer',
|
||||
id: pathId,
|
||||
data: pathData,
|
||||
getPath: (d) => d.path,
|
||||
getColor: (d) => {
|
||||
@ -71,7 +74,7 @@ export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselI
|
||||
return 2;
|
||||
},
|
||||
widthUnits: 'pixels',
|
||||
widthMinPixels: 1,
|
||||
widthMinPixels: 4,
|
||||
widthMaxPixels: 8,
|
||||
jointRounded: true,
|
||||
capRounded: true,
|
||||
@ -118,7 +121,7 @@ export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselI
|
||||
|
||||
layers.push(
|
||||
new ScatterplotLayer({
|
||||
id: 'track-point-layer',
|
||||
id: pointId,
|
||||
data: pointData,
|
||||
getPosition: (d) => d.position,
|
||||
getFillColor: (d) => d.color,
|
||||
@ -146,16 +149,19 @@ export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselI
|
||||
* @param {Function} [params.onPathHover] - 하이라이트 설정용 콜백
|
||||
* @returns {Array} Deck.gl Layer 배열
|
||||
*/
|
||||
export function createVirtualShipLayers({ currentPositions, showVirtualShip, showLabels, onIconHover, onPathHover }) {
|
||||
export function createVirtualShipLayers({ currentPositions, showVirtualShip, showLabels, onIconHover, onPathHover, layerIds }) {
|
||||
const 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 - 가상 선박 아이콘
|
||||
if (showVirtualShip) {
|
||||
layers.push(
|
||||
new IconLayer({
|
||||
id: 'track-virtual-ship-layer',
|
||||
id: iconId,
|
||||
data: currentPositions,
|
||||
iconAtlas: atlasImg,
|
||||
iconMapping: ICON_ATLAS_MAPPING,
|
||||
@ -197,7 +203,7 @@ export function createVirtualShipLayers({ currentPositions, showVirtualShip, sho
|
||||
|
||||
layers.push(
|
||||
new TextLayer({
|
||||
id: 'track-label-layer',
|
||||
id: labelId,
|
||||
data: labelData,
|
||||
getPosition: (d) => [d.lon, d.lat],
|
||||
getText: (d) => d.shipName,
|
||||
|
||||
@ -304,6 +304,61 @@ export const MMSI_COUNTRY_NAMES = {
|
||||
'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 코드로부터 한글 국가명을 반환
|
||||
* @param {string} nationalCode MMSI MID 코드 (3자리 문자열)
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user