From dcf24e96d29b0e891658b88ffad6a86e26fd24ae Mon Sep 17 00:00:00 2001 From: LHT Date: Tue, 10 Feb 2026 12:29:31 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=95=AD=EC=A0=81=EB=B6=84=EC=84=9D(?= =?UTF-8?q?=EA=B5=AC=EC=97=AD=20=EA=B2=80=EC=83=89)=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 구역 기반 선박 항적 검색 기능 추가. 사용자가 지도에 최대 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 --- src/areaSearch/components/AreaSearchPage.jsx | 383 ++++++++++++++++++ src/areaSearch/components/AreaSearchPage.scss | 314 ++++++++++++++ .../components/AreaSearchTimeline.jsx | 245 +++++++++++ .../components/AreaSearchTimeline.scss | 362 +++++++++++++++++ .../components/AreaSearchTooltip.jsx | 115 ++++++ .../components/AreaSearchTooltip.scss | 101 +++++ src/areaSearch/components/ZoneDrawPanel.jsx | 135 ++++++ src/areaSearch/components/ZoneDrawPanel.scss | 136 +++++++ src/areaSearch/hooks/useAreaSearchLayer.js | 205 ++++++++++ src/areaSearch/hooks/useZoneDraw.js | 258 ++++++++++++ src/areaSearch/services/areaSearchApi.js | 112 +++++ .../stores/areaSearchAnimationStore.js | 117 ++++++ src/areaSearch/stores/areaSearchStore.js | 240 +++++++++++ src/areaSearch/types/areaSearch.types.js | 94 +++++ .../utils/areaSearchLayerRegistry.js | 19 + src/areaSearch/utils/csvExport.js | 112 +++++ src/components/layout/SideNav.jsx | 4 +- src/components/layout/Sidebar.jsx | 3 +- src/components/ship/ShipLegend.jsx | 68 +++- src/hooks/useShipLayer.js | 8 +- src/map/MapContainer.jsx | 55 +++ src/map/layers/trackLayer.js | 20 +- .../utils/countryCodeUtils.js | 55 +++ 23 files changed, 3134 insertions(+), 27 deletions(-) create mode 100644 src/areaSearch/components/AreaSearchPage.jsx create mode 100644 src/areaSearch/components/AreaSearchPage.scss create mode 100644 src/areaSearch/components/AreaSearchTimeline.jsx create mode 100644 src/areaSearch/components/AreaSearchTimeline.scss create mode 100644 src/areaSearch/components/AreaSearchTooltip.jsx create mode 100644 src/areaSearch/components/AreaSearchTooltip.scss create mode 100644 src/areaSearch/components/ZoneDrawPanel.jsx create mode 100644 src/areaSearch/components/ZoneDrawPanel.scss create mode 100644 src/areaSearch/hooks/useAreaSearchLayer.js create mode 100644 src/areaSearch/hooks/useZoneDraw.js create mode 100644 src/areaSearch/services/areaSearchApi.js create mode 100644 src/areaSearch/stores/areaSearchAnimationStore.js create mode 100644 src/areaSearch/stores/areaSearchStore.js create mode 100644 src/areaSearch/types/areaSearch.types.js create mode 100644 src/areaSearch/utils/areaSearchLayerRegistry.js create mode 100644 src/areaSearch/utils/csvExport.js diff --git a/src/areaSearch/components/AreaSearchPage.jsx b/src/areaSearch/components/AreaSearchPage.jsx new file mode 100644 index 00000000..f42111b1 --- /dev/null +++ b/src/areaSearch/components/AreaSearchPage.jsx @@ -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 ( + + ); +} diff --git a/src/areaSearch/components/AreaSearchPage.scss b/src/areaSearch/components/AreaSearchPage.scss new file mode 100644 index 00000000..5445e152 --- /dev/null +++ b/src/areaSearch/components/AreaSearchPage.scss @@ -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; + } + } + } + } + } + } + } +} diff --git a/src/areaSearch/components/AreaSearchTimeline.jsx b/src/areaSearch/components/AreaSearchTimeline.jsx new file mode 100644 index 00000000..193aa682 --- /dev/null +++ b/src/areaSearch/components/AreaSearchTimeline.jsx @@ -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 ( +
+
+
+ 항적 분석 +
+ +
+ +
+
+ + {showSpeedMenu && ( +
+ {PLAYBACK_SPEED_OPTIONS.map((speed) => ( + + ))} +
+ )} +
+ + + + + +
+ +
+ + + {hasData ? formatDateTime(currentTime) : '--:--:--'} + + + + + +
+
+ ); +} diff --git a/src/areaSearch/components/AreaSearchTimeline.scss b/src/areaSearch/components/AreaSearchTimeline.scss new file mode 100644 index 00000000..dd734b98 --- /dev/null +++ b/src/areaSearch/components/AreaSearchTimeline.scss @@ -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); + } +} diff --git a/src/areaSearch/components/AreaSearchTooltip.jsx b/src/areaSearch/components/AreaSearchTooltip.jsx new file mode 100644 index 00000000..82b6b063 --- /dev/null +++ b/src/areaSearch/components/AreaSearchTooltip.jsx @@ -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 ( +
+
+ {kindName} + {flagUrl && ( + + 국기 { e.target.style.display = 'none'; }} + /> + + )} + + {track.shipName || track.targetId || '-'} + +
+
+ {sourceName} +
+ + {zones.length > 0 && hits.length > 0 && ( +
+ {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 ( +
+ + {zone.name} + +
+ IN + {formatTimestamp(hit.entryTimestamp)} + {entryPos && ( + {entryPos} + )} +
+
+ OUT + {formatTimestamp(hit.exitTimestamp)} + {exitPos && ( + {exitPos} + )} +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/areaSearch/components/AreaSearchTooltip.scss b/src/areaSearch/components/AreaSearchTooltip.scss new file mode 100644 index 00000000..1dc07409 --- /dev/null +++ b/src/areaSearch/components/AreaSearchTooltip.scss @@ -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; + } +} diff --git a/src/areaSearch/components/ZoneDrawPanel.jsx b/src/areaSearch/components/ZoneDrawPanel.jsx new file mode 100644 index 00000000..f642309c --- /dev/null +++ b/src/areaSearch/components/ZoneDrawPanel.jsx @@ -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 ( +
+

구역 설정

+

{zones.length}/{MAX_ZONES}개 설정됨

+ + {/* 그리기 버튼 */} +
+ + + +
+ + {activeDrawType && ( +

지도에서 구역을 그려주세요. (ESC: 취소)

+ )} + + {/* 구역 목록 */} + {zones.length > 0 && ( +
    + {zones.map((zone, index) => { + const color = ZONE_COLORS[zone.colorIndex] || ZONE_COLORS[0]; + return ( +
  • handleDragStart(e, index)} + onDragOver={(e) => handleDragOver(e, index)} + onDrop={(e) => handleDrop(e, index)} + onDragEnd={handleDragEnd} + > + + + 구역 {zone.name} + {zone.type} + +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/src/areaSearch/components/ZoneDrawPanel.scss b/src/areaSearch/components/ZoneDrawPanel.scss new file mode 100644 index 00000000..519ff885 --- /dev/null +++ b/src/areaSearch/components/ZoneDrawPanel.scss @@ -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; + } + } + } + } +} diff --git a/src/areaSearch/hooks/useAreaSearchLayer.js b/src/areaSearch/hooks/useAreaSearchLayer.js new file mode 100644 index 00000000..3637ddc5 --- /dev/null +++ b/src/areaSearch/hooks/useAreaSearchLayer.js @@ -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 = []; + }; + }, []); +} diff --git a/src/areaSearch/hooks/useZoneDraw.js b/src/areaSearch/hooks/useZoneDraw.js new file mode 100644 index 00000000..147a50cb --- /dev/null +++ b/src/areaSearch/hooks/useZoneDraw.js @@ -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; + }, []); +} diff --git a/src/areaSearch/services/areaSearchApi.js b/src/areaSearch/services/areaSearchApi.js new file mode 100644 index 00000000..94e393da --- /dev/null +++ b/src/areaSearch/services/areaSearchApi.js @@ -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, + }; +} diff --git a/src/areaSearch/stores/areaSearchAnimationStore.js b/src/areaSearch/stores/areaSearchAnimationStore.js new file mode 100644 index 00000000..ed542c1a --- /dev/null +++ b/src/areaSearch/stores/areaSearchAnimationStore.js @@ -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, + }); + }, + }; +})); diff --git a/src/areaSearch/stores/areaSearchStore.js b/src/areaSearch/stores/areaSearchStore.js new file mode 100644 index 00000000..0d3ce9b6 --- /dev/null +++ b/src/areaSearch/stores/areaSearchStore.js @@ -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; diff --git a/src/areaSearch/types/areaSearch.types.js b/src/areaSearch/types/areaSearch.types.js new file mode 100644 index 00000000..820b3216 --- /dev/null +++ b/src/areaSearch/types/areaSearch.types.js @@ -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', +}; diff --git a/src/areaSearch/utils/areaSearchLayerRegistry.js b/src/areaSearch/utils/areaSearchLayerRegistry.js new file mode 100644 index 00000000..1ac952e4 --- /dev/null +++ b/src/areaSearch/utils/areaSearchLayerRegistry.js @@ -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__ = []; +} diff --git a/src/areaSearch/utils/csvExport.js b/src/areaSearch/utils/csvExport.js new file mode 100644 index 00000000..9b01f486 --- /dev/null +++ b/src/areaSearch/utils/csvExport.js @@ -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); +} diff --git a/src/components/layout/SideNav.jsx b/src/components/layout/SideNav.jsx index 5611e1ab..2c46f1e7 100644 --- a/src/components/layout/SideNav.jsx +++ b/src/components/layout/SideNav.jsx @@ -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', }; diff --git a/src/components/layout/Sidebar.jsx b/src/components/layout/Sidebar.jsx index 1702c55a..c20389d5 100644 --- a/src/components/layout/Sidebar.jsx +++ b/src/components/layout/Sidebar.jsx @@ -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 ? : null, gnb6: Panel6Component ? : null, gnb7: , - gnb8: Panel8Component ? : null, + gnb8: , filter: DisplayComponent ? : null, layer: DisplayComponent ? : null, }; diff --git a/src/components/ship/ShipLegend.jsx b/src/components/ship/ShipLegend.jsx index 1bb15649..237f184d 100644 --- a/src/components/ship/ShipLegend.jsx +++ b/src/components/ship/ShipLegend.jsx @@ -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,22 +103,58 @@ 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 (
{/* 헤더 - 전체 On/Off */}
- - 선박 현황 + {isAreaSearchMode ? ( + <> + + 항적 분석 + + ) : ( + <> + + 선박 현황 + + )}
- + {!isAreaSearchMode && ( + + )}
{/* 선박 종류별 목록 */} @@ -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} /> ))} @@ -137,7 +175,7 @@ const ShipLegend = memo(() => { {/* 푸터 - 전체 카운트 */}
전체 - {totalCount} + {displayTotal}
); diff --git a/src/hooks/useShipLayer.js b/src/hooks/useShipLayer.js index 80c05716..10c36678 100644 --- a/src/hooks/useShipLayer.js +++ b/src/hooks/useShipLayer.js @@ -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]); diff --git a/src/map/MapContainer.jsx b/src/map/MapContainer.jsx index 5aefd676..36da42d5 100644 --- a/src/map/MapContainer.jsx +++ b/src/map/MapContainer.jsx @@ -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() { + {areaSearchCompleted && } + {areaSearchCompleted && } {replayCompleted && ( ({ 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, diff --git a/src/replay/components/VesselListManager/utils/countryCodeUtils.js b/src/replay/components/VesselListManager/utils/countryCodeUtils.js index 8ce2ac72..1275c173 100644 --- a/src/replay/components/VesselListManager/utils/countryCodeUtils.js +++ b/src/replay/components/VesselListManager/utils/countryCodeUtils.js @@ -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자리 문자열)