diff --git a/.yarn-offline-cache/base64-arraybuffer-1.0.2.tgz b/.yarn-offline-cache/base64-arraybuffer-1.0.2.tgz new file mode 100644 index 00000000..e5a8a535 Binary files /dev/null and b/.yarn-offline-cache/base64-arraybuffer-1.0.2.tgz differ diff --git a/.yarn-offline-cache/css-line-break-2.1.0.tgz b/.yarn-offline-cache/css-line-break-2.1.0.tgz new file mode 100644 index 00000000..17cac964 Binary files /dev/null and b/.yarn-offline-cache/css-line-break-2.1.0.tgz differ diff --git a/.yarn-offline-cache/html2canvas-1.4.1.tgz b/.yarn-offline-cache/html2canvas-1.4.1.tgz new file mode 100644 index 00000000..ad08c747 Binary files /dev/null and b/.yarn-offline-cache/html2canvas-1.4.1.tgz differ diff --git a/.yarn-offline-cache/text-segmentation-1.0.3.tgz b/.yarn-offline-cache/text-segmentation-1.0.3.tgz new file mode 100644 index 00000000..a787483d Binary files /dev/null and b/.yarn-offline-cache/text-segmentation-1.0.3.tgz differ diff --git a/.yarn-offline-cache/utrie-1.0.2.tgz b/.yarn-offline-cache/utrie-1.0.2.tgz new file mode 100644 index 00000000..dfd73c8a Binary files /dev/null and b/.yarn-offline-cache/utrie-1.0.2.tgz differ diff --git a/package.json b/package.json index 24dc4187..ef7b3b1e 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@stomp/stompjs": "^7.2.1", "axios": "^1.4.0", "dayjs": "^1.11.11", + "html2canvas": "^1.4.1", "ol": "^9.2.4", "ol-ext": "^4.0.10", "react": "^18.2.0", diff --git a/src/App.jsx b/src/App.jsx index eb48247b..e41d457c 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -4,6 +4,7 @@ import { lazy, Suspense } from 'react'; // 구현 영역 - 레이아웃 import MainLayout from './components/layout/MainLayout'; import { ToastContainer } from './components/common/Toast'; +import { AlertModalContainer } from './components/common/AlertModal'; // 퍼블리시 영역 (개발 환경에서만 동적 로드) // 프로덕션 빌드 시 tree-shaking으로 제외됨 @@ -23,6 +24,7 @@ export default function App() { return ( <> + {/* ===================== 구현 영역 (메인) diff --git a/src/areaSearch/components/AreaSearchPage.jsx b/src/areaSearch/components/AreaSearchPage.jsx index f42111b1..47e88013 100644 --- a/src/areaSearch/components/AreaSearchPage.jsx +++ b/src/areaSearch/components/AreaSearchPage.jsx @@ -1,19 +1,18 @@ import { useState, useEffect, useCallback } from 'react'; import './AreaSearchPage.scss'; import { useAreaSearchStore } from '../stores/areaSearchStore'; +import { useStsStore } from '../stores/stsStore'; 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 { fetchVesselContacts } from '../services/stsApi'; +import { QUERY_MAX_DAYS, getQueryDateRange, ANALYSIS_TABS } from '../types/areaSearch.types'; import { showToast } from '../../components/common/Toast'; import { hideLiveShips, showLiveShips } from '../../utils/liveControl'; -import ZoneDrawPanel from './ZoneDrawPanel'; -import { exportSearchResultToCSV } from '../utils/csvExport'; +import { unregisterAreaSearchLayers } from '../utils/areaSearchLayerRegistry'; +import { unregisterStsLayers } from '../utils/stsLayerRegistry'; +import LoadingOverlay from '../../components/common/LoadingOverlay'; +import AreaSearchTab from './AreaSearchTab'; +import StsAnalysisTab from './StsAnalysisTab'; const DAYS_TO_MS = 24 * 60 * 60 * 1000; @@ -30,19 +29,18 @@ export default function AreaSearchPage({ isOpen, onToggle }) { 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 activeTab = useAreaSearchStore((s) => s.activeTab); + const areaLoading = useAreaSearchStore((s) => s.isLoading); + const areaQueryCompleted = useAreaSearchStore((s) => s.queryCompleted); - const setSearchMode = useAreaSearchStore((s) => s.setSearchMode); + const stsLoading = useStsStore((s) => s.isLoading); + const stsQueryCompleted = useStsStore((s) => s.queryCompleted); const setTimeRange = useAreaSearchAnimationStore((s) => s.setTimeRange); + const isLoading = activeTab === ANALYSIS_TABS.AREA ? areaLoading : stsLoading; + const queryCompleted = activeTab === ANALYSIS_TABS.AREA ? areaQueryCompleted : stsQueryCompleted; + // 기간 초기화 (D-7 ~ D-1) useEffect(() => { const { startDate: sDate, endDate: eDate } = getQueryDateRange(); @@ -52,17 +50,56 @@ export default function AreaSearchPage({ isOpen, onToggle }) { setEndTime('23:59'); }, []); - // 패널 닫힘 시 정리 + // 패널 닫힘 시 정리 (isOpen=false 감지, activeTab 보존) useEffect(() => { - return () => { - const { queryCompleted: completed } = useAreaSearchStore.getState(); - if (completed) { - useAreaSearchStore.getState().reset(); - useAreaSearchAnimationStore.getState().reset(); - showLiveShips(); + if (isOpen) return; + const areaState = useAreaSearchStore.getState(); + const stsState = useStsStore.getState(); + if (areaState.queryCompleted || stsState.queryCompleted) { + areaState.clearResults(); + stsState.clearResults(); + useAreaSearchAnimationStore.getState().reset(); + unregisterAreaSearchLayers(); + unregisterStsLayers(); + showLiveShips(); + } + }, [isOpen]); + + // ========== 탭 전환 ========== + + const handleTabChange = useCallback((newTab) => { + if (newTab === activeTab) return; + + const areaState = useAreaSearchStore.getState(); + const stsState = useStsStore.getState(); + + // 현재 탭 결과가 있으면 초기화 확인 + const hasResults = activeTab === ANALYSIS_TABS.AREA + ? areaState.queryCompleted + : stsState.queryCompleted; + + if (hasResults) { + const confirmed = window.confirm('탭을 전환하면 현재 결과가 초기화됩니다.\n계속하시겠습니까?'); + if (!confirmed) return; + + if (activeTab === ANALYSIS_TABS.AREA) { + areaState.clearResults(); + unregisterAreaSearchLayers(); + } else { + stsState.clearResults(); + unregisterStsLayers(); } - }; - }, []); + useAreaSearchAnimationStore.getState().reset(); + showLiveShips(); + } + + // 탭 전환 시 zones 초기화 (이전 탭 구역이 남지 않도록) + areaState.clearZones(); + setErrorMessage(''); + areaState.setActiveTab(newTab); + }, [activeTab]); + + // ========== 날짜 핸들러 ========== const handleStartDateChange = useCallback((newStartDate) => { setStartDate(newStartDate); @@ -104,9 +141,12 @@ export default function AreaSearchPage({ isOpen, onToggle }) { } }, [startDate, startTime, endTime]); - const executeQuery = useCallback(async () => { + // ========== 조회 실행 ========== + + const executeAreaSearch = useCallback(async () => { const from = new Date(`${startDate}T${startTime}:00`); const to = new Date(`${endDate}T${endTime}:00`); + const searchMode = useAreaSearchStore.getState().searchMode; try { setErrorMessage(''); @@ -125,22 +165,26 @@ export default function AreaSearchPage({ isOpen, onToggle }) { polygons, }); + if (result.tracks.length === 0) { + useAreaSearchStore.getState().setLoading(false); + showToast('조회 결과가 없습니다.'); + return; + } + 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(); - } + 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) { @@ -148,7 +192,58 @@ export default function AreaSearchPage({ isOpen, onToggle }) { useAreaSearchStore.getState().setLoading(false); setErrorMessage(`조회 실패: ${error.message}`); } - }, [startDate, startTime, endDate, endTime, zones, searchMode, setTimeRange]); + }, [startDate, startTime, endDate, endTime, zones, setTimeRange]); + + const executeStsSearch = useCallback(async () => { + const from = new Date(`${startDate}T${startTime}:00`); + const to = new Date(`${endDate}T${endTime}:00`); + const stsState = useStsStore.getState(); + + try { + setErrorMessage(''); + stsState.setLoading(true); + + const zone = zones[0]; + const polygon = { + id: zone.id, + name: zone.name, + coordinates: zone.coordinates, + }; + + const result = await fetchVesselContacts({ + startTime: toKstISOString(from), + endTime: toKstISOString(to), + polygon, + minContactDurationMinutes: stsState.minContactDurationMinutes, + maxContactDistanceMeters: stsState.maxContactDistanceMeters, + }); + + if (result.contacts.length === 0) { + stsState.setLoading(false); + showToast('접촉 의심 쌍이 없습니다.'); + return; + } + + stsState.setResults(result); + + 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(); + + stsState.setLoading(false); + } catch (error) { + console.error('[STS] 조회 실패:', error); + useStsStore.getState().setLoading(false); + setErrorMessage(`조회 실패: ${error.message}`); + } + }, [startDate, startTime, endDate, endTime, zones, setTimeRange]); const handleQuery = useCallback(async () => { if (!startDate || !endDate) { @@ -173,39 +268,35 @@ export default function AreaSearchPage({ isOpen, onToggle }) { } // 기존 조회 결과가 있으면 초기화 확인 - const { queryCompleted: hasExisting } = useAreaSearchStore.getState(); - if (hasExisting) { + if (queryCompleted) { const confirmed = window.confirm('이전 조회 정보가 초기화됩니다.\n새로운 조건으로 다시 조회하시겠습니까?'); if (!confirmed) return; - // 기존 결과 즉시 클리어 (queryCompleted: false → 레이어 해제 + 타임라인 숨김) - useAreaSearchStore.getState().clearResults(); + if (activeTab === ANALYSIS_TABS.AREA) { + useAreaSearchStore.getState().clearResults(); + } else { + useStsStore.getState().clearResults(); + } useAreaSearchAnimationStore.getState().reset(); - // showLiveShips() 호출하지 않음 - 라이브 비활성 유지 } - executeQuery(); - }, [startDate, startTime, endDate, endTime, zones, searchMode, executeQuery]); + if (activeTab === ANALYSIS_TABS.AREA) { + executeAreaSearch(); + } else { + executeStsSearch(); + } + }, [startDate, startTime, endDate, endTime, zones, activeTab, queryCompleted, executeAreaSearch, executeStsSearch]); const handleReset = useCallback(() => { useAreaSearchStore.getState().reset(); + useStsStore.getState().reset(); useAreaSearchAnimationStore.getState().reset(); + unregisterAreaSearchLayers(); + unregisterStsLayers(); 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 index 5445e152..d705aaeb 100644 --- a/src/areaSearch/components/AreaSearchPage.scss +++ b/src/areaSearch/components/AreaSearchPage.scss @@ -36,7 +36,7 @@ padding: 0 2rem 2rem 2rem; overflow-y: auto; - // 조회 기간 + // 조회 기간 (리플레이와 동일) .query-section { background-color: var(--secondary1, rgba(255, 255, 255, 0.05)); border-radius: 0.6rem; @@ -47,13 +47,7 @@ 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; + margin-bottom: 1.5rem; } .query-row { @@ -109,79 +103,119 @@ } } } - } - // 검색 모드 - .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 { + .btnBox { display: flex; - flex-direction: column; - gap: 0.8rem; + justify-content: flex-end; + margin-top: 2rem; + gap: 1rem; - .mode-radio { - display: flex; - align-items: center; - gap: 0.8rem; - cursor: pointer; + .btn { + min-width: 12rem; + padding: 1rem 2rem; + border-radius: 0.4rem; font-size: var(--fs-s, 1.2rem); - color: var(--tertiary4, #ccc); + font-weight: var(--fw-semibold, 600); + cursor: pointer; + transition: all 0.2s; + border: none; - input[type='radio'] { - accent-color: var(--primary1, #4a9eff); + &.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; + } } - &:has(input:checked) span { - color: var(--white, #fff); + &.btn-query { + min-width: 14rem; } } } } - // 조회 버튼 - .btnBox { + // 탭 바 (segmented control) + .analysis-tab-bar { display: flex; - justify-content: flex-end; margin-bottom: 1.2rem; + background-color: var(--tertiary1, rgba(0, 0, 0, 0.3)); + border-radius: 0.6rem; + padding: 0.3rem; + gap: 0.3rem; - .btn { - min-width: 12rem; - padding: 1rem 2rem; + .analysis-tab { + flex: 1; + padding: 0.7rem 0; + border: none; border-radius: 0.4rem; + background: transparent; + color: var(--tertiary4, #999); font-size: var(--fs-s, 1.2rem); font-weight: var(--fw-semibold, 600); cursor: pointer; - transition: all 0.2s; - border: none; + transition: all 0.15s; - &.btn-primary { - background-color: var(--primary1, #4a9eff); + &:hover:not(:disabled) { 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; + &.active { + background-color: rgba(74, 158, 255, 0.2); + color: var(--white, #fff); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + } + } + + // 검색 모드 (칩 스타일 + 구역 설정과 동일 배경) + .search-mode { + background-color: var(--secondary1, rgba(255, 255, 255, 0.05)); + border-radius: 0.6rem; + padding: 1.2rem 1.5rem; + margin-bottom: 1.2rem; + display: flex; + align-items: center; + justify-content: center; + gap: 0.6rem; + + .mode-chip { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.5rem 1.2rem; + border: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.2)); + border-radius: 2rem; + background: transparent; + cursor: pointer; + font-size: var(--fs-xs, 1.1rem); + color: var(--tertiary4, #ccc); + transition: all 0.15s; + + input[type='radio'] { + display: none; + } + + &:hover { + border-color: var(--primary1, rgba(255, 255, 255, 0.4)); + color: var(--white, #fff); + } + + &.active { + border-color: var(--primary1, #4a9eff); + background-color: rgba(74, 158, 255, 0.15); + color: var(--white, #fff); } } } @@ -194,7 +228,7 @@ flex-direction: column; background-color: var(--tertiary1, rgba(0, 0, 0, 0.2)); border-radius: 0.6rem; - padding: 1.5rem; + padding: 1.2rem; overflow-y: auto; &:has(> .loading-message), @@ -221,38 +255,47 @@ 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%; + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; .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); + justify-content: space-between; + margin-bottom: 0.8rem; + flex-shrink: 0; - .processing-time { + .result-summary-text { + 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); + margin-left: 0.4rem; + } + } + + .btn-csv { + 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); - font-weight: normal; - color: var(--tertiary4, #999); + cursor: pointer; + flex-shrink: 0; + + &:hover { + border-color: var(--primary1, #4a9eff); + color: var(--white, #fff); + } } } @@ -260,8 +303,12 @@ list-style: none; padding: 0; margin: 0; + flex: 1; + overflow-y: auto; .vessel-item { + display: flex; + align-items: center; border-bottom: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.1)); &.highlighted { @@ -276,8 +323,9 @@ display: flex; align-items: center; gap: 0.8rem; - width: 100%; - padding: 1rem 0.4rem; + flex: 1; + min-width: 0; + padding: 0.8rem 0.4rem; background: none; border: none; cursor: pointer; @@ -304,6 +352,33 @@ font-size: var(--fs-xs, 1.1rem); color: var(--tertiary4, #999); flex-shrink: 0; + + .visit-count { + margin-left: 0.4rem; + color: var(--primary1, #4a9eff); + font-weight: var(--fw-semibold, 600); + } + } + } + + .vessel-detail-btn { + flex-shrink: 0; + width: 2.4rem; + height: 2.4rem; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--tertiary4, #999); + font-size: 1rem; + cursor: pointer; + border-radius: 0.3rem; + transition: all 0.15s; + + &:hover { + background-color: rgba(255, 255, 255, 0.1); + color: var(--white, #fff); } } } diff --git a/src/areaSearch/components/AreaSearchTab.jsx b/src/areaSearch/components/AreaSearchTab.jsx new file mode 100644 index 00000000..39a6d1e3 --- /dev/null +++ b/src/areaSearch/components/AreaSearchTab.jsx @@ -0,0 +1,188 @@ +/** + * 구역분석 탭 컴포넌트 + * + * AreaSearchPage에서 추출된 구역분석 전용 UI: + * - ZoneDrawPanel (구역 설정) + * - 검색 모드 (ANY / ALL / SEQUENTIAL) + * - 결과 영역 (선박 리스트, 요약, CSV 내보내기) + * - VesselDetailModal + */ +import { useState, useCallback, useEffect, useRef } from 'react'; +import { useAreaSearchStore } from '../stores/areaSearchStore'; +import { + SEARCH_MODE_LABELS, +} from '../types/areaSearch.types'; +import { getShipKindColor, getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types'; +import ZoneDrawPanel from './ZoneDrawPanel'; +import VesselDetailModal from './VesselDetailModal'; +import { exportSearchResultToCSV } from '../utils/csvExport'; + +export default function AreaSearchTab({ isLoading, errorMessage }) { + const [detailVesselId, setDetailVesselId] = useState(null); + + 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 queryCompleted = useAreaSearchStore((s) => s.queryCompleted); + const disabledVesselIds = useAreaSearchStore((s) => s.disabledVesselIds); + const highlightedVesselId = useAreaSearchStore((s) => s.highlightedVesselId); + const setSearchMode = useAreaSearchStore((s) => s.setSearchMode); + + 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]); + + const listRef = useRef(null); + + useEffect(() => { + if (!highlightedVesselId || !listRef.current) return; + const el = listRef.current.querySelector('.vessel-item.highlighted'); + if (!el) return; + + const container = listRef.current; + const elRect = el.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + + if (elRect.top >= containerRect.top && elRect.bottom <= containerRect.bottom) return; + container.scrollTop += (elRect.top - containerRect.top); + }, [highlightedVesselId]); + + return ( + <> + {/* 구역 설정 */} + + + {/* 검색 모드 */} +
+ {Object.entries(SEARCH_MODE_LABELS).map(([mode, label]) => ( + + ))} +
+ + {/* 결과 영역 */} +
+ {errorMessage &&
{errorMessage}
} + + {isLoading &&
데이터를 불러오는 중입니다...
} + + {queryCompleted && tracks.length > 0 && ( +
+
+ + 검색결과: {summary?.totalVessels ?? tracks.length}척 + {summary?.processingTimeMs != null && ( + + ({(summary.processingTimeMs / 1000).toFixed(2)}초) + + )} + + +
+ +
    + {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]})`; + const vesselHits = hitDetails[track.vesselId] || []; + const totalVisits = vesselHits.length; + const hasRevisits = totalVisits > zones.length; + + return ( +
  • { + handleHighlightVessel(track.vesselId); + const rect = e.currentTarget.getBoundingClientRect(); + useAreaSearchStore.getState().setAreaSearchTooltip({ + vesselId: track.vesselId, + x: rect.right + 8, + y: rect.top, + }); + }} + onMouseLeave={() => { + handleHighlightVessel(null); + useAreaSearchStore.getState().setAreaSearchTooltip(null); + }} + > + + +
  • + ); + })} +
+
+ )} + + {queryCompleted && tracks.length === 0 && !errorMessage && ( +
조건에 맞는 선박이 없습니다.
+ )} + + {!isLoading && !queryCompleted && !errorMessage && ( +
+ 구역을 설정하고 조회 버튼을 클릭하세요. +
+ )} +
+ + {detailVesselId && ( + setDetailVesselId(null)} + /> + )} + + ); +} diff --git a/src/areaSearch/components/AreaSearchTimeline.jsx b/src/areaSearch/components/AreaSearchTimeline.jsx index 193aa682..cc948085 100644 --- a/src/areaSearch/components/AreaSearchTimeline.jsx +++ b/src/areaSearch/components/AreaSearchTimeline.jsx @@ -10,10 +10,12 @@ import { useCallback, useEffect, useRef, useState, useMemo } from 'react'; import { useAreaSearchAnimationStore } from '../stores/areaSearchAnimationStore'; import { useAreaSearchStore } from '../stores/areaSearchStore'; +import { useStsStore } from '../stores/stsStore'; import { unregisterAreaSearchLayers } from '../utils/areaSearchLayerRegistry'; +import { unregisterStsLayers } from '../utils/stsLayerRegistry'; import { showLiveShips } from '../../utils/liveControl'; import { shipBatchRenderer } from '../../map/ShipBatchRenderer'; -import { PLAYBACK_SPEED_OPTIONS } from '../types/areaSearch.types'; +import { PLAYBACK_SPEED_OPTIONS, ANALYSIS_TABS } from '../types/areaSearch.types'; import './AreaSearchTimeline.scss'; const PATH_LABEL = '항적'; @@ -39,10 +41,26 @@ export default function AreaSearchTimeline() { 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 activeTab = useAreaSearchStore((s) => s.activeTab); + const isSts = activeTab === ANALYSIS_TABS.STS; + + const areaShowPaths = useAreaSearchStore((s) => s.showPaths); + const areaShowTrail = useAreaSearchStore((s) => s.showTrail); + const stsShowPaths = useStsStore((s) => s.showPaths); + const stsShowTrail = useStsStore((s) => s.showTrail); + + const showPaths = isSts ? stsShowPaths : areaShowPaths; + const showTrail = isSts ? stsShowTrail : areaShowTrail; + + const handleTogglePaths = useCallback(() => { + if (isSts) useStsStore.getState().setShowPaths(!stsShowPaths); + else useAreaSearchStore.getState().setShowPaths(!areaShowPaths); + }, [isSts, stsShowPaths, areaShowPaths]); + + const handleToggleTrail = useCallback(() => { + if (isSts) useStsStore.getState().setShowTrail(!stsShowTrail); + else useAreaSearchStore.getState().setShowTrail(!areaShowTrail); + }, [isSts, stsShowTrail, areaShowTrail]); const progress = useMemo(() => { if (endTime <= startTime || startTime <= 0) return 0; @@ -127,9 +145,11 @@ export default function AreaSearchTimeline() { }, [setCurrentTime]); const handleClose = useCallback(() => { - useAreaSearchStore.getState().reset(); + useAreaSearchStore.getState().clearResults(); + useStsStore.getState().clearResults(); useAreaSearchAnimationStore.getState().reset(); unregisterAreaSearchLayers(); + unregisterStsLayers(); showLiveShips(); shipBatchRenderer.immediateRender(); }, []); @@ -224,7 +244,7 @@ export default function AreaSearchTimeline() { setShowPaths(!showPaths)} + onChange={handleTogglePaths} disabled={!hasData} /> {PATH_LABEL} @@ -234,7 +254,7 @@ export default function AreaSearchTimeline() { setShowTrail(!showTrail)} + onChange={handleToggleTrail} disabled={!hasData} /> {TRAIL_LABEL} diff --git a/src/areaSearch/components/AreaSearchTooltip.jsx b/src/areaSearch/components/AreaSearchTooltip.jsx index 82b6b063..6b61d72b 100644 --- a/src/areaSearch/components/AreaSearchTooltip.jsx +++ b/src/areaSearch/components/AreaSearchTooltip.jsx @@ -1,8 +1,9 @@ /** * 항적분석 호버 툴팁 컴포넌트 * - 선박 기본 정보 (선종, 선명, 신호원) - * - 구역별 진입/진출 시간 및 위치 + * - 시간순 방문 이력 (구역 무관, entryTimestamp 정렬) */ +import { useMemo } from 'react'; import { useAreaSearchStore } from '../stores/areaSearchStore'; import { ZONE_COLORS } from '../types/areaSearch.types'; import { getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types'; @@ -17,14 +18,14 @@ function getNationalFlagUrl(nationalCode) { return `/ship/image/small/${nationalCode}.svg`; } -function formatTimestamp(ms) { +export 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())}`; + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } -function formatPosition(pos) { +export function formatPosition(pos) { if (!pos || pos.length < 2) return null; const lon = pos[0]; const lat = pos[1]; @@ -39,6 +40,17 @@ export default function AreaSearchTooltip() { const hitDetails = useAreaSearchStore((s) => s.hitDetails); const zones = useAreaSearchStore((s) => s.zones); + const zoneMap = useMemo(() => { + const map = new Map(); + zones.forEach((z, idx) => { + map.set(z.id, z); + map.set(z.name, z); + map.set(idx, z); + map.set(String(idx), z); + }); + return map; + }, [zones]); + if (!tooltip) return null; const { vesselId, x, y } = tooltip; @@ -50,6 +62,9 @@ export default function AreaSearchTooltip() { const sourceName = getSignalSourceName(track.sigSrcCd); const flagUrl = getNationalFlagUrl(track.nationalCode); + // 시간순 정렬 (구역 무관) + const sortedHits = [...hits].sort((a, b) => a.entryTimestamp - b.entryTimestamp); + return (
{sourceName}
- {zones.length > 0 && hits.length > 0 && ( + {sortedHits.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'; + {sortedHits.map((hit, idx) => { + const zone = zoneMap.get(hit.polygonId); + const zoneColor = zone + ? (ZONE_COLORS[zone.colorIndex]?.label || '#ffd43b') + : '#adb5bd'; + const zoneName = zone + ? `${zone.name}구역` + : (hit.polygonName ? `${hit.polygonName}구역` : '구역'); + const visitLabel = hit.visitIndex > 1 || sortedHits.filter((h) => h.polygonId === hit.polygonId).length > 1 + ? `(${hit.visitIndex}차)` + : ''; const entryPos = formatPosition(hit.entryPosition); const exitPos = formatPosition(hit.exitPosition); return ( -
- - {zone.name} - +
+
+ {idx + 1}. + + {zoneName} + + {visitLabel && ( + {visitLabel} + )} +
- IN + {idx + 1}-IN {formatTimestamp(hit.entryTimestamp)} {entryPos && ( {entryPos} )}
- OUT + {idx + 1}-OUT {formatTimestamp(hit.exitTimestamp)} {exitPos && ( {exitPos} diff --git a/src/areaSearch/components/AreaSearchTooltip.scss b/src/areaSearch/components/AreaSearchTooltip.scss index 1dc07409..561b4401 100644 --- a/src/areaSearch/components/AreaSearchTooltip.scss +++ b/src/areaSearch/components/AreaSearchTooltip.scss @@ -72,10 +72,27 @@ gap: 1px; } + &__zone-header { + display: flex; + align-items: center; + gap: 4px; + margin-bottom: 1px; + } + + &__visit-seq { + font-size: 10px; + color: #868e96; + min-width: 14px; + } + &__zone-name { font-weight: 700; font-size: 11px; - margin-bottom: 1px; + } + + &__visit-label { + font-size: 10px; + color: #868e96; } &__zone-row { @@ -84,14 +101,14 @@ gap: 5px; color: #ced4da; font-size: 11px; - padding-left: 2px; + padding-left: 18px; } &__zone-label { font-weight: 600; font-size: 9px; color: #868e96; - min-width: 24px; + min-width: 34px; } &__pos { diff --git a/src/areaSearch/components/StsAnalysisTab.jsx b/src/areaSearch/components/StsAnalysisTab.jsx new file mode 100644 index 00000000..2483f004 --- /dev/null +++ b/src/areaSearch/components/StsAnalysisTab.jsx @@ -0,0 +1,140 @@ +/** + * STS(Ship-to-Ship) 분석 탭 컴포넌트 + * + * - ZoneDrawPanel (maxZones=1) + * - STS 파라미터 슬라이더 (최소 접촉 시간, 최대 접촉 거리) + * - 결과: StsContactList + */ +import { useCallback, useState } from 'react'; +import './StsAnalysisTab.scss'; +import { useStsStore } from '../stores/stsStore'; +import { useAreaSearchStore } from '../stores/areaSearchStore'; +import { STS_LIMITS } from '../types/sts.types'; +import ZoneDrawPanel from './ZoneDrawPanel'; +import StsContactList from './StsContactList'; +import StsContactDetailModal from './StsContactDetailModal'; + +export default function StsAnalysisTab({ isLoading, errorMessage }) { + const queryCompleted = useStsStore((s) => s.queryCompleted); + const groupedContacts = useStsStore((s) => s.groupedContacts); + const summary = useStsStore((s) => s.summary); + const minContactDuration = useStsStore((s) => s.minContactDurationMinutes); + const maxContactDistance = useStsStore((s) => s.maxContactDistanceMeters); + + const handleDurationChange = useCallback((e) => { + useStsStore.getState().setMinContactDuration(Number(e.target.value)); + }, []); + + const handleDistanceChange = useCallback((e) => { + useStsStore.getState().setMaxContactDistance(Number(e.target.value)); + }, []); + + const [detailGroupIndex, setDetailGroupIndex] = useState(null); + + const handleDetailClick = useCallback((idx) => { + setDetailGroupIndex(idx); + }, []); + + const handleDetailClose = useCallback(() => { + setDetailGroupIndex(null); + }, []); + + return ( + <> + {/* 구역 설정 (1개만) */} + + + {/* STS 파라미터 */} +
+
+
+ 최소 접촉 시간 + {minContactDuration}분 +
+ +
+ {STS_LIMITS.DURATION_MIN}분 + {STS_LIMITS.DURATION_MAX}분 +
+
+ +
+
+ 최대 접촉 거리 + {maxContactDistance}m +
+ +
+ {STS_LIMITS.DISTANCE_MIN}m + {STS_LIMITS.DISTANCE_MAX}m +
+
+
+ + {/* 결과 영역 */} +
+ {errorMessage &&
{errorMessage}
} + + {isLoading &&
데이터를 불러오는 중입니다...
} + + {queryCompleted && groupedContacts.length > 0 && ( +
+ {summary && ( +
+ 접촉 {summary.totalContactPairs}쌍 + | + 관련 {summary.totalVesselsInvolved}척 + | + 구역 내 {summary.totalVesselsInPolygon}척 + {summary.processingTimeMs != null && ( + <> + | + + {(summary.processingTimeMs / 1000).toFixed(1)}초 + + + )} +
+ )} + +
+ )} + + {queryCompleted && groupedContacts.length === 0 && !errorMessage && ( +
접촉 의심 쌍이 없습니다.
+ )} + + {!isLoading && !queryCompleted && !errorMessage && ( +
+ 구역을 설정하고 조회 버튼을 클릭하세요. +
+ )} +
+ + {detailGroupIndex !== null && ( + + )} + + ); +} diff --git a/src/areaSearch/components/StsAnalysisTab.scss b/src/areaSearch/components/StsAnalysisTab.scss new file mode 100644 index 00000000..1b20e80a --- /dev/null +++ b/src/areaSearch/components/StsAnalysisTab.scss @@ -0,0 +1,94 @@ +// STS 분석 탭 전용 스타일 + +.sts-params { + background-color: var(--secondary1, rgba(255, 255, 255, 0.05)); + border-radius: 0.6rem; + padding: 1.2rem 1.5rem; + margin-bottom: 1.2rem; + display: flex; + flex-direction: column; + gap: 1.2rem; + + .sts-param { + &__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.6rem; + } + + &__label { + font-size: var(--fs-s, 1.2rem); + color: var(--tertiary4, #ccc); + } + + &__value { + font-size: var(--fs-s, 1.2rem); + font-weight: var(--fw-bold, 700); + color: var(--primary1, #4a9eff); + min-width: 5rem; + text-align: right; + } + + &__slider { + width: 100%; + height: 0.4rem; + -webkit-appearance: none; + appearance: none; + background: var(--tertiary2, rgba(255, 255, 255, 0.2)); + border-radius: 0.2rem; + outline: none; + cursor: pointer; + + &::-webkit-slider-thumb { + -webkit-appearance: none; + width: 1.4rem; + height: 1.4rem; + background: var(--primary1, #4a9eff); + border-radius: 50%; + cursor: pointer; + transition: transform 0.1s; + + &:hover { + transform: scale(1.2); + } + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + } + + &__range { + display: flex; + justify-content: space-between; + margin-top: 0.3rem; + font-size: var(--fs-xxs, 1rem); + color: var(--tertiary3, #666); + } + } +} + +.sts-summary { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 0; + margin-bottom: 0.8rem; + font-size: var(--fs-s, 1.2rem); + font-weight: var(--fw-semibold, 600); + color: var(--white, #fff); + flex-shrink: 0; + + &__sep { + color: var(--tertiary3, #555); + font-weight: normal; + } + + &__time { + color: var(--tertiary4, #999); + font-weight: normal; + font-size: var(--fs-xs, 1.1rem); + } +} diff --git a/src/areaSearch/components/StsContactDetailModal.jsx b/src/areaSearch/components/StsContactDetailModal.jsx new file mode 100644 index 00000000..d5d90f65 --- /dev/null +++ b/src/areaSearch/components/StsContactDetailModal.jsx @@ -0,0 +1,489 @@ +/** + * STS 접촉 쌍 상세 모달 — 임베디드 OL 지도 + 그리드 레이아웃 + 이미지 저장 + * 그룹 기반: 동일 선박 쌍의 여러 접촉을 리스트로 표시 + */ +import { useEffect, useRef, useCallback, useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; +import Map from 'ol/Map'; +import View from 'ol/View'; +import { XYZ } from 'ol/source'; +import TileLayer from 'ol/layer/Tile'; +import VectorSource from 'ol/source/Vector'; +import VectorLayer from 'ol/layer/Vector'; +import { Feature } from 'ol'; +import { Point, LineString, Polygon } from 'ol/geom'; +import { fromLonLat } from 'ol/proj'; +import { Style, Fill, Stroke, Circle as CircleStyle, Text } from 'ol/style'; +import { defaults as defaultControls, ScaleLine } from 'ol/control'; +import { defaults as defaultInteractions } from 'ol/interaction'; +import html2canvas from 'html2canvas'; + +import { useStsStore } from '../stores/stsStore'; +import { useAreaSearchStore } from '../stores/areaSearchStore'; +import { ZONE_COLORS } from '../types/areaSearch.types'; +import { getShipKindColor, getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types'; +import { formatTimestamp, formatPosition } from './AreaSearchTooltip'; +import { + getIndicatorDetail, + formatDistance, + formatDuration, + getContactRiskColor, +} from '../types/sts.types'; +import { mapLayerConfig } from '../../map/layers/baseLayer'; +import './StsContactDetailModal.scss'; + +function getNationalFlagUrl(nationalCode) { + if (!nationalCode) return null; + return `/ship/image/small/${nationalCode}.svg`; +} + +function createZoneFeatures(zones) { + const features = []; + zones.forEach((zone) => { + const coords3857 = zone.coordinates.map((c) => fromLonLat(c)); + const polygon = new Polygon([coords3857]); + const feature = new Feature({ geometry: polygon }); + const color = ZONE_COLORS[zone.colorIndex] || ZONE_COLORS[0]; + feature.setStyle([ + new Style({ + fill: new Fill({ color: `rgba(${color.fill.join(',')})` }), + stroke: new Stroke({ color: `rgba(${color.stroke.join(',')})`, width: 2 }), + }), + new Style({ + geometry: () => { + const ext = polygon.getExtent(); + const center = [(ext[0] + ext[2]) / 2, (ext[1] + ext[3]) / 2]; + return new Point(center); + }, + text: new Text({ + text: `${zone.name}구역`, + font: 'bold 12px sans-serif', + fill: new Fill({ color: color.label || '#fff' }), + stroke: new Stroke({ color: 'rgba(0,0,0,0.7)', width: 3 }), + }), + }), + ]); + features.push(feature); + }); + return features; +} + +function createTrackFeature(track) { + const coords3857 = track.geometry.map((c) => fromLonLat(c)); + const line = new LineString(coords3857); + const feature = new Feature({ geometry: line }); + const color = getShipKindColor(track.shipKindCode); + feature.setStyle(new Style({ + stroke: new Stroke({ + color: `rgba(${color[0]},${color[1]},${color[2]},0.8)`, + width: 2, + }), + })); + return feature; +} + +function createContactMarkers(contacts) { + const features = []; + + contacts.forEach((contact, idx) => { + if (!contact.contactCenterPoint) return; + + const pos3857 = fromLonLat(contact.contactCenterPoint); + const riskColor = getContactRiskColor(contact.indicators); + + const f = new Feature({ geometry: new Point(pos3857) }); + f.setStyle(new Style({ + image: new CircleStyle({ + radius: 10, + fill: new Fill({ color: `rgba(${riskColor[0]},${riskColor[1]},${riskColor[2]},0.6)` }), + stroke: new Stroke({ color: '#fff', width: 2 }), + }), + text: new Text({ + text: contacts.length > 1 ? `#${idx + 1}` : '접촉 중심', + font: 'bold 11px sans-serif', + fill: new Fill({ color: '#fff' }), + stroke: new Stroke({ color: 'rgba(0,0,0,0.8)', width: 3 }), + offsetY: -18, + }), + })); + features.push(f); + + if (contact.contactStartTimestamp) { + const startLabel = `시작 ${formatTimestamp(contact.contactStartTimestamp)}`; + const endLabel = `종료 ${formatTimestamp(contact.contactEndTimestamp)}`; + const labelF = new Feature({ geometry: new Point(pos3857) }); + labelF.setStyle(new Style({ + text: new Text({ + text: `${startLabel}\n${endLabel}`, + font: '10px sans-serif', + fill: new Fill({ color: '#ced4da' }), + stroke: new Stroke({ color: 'rgba(0,0,0,0.8)', width: 3 }), + offsetY: 24, + }), + })); + features.push(labelF); + } + }); + + return features; +} + +const MODAL_WIDTH = 680; +const MODAL_APPROX_HEIGHT = 780; + +export default function StsContactDetailModal({ groupIndex, onClose }) { + const groupedContacts = useStsStore((s) => s.groupedContacts); + const tracks = useStsStore((s) => s.tracks); + const zones = useAreaSearchStore((s) => s.zones); + + const mapContainerRef = useRef(null); + const mapRef = useRef(null); + const contentRef = useRef(null); + + const [position, setPosition] = useState(() => ({ + x: (window.innerWidth - MODAL_WIDTH) / 2, + y: Math.max(20, (window.innerHeight - MODAL_APPROX_HEIGHT) / 2), + })); + const posRef = useRef(position); + const dragging = useRef(false); + const dragStart = useRef({ x: 0, y: 0 }); + + const handleMouseDown = useCallback((e) => { + dragging.current = true; + dragStart.current = { + x: e.clientX - posRef.current.x, + y: e.clientY - posRef.current.y, + }; + e.preventDefault(); + }, []); + + useEffect(() => { + const handleMouseMove = (e) => { + if (!dragging.current) return; + const newPos = { + x: e.clientX - dragStart.current.x, + y: e.clientY - dragStart.current.y, + }; + posRef.current = newPos; + setPosition(newPos); + }; + const handleMouseUp = () => { + dragging.current = false; + }; + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, []); + + const group = useMemo(() => groupedContacts[groupIndex], [groupedContacts, groupIndex]); + const vessel1Track = useMemo( + () => tracks.find((t) => t.vesselId === group?.vessel1?.vesselId), + [tracks, group], + ); + const vessel2Track = useMemo( + () => tracks.find((t) => t.vesselId === group?.vessel2?.vesselId), + [tracks, group], + ); + + // OL 지도 초기화 + useEffect(() => { + if (!mapContainerRef.current || !group || !vessel1Track || !vessel2Track) return; + + const tileSource = new XYZ({ + url: mapLayerConfig.darkLayer.source.getUrls()[0], + minZoom: 6, + maxZoom: 11, + }); + const tileLayer = new TileLayer({ source: tileSource, preload: Infinity }); + + const zoneSource = new VectorSource({ features: createZoneFeatures(zones) }); + const zoneLayer = new VectorLayer({ source: zoneSource }); + + const trackSource = new VectorSource({ + features: [createTrackFeature(vessel1Track), createTrackFeature(vessel2Track)], + }); + const trackLayer = new VectorLayer({ source: trackSource }); + + const markerFeatures = createContactMarkers(group.contacts); + const markerSource = new VectorSource({ features: markerFeatures }); + const markerLayer = new VectorLayer({ source: markerSource }); + + const map = new Map({ + target: mapContainerRef.current, + layers: [tileLayer, zoneLayer, trackLayer, markerLayer], + view: new View({ center: [0, 0], zoom: 7 }), + controls: defaultControls({ attribution: false, zoom: false, rotate: false }) + .extend([new ScaleLine({ units: 'nautical' })]), + interactions: defaultInteractions({ doubleClickZoom: false }), + }); + + const allSource = new VectorSource(); + [...zoneSource.getFeatures(), ...trackSource.getFeatures()].forEach((f) => allSource.addFeature(f.clone())); + const extent = allSource.getExtent(); + if (extent && extent[0] !== Infinity) { + map.getView().fit(extent, { padding: [50, 50, 50, 50], maxZoom: 14 }); + } + + mapRef.current = map; + + return () => { + map.setTarget(null); + map.dispose(); + mapRef.current = null; + }; + }, [group, vessel1Track, vessel2Track, zones]); + + const handleSaveImage = useCallback(async () => { + const el = contentRef.current; + if (!el) return; + + const modal = el.parentElement; + const saved = { + elOverflow: el.style.overflow, + modalMaxHeight: modal.style.maxHeight, + modalOverflow: modal.style.overflow, + }; + + el.style.overflow = 'visible'; + modal.style.maxHeight = 'none'; + modal.style.overflow = 'visible'; + + try { + const canvas = await html2canvas(el, { + backgroundColor: '#141820', + useCORS: true, + scale: 2, + }); + canvas.toBlob((blob) => { + if (!blob) return; + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + const pad = (n) => String(n).padStart(2, '0'); + const now = new Date(); + const v1Name = group?.vessel1?.vesselName || 'V1'; + const v2Name = group?.vessel2?.vesselName || 'V2'; + link.href = url; + link.download = `STS분석_${v1Name}_${v2Name}_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}.png`; + link.click(); + URL.revokeObjectURL(url); + }, 'image/png'); + } catch (err) { + console.error('[StsContactDetailModal] 이미지 저장 실패:', err); + } finally { + el.style.overflow = saved.elOverflow; + modal.style.maxHeight = saved.modalMaxHeight; + modal.style.overflow = saved.modalOverflow; + } + }, [group]); + + if (!group || !vessel1Track || !vessel2Track) return null; + + const { vessel1, vessel2, indicators } = group; + const riskColor = getContactRiskColor(indicators); + const primaryContact = group.contacts[0]; + const lastContact = group.contacts[group.contacts.length - 1]; + + const activeIndicators = Object.entries(indicators || {}) + .filter(([, val]) => val) + .map(([key]) => ({ key, detail: getIndicatorDetail(key, primaryContact) })); + + return createPortal( +
+
e.stopPropagation()} + > + {/* 헤더 */} +
+
+ + + +
+ +
+ + {/* 콘텐츠 */} +
+
+ +
+ + {/* 접촉 요약 — 그리드 2열 */} +
+

접촉 요약

+
+
+ 접촉 기간 + {formatTimestamp(primaryContact.contactStartTimestamp)} ~ {formatTimestamp(lastContact.contactEndTimestamp)} +
+
+ 총 접촉 시간 + {formatDuration(group.totalDurationMinutes)} +
+
+ 평균 거리 + {formatDistance(group.avgDistanceMeters)} +
+ {group.contacts.length > 1 && ( +
+ 접촉 횟수 + {group.contacts.length}회 +
+ )} +
+
+ + {/* 특이사항 */} + {activeIndicators.length > 0 && ( +
+

특이사항

+
+ {activeIndicators.map(({ key, detail }) => ( + + {detail} + + ))} +
+
+ )} + + {/* 접촉 이력 (2개 이상) */} + {group.contacts.length > 1 && ( +
+

접촉 이력 ({group.contacts.length}회)

+
+ {group.contacts.map((c, ci) => ( +
+ #{ci + 1} + {formatTimestamp(c.contactStartTimestamp)} ~ {formatTimestamp(c.contactEndTimestamp)} + | + {formatDuration(c.contactDurationMinutes)} + | + 평균 {formatDistance(c.avgDistanceMeters)} +
+ ))} +
+
+ )} + + {/* 거리 통계 — 3열 그리드 */} +
+

거리 통계

+
+
+ 최소 + {formatDistance(group.minDistanceMeters)} +
+
+ 평균 + {formatDistance(group.avgDistanceMeters)} +
+
+ 최대 + {formatDistance(group.maxDistanceMeters)} +
+
+
+
+ 측정 + {group.totalContactPointCount} 포인트 +
+ {group.contactCenterPoint && ( +
+ 중심 좌표 + {formatPosition(group.contactCenterPoint)} +
+ )} +
+
+ + {/* 선박 상세 — 2열 그리드 */} + + +
+ +
+ +
+
+
, + document.body, + ); +} + +function VesselBadge({ vessel, track }) { + const kindName = getShipKindName(track.shipKindCode); + const flagUrl = getNationalFlagUrl(vessel.nationalCode); + return ( + + {kindName} + {flagUrl && ( + { e.target.style.display = 'none'; }} + /> + )} + + {vessel.vesselName || vessel.vesselId || '-'} + + + ); +} + +function VesselDetailSection({ label, vessel, track }) { + const kindName = getShipKindName(track.shipKindCode); + const sourceName = getSignalSourceName(track.sigSrcCd); + const color = getShipKindColor(track.shipKindCode); + + return ( +
+

+ + {label} — {vessel.vesselName || vessel.vesselId} +

+
+
+ 선종 + {kindName} +
+
+ 신호원 + {sourceName} +
+
+ 구역 체류 + {formatDuration(vessel.insidePolygonDurationMinutes)} +
+
+ 평균 속력 + {vessel.estimatedAvgSpeedKnots?.toFixed(1) ?? '-'} kn +
+
+ 진입 시각 + {formatTimestamp(vessel.insidePolygonStartTs)} +
+
+ 퇴출 시각 + {formatTimestamp(vessel.insidePolygonEndTs)} +
+
+
+ ); +} diff --git a/src/areaSearch/components/StsContactDetailModal.scss b/src/areaSearch/components/StsContactDetailModal.scss new file mode 100644 index 00000000..d4235795 --- /dev/null +++ b/src/areaSearch/components/StsContactDetailModal.scss @@ -0,0 +1,319 @@ +.sts-detail-overlay { + position: fixed; + inset: 0; + z-index: 300; + background: rgba(0, 0, 0, 0.6); +} + +.sts-detail-modal { + position: fixed; + z-index: 301; + width: 680px; + max-height: 90vh; + display: flex; + flex-direction: column; + background: rgba(20, 24, 32, 0.98); + border-radius: 8px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5); + color: #fff; + overflow: hidden; + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + flex-shrink: 0; + cursor: move; + user-select: none; + } + + &__title { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1; + } + + &__arrow { + color: #4a9eff; + font-weight: 700; + font-size: 14px; + flex-shrink: 0; + } + + &__vessel-badge { + display: inline-flex; + align-items: center; + gap: 5px; + min-width: 0; + } + + &__kind { + padding: 2px 6px; + background: rgba(255, 255, 255, 0.15); + border-radius: 3px; + font-size: 10px; + color: #adb5bd; + flex-shrink: 0; + } + + &__flag { + width: 18px; + height: 13px; + object-fit: contain; + flex-shrink: 0; + } + + &__name { + font-weight: 700; + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__close { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: #868e96; + font-size: 20px; + cursor: pointer; + border-radius: 4px; + flex-shrink: 0; + + &:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; + } + } + + &__content { + flex: 1; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + } + + &__map { + width: 100%; + height: 480px; + flex-shrink: 0; + background: #0d1117; + + .ol-scale-line { + bottom: 8px; + left: 8px; + } + } + + &__risk-bar { + height: 3px; + flex-shrink: 0; + } + + &__section { + padding: 10px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + + &:last-child { + border-bottom: none; + } + } + + &__section-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 700; + color: #ced4da; + margin: 0 0 8px 0; + } + + &__track-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + } + + &__row { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: #ced4da; + padding: 2px 0; + } + + &__label { + font-weight: 600; + font-size: 11px; + color: #868e96; + min-width: 60px; + flex-shrink: 0; + } + + &__pos { + color: #74b9ff; + font-size: 11px; + } + + // ========== 그리드 레이아웃 ========== + + &__summary-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 6px; + } + + &__stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; + } + + &__stat-item { + display: flex; + flex-direction: column; + gap: 2px; + padding: 6px 8px; + background: rgba(255, 255, 255, 0.04); + border-radius: 4px; + + .stat-label { + font-size: 10px; + font-weight: 600; + color: #868e96; + } + + .stat-value { + font-size: 12px; + color: #ced4da; + } + } + + &__vessel-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 6px; + } + + &__vessel-grid-item { + display: flex; + flex-direction: column; + gap: 2px; + padding: 6px 8px; + background: rgba(255, 255, 255, 0.04); + border-radius: 4px; + + .vessel-item-label { + font-size: 10px; + font-weight: 600; + color: #868e96; + } + + .vessel-item-value { + font-size: 12px; + color: #ced4da; + } + } + + // ========== 접촉 이력 리스트 ========== + + &__contact-list { + display: flex; + flex-direction: column; + gap: 4px; + } + + &__contact-item { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + font-size: 11px; + color: #ced4da; + background: rgba(255, 255, 255, 0.03); + border-radius: 3px; + } + + &__contact-num { + font-weight: 700; + color: #4a9eff; + min-width: 20px; + } + + &__contact-sep { + color: #495057; + font-size: 10px; + } + + &__indicators { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + + &__badge { + display: inline-block; + padding: 3px 8px; + border-radius: 3px; + font-size: 11px; + font-weight: 600; + + &--lowSpeedContact { + background: rgba(46, 204, 113, 0.15); + color: #2ecc71; + } + + &--differentVesselTypes { + background: rgba(243, 156, 18, 0.15); + color: #f39c12; + } + + &--differentNationalities { + background: rgba(52, 152, 219, 0.15); + color: #3498db; + } + + &--nightTimeContact { + background: rgba(155, 89, 182, 0.15); + color: #9b59b6; + } + } + + &__footer { + display: flex; + justify-content: flex-end; + padding: 10px 16px; + border-top: 1px solid rgba(255, 255, 255, 0.1); + flex-shrink: 0; + } + + &__save-btn { + padding: 6px 16px; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + background: transparent; + color: #ced4da; + font-size: 12px; + cursor: pointer; + transition: all 0.15s; + + &:hover { + border-color: #4a9eff; + color: #fff; + } + } +} diff --git a/src/areaSearch/components/StsContactList.jsx b/src/areaSearch/components/StsContactList.jsx new file mode 100644 index 00000000..f4e532f2 --- /dev/null +++ b/src/areaSearch/components/StsContactList.jsx @@ -0,0 +1,261 @@ +/** + * STS 접촉 쌍 결과 리스트 (그룹 기반) + * + * - 동일 선박 쌍의 여러 접촉을 하나의 카드로 그룹핑 + * - 카드 클릭 → on/off 토글 + * - ▼/▲ 버튼 → 하단 정보 확장 + * - ▶ 버튼 → 모달 팝업 + * - 호버 → 지도 하이라이트 + */ +import { useCallback, useEffect, useRef } from 'react'; +import './StsContactList.scss'; +import { useStsStore } from '../stores/stsStore'; +import { getShipKindName } from '../../tracking/types/trackQuery.types'; +import { + getIndicatorDetail, + formatDistance, + formatDuration, + getContactRiskColor, +} from '../types/sts.types'; +import { formatTimestamp, formatPosition } from './AreaSearchTooltip'; + +function getNationalFlagUrl(nationalCode) { + if (!nationalCode) return null; + return `/ship/image/small/${nationalCode}.svg`; +} + +function GroupCard({ group, index, onDetailClick }) { + const highlightedGroupIndex = useStsStore((s) => s.highlightedGroupIndex); + const expandedGroupIndex = useStsStore((s) => s.expandedGroupIndex); + const disabledGroupIndices = useStsStore((s) => s.disabledGroupIndices); + + const isHighlighted = highlightedGroupIndex === index; + const isExpanded = expandedGroupIndex === index; + const isDisabled = disabledGroupIndices.has(index); + const riskColor = getContactRiskColor(group.indicators); + + const handleMouseEnter = useCallback(() => { + useStsStore.getState().setHighlightedGroupIndex(index); + }, [index]); + + const handleMouseLeave = useCallback(() => { + useStsStore.getState().setHighlightedGroupIndex(null); + }, []); + + // 카드 클릭 → on/off 토글 + const handleClick = useCallback(() => { + useStsStore.getState().toggleGroupEnabled(index); + }, [index]); + + // ▼/▲ 버튼 → 하단 정보 확장 + const handleExpand = useCallback((e) => { + e.stopPropagation(); + useStsStore.getState().setExpandedGroupIndex(index); + }, [index]); + + // ▶ 버튼 → 모달 열기 + const handleDetail = useCallback((e) => { + e.stopPropagation(); + onDetailClick?.(index); + }, [index, onDetailClick]); + + const { vessel1, vessel2, indicators } = group; + const v1Kind = getShipKindName(vessel1.shipKindCode); + const v2Kind = getShipKindName(vessel2.shipKindCode); + const v1Flag = getNationalFlagUrl(vessel1.nationalCode); + const v2Flag = getNationalFlagUrl(vessel2.nationalCode); + + const activeIndicators = Object.entries(indicators || {}) + .filter(([, val]) => val) + .map(([key]) => ({ + key, + detail: getIndicatorDetail(key, group.contacts[0]), + })); + + // 시간 범위: 첫 접촉 시작 ~ 마지막 접촉 종료 + const firstContact = group.contacts[0]; + const lastContact = group.contacts[group.contacts.length - 1]; + + return ( +
  • +
    + +
    + {/* vessel1 */} +
    + {v1Kind} + {v1Flag && ( + { e.target.style.display = 'none'; }} + /> + )} + {vessel1.vesselName || vessel1.vesselId} +
    + + {/* 접촉 요약 (그룹 합산) */} +
    + + {formatDuration(group.totalDurationMinutes)} + | + 평균 {formatDistance(group.avgDistanceMeters)} + {group.contacts.length > 1 && ( + {group.contacts.length}회 + )} +
    + + {/* vessel2 + 버튼들 */} +
    + {v2Kind} + {v2Flag && ( + { e.target.style.display = 'none'; }} + /> + )} + {vessel2.vesselName || vessel2.vesselId} + + +
    + + {/* 접촉 시간대 */} +
    + {formatTimestamp(firstContact.contactStartTimestamp)} ~ {formatTimestamp(lastContact.contactEndTimestamp)} +
    + + {/* Indicator 뱃지 */} + {activeIndicators.length > 0 && ( +
    + {activeIndicators.map(({ key, detail }) => ( + + {detail} + + ))} +
    + )} + + {/* 확장 상세 */} + {isExpanded && ( +
    + {/* 그룹 내 개별 접촉 목록 (2개 이상) */} + {group.contacts.length > 1 && ( +
    + 접촉 이력 ({group.contacts.length}회) + {group.contacts.map((c, ci) => ( +
    + #{ci + 1} + {formatTimestamp(c.contactStartTimestamp)} ~ {formatTimestamp(c.contactEndTimestamp)} + | + {formatDuration(c.contactDurationMinutes)} + | + 평균 {formatDistance(c.avgDistanceMeters)} +
    + ))} +
    + )} + +
    +
    + 거리 + + 최소 {formatDistance(group.minDistanceMeters)} / 평균 {formatDistance(group.avgDistanceMeters)} / 최대 {formatDistance(group.maxDistanceMeters)} + +
    +
    + 측정 + {group.totalContactPointCount} 포인트 +
    + {group.contactCenterPoint && ( +
    + 중심 + {formatPosition(group.contactCenterPoint)} +
    + )} +
    + + + +
    + )} +
    +
  • + ); +} + +function VesselDetail({ label, vessel }) { + return ( +
    +
    + {label} + {vessel.vesselName || vessel.vesselId} +
    +
    + 구역체류 + {formatDuration(vessel.insidePolygonDurationMinutes)} + | + 평균 {vessel.estimatedAvgSpeedKnots?.toFixed(1) ?? '-'}kn +
    +
    + 진입 + {formatTimestamp(vessel.insidePolygonStartTs)} +
    +
    + 퇴출 + {formatTimestamp(vessel.insidePolygonEndTs)} +
    +
    + ); +} + +export default function StsContactList({ onDetailClick }) { + const groupedContacts = useStsStore((s) => s.groupedContacts); + const highlightedGroupIndex = useStsStore((s) => s.highlightedGroupIndex); + const listRef = useRef(null); + + useEffect(() => { + if (highlightedGroupIndex === null || !listRef.current) return; + const el = listRef.current.querySelector('.sts-card.highlighted'); + if (!el) return; + + const container = listRef.current; + const elRect = el.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + + if (elRect.top >= containerRect.top && elRect.bottom <= containerRect.bottom) return; + container.scrollTop += (elRect.top - containerRect.top); + }, [highlightedGroupIndex]); + + return ( +
      + {groupedContacts.map((group, idx) => ( + + ))} +
    + ); +} diff --git a/src/areaSearch/components/StsContactList.scss b/src/areaSearch/components/StsContactList.scss new file mode 100644 index 00000000..227b4bdf --- /dev/null +++ b/src/areaSearch/components/StsContactList.scss @@ -0,0 +1,276 @@ +.sts-contact-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.6rem; + flex: 1; + overflow-y: auto; +} + +.sts-card { + display: flex; + border-radius: 0.6rem; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + cursor: pointer; + transition: all 0.15s; + overflow: hidden; + + &:hover, + &.highlighted { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.15); + } + + &.disabled { + opacity: 0.35; + } + + &__risk-bar { + width: 3px; + flex-shrink: 0; + } + + &__body { + flex: 1; + min-width: 0; + padding: 0.8rem 1rem; + display: flex; + flex-direction: column; + gap: 0.3rem; + } + + &__vessel { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; + } + + &__kind { + padding: 0.1rem 0.4rem; + background: rgba(255, 255, 255, 0.12); + border-radius: 0.2rem; + font-size: var(--fs-xxs, 1rem); + color: var(--tertiary4, #adb5bd); + flex-shrink: 0; + } + + &__flag { + width: 1.4rem; + height: 1rem; + object-fit: contain; + flex-shrink: 0; + } + + &__name { + font-size: var(--fs-s, 1.2rem); + font-weight: var(--fw-semibold, 600); + color: var(--white, #fff); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + } + + &__expand-btn { + flex-shrink: 0; + width: 2.4rem; + height: 2.4rem; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: 1px solid rgba(255, 255, 255, 0.12); + color: var(--tertiary4, #999); + font-size: 0.8rem; + cursor: pointer; + border-radius: 0.3rem; + transition: all 0.15s; + + &:hover { + background: rgba(255, 255, 255, 0.1); + color: var(--white, #fff); + } + } + + &__detail-btn { + flex-shrink: 0; + width: 2.4rem; + height: 2.4rem; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: 1px solid rgba(255, 255, 255, 0.15); + color: var(--primary1, #4a9eff); + font-size: 0.8rem; + cursor: pointer; + border-radius: 0.3rem; + transition: all 0.15s; + + &:hover { + background: rgba(74, 158, 255, 0.15); + border-color: var(--primary1, #4a9eff); + } + } + + &__contact-summary { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: var(--fs-xs, 1.1rem); + color: var(--tertiary4, #ccc); + padding-left: 0.2rem; + } + + &__arrow { + color: var(--primary1, #4a9eff); + font-weight: bold; + } + + &__sep { + color: var(--tertiary3, #555); + } + + &__count { + padding: 0.1rem 0.4rem; + background: rgba(74, 158, 255, 0.15); + border-radius: 0.2rem; + font-size: var(--fs-xxs, 1rem); + color: var(--primary1, #4a9eff); + font-weight: var(--fw-semibold, 600); + } + + &__time { + font-size: var(--fs-xxs, 1rem); + color: var(--tertiary3, #868e96); + padding-left: 0.2rem; + } + + &__indicators { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-top: 0.2rem; + } + + &__badge { + display: inline-block; + padding: 0.15rem 0.5rem; + border-radius: 0.2rem; + font-size: var(--fs-xxs, 1rem); + font-weight: var(--fw-semibold, 600); + + &--lowSpeedContact { + background: rgba(46, 204, 113, 0.15); + color: #2ecc71; + } + + &--differentVesselTypes { + background: rgba(243, 156, 18, 0.15); + color: #f39c12; + } + + &--differentNationalities { + background: rgba(52, 152, 219, 0.15); + color: #3498db; + } + + &--nightTimeContact { + background: rgba(155, 89, 182, 0.15); + color: #9b59b6; + } + } + + // 확장 상세 + &__detail { + margin-top: 0.6rem; + padding-top: 0.6rem; + border-top: 1px solid rgba(255, 255, 255, 0.08); + display: flex; + flex-direction: column; + gap: 0.6rem; + } + + &__detail-section { + display: flex; + flex-direction: column; + gap: 0.3rem; + } + + &__detail-row { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: var(--fs-xxs, 1rem); + color: var(--tertiary4, #ced4da); + } + + &__detail-label { + font-weight: var(--fw-semibold, 600); + font-size: var(--fs-xxs, 1rem); + color: var(--tertiary3, #868e96); + min-width: 2.8rem; + flex-shrink: 0; + } + + &__detail-sublabel { + font-size: var(--fs-xxs, 1rem); + color: var(--tertiary3, #868e96); + min-width: 3.2rem; + flex-shrink: 0; + padding-left: 0.6rem; + } + + &__pos { + color: #74b9ff; + } + + &__vessel-detail { + display: flex; + flex-direction: column; + gap: 0.2rem; + } + + &__vessel-detail-header { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: var(--fs-xs, 1.1rem); + color: var(--white, #fff); + font-weight: var(--fw-semibold, 600); + } + + // 그룹 내 접촉 이력 + &__sub-contacts { + display: flex; + flex-direction: column; + gap: 0.3rem; + padding-bottom: 0.4rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + } + + &__sub-title { + font-size: var(--fs-xxs, 1rem); + font-weight: var(--fw-semibold, 600); + color: var(--tertiary3, #868e96); + } + + &__sub-contact { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: var(--fs-xxs, 1rem); + color: var(--tertiary4, #ced4da); + padding: 0.15rem 0; + } + + &__sub-num { + font-weight: 700; + color: var(--primary1, #4a9eff); + min-width: 1.6rem; + } +} diff --git a/src/areaSearch/components/VesselDetailModal.jsx b/src/areaSearch/components/VesselDetailModal.jsx new file mode 100644 index 00000000..112aca3b --- /dev/null +++ b/src/areaSearch/components/VesselDetailModal.jsx @@ -0,0 +1,459 @@ +/** + * 선박 상세 모달 — 임베디드 OL 지도 + 시간순 방문 이력 + 이미지 저장 + */ +import { useEffect, useRef, useCallback, useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; +import Map from 'ol/Map'; +import View from 'ol/View'; +import { XYZ } from 'ol/source'; +import TileLayer from 'ol/layer/Tile'; +import VectorSource from 'ol/source/Vector'; +import VectorLayer from 'ol/layer/Vector'; +import { Feature } from 'ol'; +import { Point, LineString, Polygon } from 'ol/geom'; +import { fromLonLat } from 'ol/proj'; +import { Style, Fill, Stroke, Circle as CircleStyle, Text } from 'ol/style'; +import { defaults as defaultControls, ScaleLine } from 'ol/control'; +import { defaults as defaultInteractions } from 'ol/interaction'; +import html2canvas from 'html2canvas'; + +import { useAreaSearchStore } from '../stores/areaSearchStore'; +import { ZONE_COLORS } from '../types/areaSearch.types'; +import { getShipKindColor, getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types'; +import { formatTimestamp, formatPosition } from './AreaSearchTooltip'; +import { mapLayerConfig } from '../../map/layers/baseLayer'; +import './VesselDetailModal.scss'; + +function getNationalFlagUrl(nationalCode) { + if (!nationalCode) return null; + return `/ship/image/small/${nationalCode}.svg`; +} + +function createZoneFeatures(zones) { + const features = []; + zones.forEach((zone) => { + const coords3857 = zone.coordinates.map((c) => fromLonLat(c)); + const polygon = new Polygon([coords3857]); + const feature = new Feature({ geometry: polygon }); + const color = ZONE_COLORS[zone.colorIndex] || ZONE_COLORS[0]; + feature.setStyle([ + new Style({ + fill: new Fill({ color: `rgba(${color.fill.join(',')})` }), + stroke: new Stroke({ color: `rgba(${color.stroke.join(',')})`, width: 2 }), + }), + new Style({ + geometry: () => { + const ext = polygon.getExtent(); + const center = [(ext[0] + ext[2]) / 2, (ext[1] + ext[3]) / 2]; + return new Point(center); + }, + text: new Text({ + text: `${zone.name}구역`, + font: 'bold 12px sans-serif', + fill: new Fill({ color: color.label || '#fff' }), + stroke: new Stroke({ color: 'rgba(0,0,0,0.7)', width: 3 }), + }), + }), + ]); + features.push(feature); + }); + return features; +} + +function createTrackFeature(track) { + const coords3857 = track.geometry.map((c) => fromLonLat(c)); + const line = new LineString(coords3857); + const feature = new Feature({ geometry: line }); + const color = getShipKindColor(track.shipKindCode); + feature.setStyle(new Style({ + stroke: new Stroke({ + color: `rgba(${color[0]},${color[1]},${color[2]},0.8)`, + width: 2, + }), + })); + return feature; +} + +function createMarkerFeatures(sortedHits) { + const features = []; + sortedHits.forEach((hit, idx) => { + const seqNum = idx + 1; + + if (hit.entryPosition) { + const pos3857 = fromLonLat(hit.entryPosition); + const f = new Feature({ geometry: new Point(pos3857) }); + const timeStr = formatTimestamp(hit.entryTimestamp); + f.set('_markerType', 'in'); + f.set('_seqNum', seqNum); + f.setStyle(new Style({ + image: new CircleStyle({ + radius: 7, + fill: new Fill({ color: '#2ecc71' }), + stroke: new Stroke({ color: '#fff', width: 2 }), + }), + text: new Text({ + text: `${seqNum}-IN ${timeStr}`, + font: 'bold 10px sans-serif', + fill: new Fill({ color: '#2ecc71' }), + stroke: new Stroke({ color: 'rgba(0,0,0,0.8)', width: 3 }), + offsetY: -16, + textAlign: 'left', + offsetX: 10, + }), + })); + features.push(f); + } + + if (hit.exitPosition) { + const pos3857 = fromLonLat(hit.exitPosition); + const f = new Feature({ geometry: new Point(pos3857) }); + const timeStr = formatTimestamp(hit.exitTimestamp); + f.set('_markerType', 'out'); + f.set('_seqNum', seqNum); + f.setStyle(new Style({ + image: new CircleStyle({ + radius: 7, + fill: new Fill({ color: '#e74c3c' }), + stroke: new Stroke({ color: '#fff', width: 2 }), + }), + text: new Text({ + text: `${seqNum}-OUT ${timeStr}`, + font: 'bold 10px sans-serif', + fill: new Fill({ color: '#e74c3c' }), + stroke: new Stroke({ color: 'rgba(0,0,0,0.8)', width: 3 }), + offsetY: 16, + textAlign: 'left', + offsetX: 10, + }), + })); + features.push(f); + } + }); + return features; +} + +/** + * 마커 텍스트 겹침 보정 — 포인트(원)는 그대로, 텍스트 offsetY만 조정 + * 해상도 기반으로 근접 마커를 감지하고 텍스트를 수직 분산 배치 + */ +function adjustOverlappingLabels(features, resolution) { + if (!resolution || features.length < 2) return; + + const PROXIMITY_PX = 40; + const proximityMap = resolution * PROXIMITY_PX; + const LINE_HEIGHT_PX = 16; + + // 피처별 좌표 추출 + const items = features.map((f) => { + const coord = f.getGeometry().getCoordinates(); + return { feature: f, x: coord[0], y: coord[1] }; + }); + + // 근접 그룹 찾기 (Union-Find 방식) + const parent = items.map((_, i) => i); + const find = (i) => { while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i]; } return i; }; + const union = (a, b) => { parent[find(a)] = find(b); }; + + for (let i = 0; i < items.length; i++) { + for (let j = i + 1; j < items.length; j++) { + const dx = items[i].x - items[j].x; + const dy = items[i].y - items[j].y; + if (Math.sqrt(dx * dx + dy * dy) < proximityMap) { + union(i, j); + } + } + } + + // 그룹별 텍스트 offsetY 분산 (ol/Map import와 충돌 방지를 위해 plain object 사용) + const groups = {}; + items.forEach((item, i) => { + const root = find(i); + if (!groups[root]) groups[root] = []; + groups[root].push(item); + }); + + Object.values(groups).forEach((group) => { + if (group.length < 2) return; + // 시퀀스 번호 순 정렬 후 IN→OUT 순서 + group.sort((a, b) => { + const seqA = a.feature.get('_seqNum'); + const seqB = b.feature.get('_seqNum'); + if (seqA !== seqB) return seqA - seqB; + const typeA = a.feature.get('_markerType') === 'in' ? 0 : 1; + const typeB = b.feature.get('_markerType') === 'in' ? 0 : 1; + return typeA - typeB; + }); + + const totalHeight = group.length * LINE_HEIGHT_PX; + const startY = -totalHeight / 2 - 8; + + group.forEach((item, idx) => { + const style = item.feature.getStyle(); + const textStyle = style.getText(); + if (textStyle) { + textStyle.setOffsetY(startY + idx * LINE_HEIGHT_PX); + } + }); + }); +} + +const MODAL_WIDTH = 680; +const MODAL_APPROX_HEIGHT = 780; + +export default function VesselDetailModal({ vesselId, onClose }) { + const tracks = useAreaSearchStore((s) => s.tracks); + const hitDetails = useAreaSearchStore((s) => s.hitDetails); + const zones = useAreaSearchStore((s) => s.zones); + + const mapContainerRef = useRef(null); + const mapRef = useRef(null); + const contentRef = useRef(null); + + // 드래그 위치 관리 + const [position, setPosition] = useState(() => ({ + x: (window.innerWidth - MODAL_WIDTH) / 2, + y: Math.max(20, (window.innerHeight - MODAL_APPROX_HEIGHT) / 2), + })); + const posRef = useRef(position); + const dragging = useRef(false); + const dragStart = useRef({ x: 0, y: 0 }); + + const handleMouseDown = useCallback((e) => { + dragging.current = true; + dragStart.current = { + x: e.clientX - posRef.current.x, + y: e.clientY - posRef.current.y, + }; + e.preventDefault(); + }, []); + + useEffect(() => { + const handleMouseMove = (e) => { + if (!dragging.current) return; + const newPos = { + x: e.clientX - dragStart.current.x, + y: e.clientY - dragStart.current.y, + }; + posRef.current = newPos; + setPosition(newPos); + }; + const handleMouseUp = () => { + dragging.current = false; + }; + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, []); + + const track = useMemo( + () => tracks.find((t) => t.vesselId === vesselId), + [tracks, vesselId], + ); + const hits = useMemo(() => hitDetails[vesselId] || [], [hitDetails, vesselId]); + + const zoneMap = useMemo(() => { + const lookup = {}; + zones.forEach((z, idx) => { + lookup[z.id] = z; + lookup[z.name] = z; + lookup[idx] = z; + lookup[String(idx)] = z; + }); + return lookup; + }, [zones]); + + const sortedHits = useMemo( + () => [...hits].sort((a, b) => a.entryTimestamp - b.entryTimestamp), + [hits], + ); + + // OL 지도 초기화 + useEffect(() => { + if (!mapContainerRef.current || !track) return; + + const tileSource = new XYZ({ + url: mapLayerConfig.darkLayer.source.getUrls()[0], + minZoom: 6, + maxZoom: 11, + }); + const tileLayer = new TileLayer({ source: tileSource, preload: Infinity }); + + const zoneSource = new VectorSource({ features: createZoneFeatures(zones) }); + const zoneLayer = new VectorLayer({ source: zoneSource }); + + const trackSource = new VectorSource({ features: [createTrackFeature(track)] }); + const trackLayer = new VectorLayer({ source: trackSource }); + + const markerFeatures = createMarkerFeatures(sortedHits); + const markerSource = new VectorSource({ features: markerFeatures }); + const markerLayer = new VectorLayer({ source: markerSource }); + + const map = new Map({ + target: mapContainerRef.current, + layers: [tileLayer, zoneLayer, trackLayer, markerLayer], + view: new View({ center: [0, 0], zoom: 7 }), + controls: defaultControls({ attribution: false, zoom: false, rotate: false }) + .extend([new ScaleLine({ units: 'nautical' })]), + interactions: defaultInteractions({ doubleClickZoom: false }), + }); + + // 전체 extent에 맞춤 + const allSource = new VectorSource(); + [...zoneSource.getFeatures(), ...trackSource.getFeatures()].forEach((f) => allSource.addFeature(f.clone())); + const extent = allSource.getExtent(); + if (extent && extent[0] !== Infinity) { + map.getView().fit(extent, { padding: [50, 50, 50, 50], maxZoom: 14 }); + } + + // view fit 후 해상도 기반 텍스트 겹침 보정 + const resolution = map.getView().getResolution(); + adjustOverlappingLabels(markerFeatures, resolution); + + mapRef.current = map; + + return () => { + map.setTarget(null); + map.dispose(); + mapRef.current = null; + }; + }, [track, zones, sortedHits, zoneMap]); + + const handleSaveImage = useCallback(async () => { + const el = contentRef.current; + if (!el) return; + + const modal = el.parentElement; + const saved = { + elOverflow: el.style.overflow, + modalMaxHeight: modal.style.maxHeight, + modalOverflow: modal.style.overflow, + }; + + // 스크롤 영역 포함 전체 캡처를 위해 일시적으로 제약 해제 + el.style.overflow = 'visible'; + modal.style.maxHeight = 'none'; + modal.style.overflow = 'visible'; + + try { + const canvas = await html2canvas(el, { + backgroundColor: '#141820', + useCORS: true, + scale: 2, + }); + canvas.toBlob((blob) => { + if (!blob) return; + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + const pad = (n) => String(n).padStart(2, '0'); + const now = new Date(); + const name = track?.shipName || track?.targetId || 'vessel'; + link.href = url; + link.download = `항적분석_${name}_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}.png`; + link.click(); + URL.revokeObjectURL(url); + }, 'image/png'); + } catch (err) { + console.error('[VesselDetailModal] 이미지 저장 실패:', err); + } finally { + el.style.overflow = saved.elOverflow; + modal.style.maxHeight = saved.modalMaxHeight; + modal.style.overflow = saved.modalOverflow; + } + }, [track]); + + if (!track) return null; + + const kindName = getShipKindName(track.shipKindCode); + const sourceName = getSignalSourceName(track.sigSrcCd); + const flagUrl = getNationalFlagUrl(track.nationalCode); + + return createPortal( +
    +
    e.stopPropagation()} + > + {/* 헤더 (드래그 핸들) */} +
    +
    + {kindName} + {flagUrl && ( + + 국기 { e.target.style.display = 'none'; }} /> + + )} + + {track.shipName || track.targetId || '-'} + + {sourceName} +
    + +
    + + {/* 콘텐츠 (이미지 캡처 영역) */} +
    + {/* OL 지도 */} +
    + + {/* 방문 이력 */} +
    +

    방문 이력 (시간순)

    +
    + {sortedHits.map((hit, idx) => { + const zone = zoneMap[hit.polygonId]; + const zoneColor = zone + ? (ZONE_COLORS[zone.colorIndex]?.label || '#ffd43b') + : '#adb5bd'; + const zoneName = zone + ? `${zone.name}구역` + : (hit.polygonName ? `${hit.polygonName}구역` : '구역'); + const visitLabel = hit.visitIndex > 1 || hits.filter((h) => h.polygonId === hit.polygonId).length > 1 + ? `${hit.visitIndex}차` + : ''; + const entryPos = formatPosition(hit.entryPosition); + const exitPos = formatPosition(hit.exitPosition); + + return ( +
    + {idx + 1}. +
    +
    + + {zoneName} + {visitLabel && {visitLabel}} +
    +
    + {idx + 1}-IN + {formatTimestamp(hit.entryTimestamp)} + {entryPos && {entryPos}} +
    +
    + {idx + 1}-OUT + {formatTimestamp(hit.exitTimestamp)} + {exitPos && {exitPos}} +
    +
    +
    + ); + })} +
    +
    +
    + + {/* 하단 버튼 */} +
    + +
    +
    +
    , + document.body, + ); +} diff --git a/src/areaSearch/components/VesselDetailModal.scss b/src/areaSearch/components/VesselDetailModal.scss new file mode 100644 index 00000000..0af67832 --- /dev/null +++ b/src/areaSearch/components/VesselDetailModal.scss @@ -0,0 +1,224 @@ +.vessel-detail-overlay { + position: fixed; + inset: 0; + z-index: 300; + background: rgba(0, 0, 0, 0.6); +} + +.vessel-detail-modal { + position: fixed; + z-index: 301; + width: 680px; + max-height: 90vh; + display: flex; + flex-direction: column; + background: rgba(20, 24, 32, 0.98); + border-radius: 8px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5); + color: #fff; + overflow: hidden; + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + flex-shrink: 0; + cursor: move; + user-select: none; + } + + &__title { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + flex: 1; + } + + &__kind { + padding: 2px 6px; + background: rgba(255, 255, 255, 0.15); + border-radius: 3px; + font-size: 10px; + color: #adb5bd; + flex-shrink: 0; + } + + &__flag { + display: inline-flex; + align-items: center; + flex-shrink: 0; + + img { + width: 18px; + height: 13px; + object-fit: contain; + } + } + + &__name { + font-weight: 700; + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__source { + font-size: 11px; + color: #868e96; + flex-shrink: 0; + } + + &__close { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: #868e96; + font-size: 20px; + cursor: pointer; + border-radius: 4px; + flex-shrink: 0; + + &:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; + } + } + + &__content { + flex: 1; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + } + + &__map { + width: 100%; + height: 480px; + flex-shrink: 0; + background: #0d1117; + + .ol-scale-line { + bottom: 8px; + left: 8px; + } + } + + &__visits { + padding: 12px 16px; + flex-shrink: 0; + } + + &__visits-title { + font-size: 12px; + font-weight: 700; + color: #ced4da; + margin: 0 0 8px 0; + } + + &__visits-list { + display: flex; + flex-direction: column; + gap: 6px; + } + + &__visit { + display: flex; + gap: 6px; + align-items: flex-start; + } + + &__visit-seq { + font-size: 11px; + color: #868e96; + min-width: 18px; + padding-top: 1px; + } + + &__visit-body { + flex: 1; + display: flex; + flex-direction: column; + gap: 1px; + } + + &__visit-zone { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + margin-bottom: 2px; + } + + &__visit-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + } + + &__visit-idx { + font-size: 10px; + color: #868e96; + } + + &__visit-row { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: #ced4da; + padding-left: 12px; + } + + &__visit-label { + font-weight: 600; + font-size: 9px; + min-width: 34px; + + &.in { + color: #2ecc71; + } + + &.out { + color: #e74c3c; + } + } + + &__visit-pos { + color: #74b9ff; + font-size: 10px; + } + + &__footer { + display: flex; + justify-content: flex-end; + padding: 10px 16px; + border-top: 1px solid rgba(255, 255, 255, 0.1); + flex-shrink: 0; + } + + &__save-btn { + padding: 6px 16px; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + background: transparent; + color: #ced4da; + font-size: 12px; + cursor: pointer; + transition: all 0.15s; + + &:hover { + border-color: #4a9eff; + color: #fff; + } + } +} diff --git a/src/areaSearch/components/ZoneDrawPanel.jsx b/src/areaSearch/components/ZoneDrawPanel.jsx index f642309c..58bd374b 100644 --- a/src/areaSearch/components/ZoneDrawPanel.jsx +++ b/src/areaSearch/components/ZoneDrawPanel.jsx @@ -7,19 +7,41 @@ import { ZONE_COLORS, } from '../types/areaSearch.types'; -export default function ZoneDrawPanel({ disabled }) { +export default function ZoneDrawPanel({ disabled, maxZones }) { + const effectiveMaxZones = maxZones ?? MAX_ZONES; const zones = useAreaSearchStore((s) => s.zones); const activeDrawType = useAreaSearchStore((s) => s.activeDrawType); + const selectedZoneId = useAreaSearchStore((s) => s.selectedZoneId); const setActiveDrawType = useAreaSearchStore((s) => s.setActiveDrawType); const removeZone = useAreaSearchStore((s) => s.removeZone); const reorderZones = useAreaSearchStore((s) => s.reorderZones); + const selectZone = useAreaSearchStore((s) => s.selectZone); + const deselectZone = useAreaSearchStore((s) => s.deselectZone); + const confirmAndClearResults = useAreaSearchStore((s) => s.confirmAndClearResults); - const canAddZone = zones.length < MAX_ZONES; + const canAddZone = zones.length < effectiveMaxZones; const handleDrawClick = useCallback((type) => { if (!canAddZone || disabled) return; + if (!confirmAndClearResults()) return; setActiveDrawType(activeDrawType === type ? null : type); - }, [canAddZone, disabled, activeDrawType, setActiveDrawType]); + }, [canAddZone, disabled, activeDrawType, setActiveDrawType, confirmAndClearResults]); + + const handleZoneClick = useCallback((zoneId) => { + if (disabled) return; + if (selectedZoneId === zoneId) { + deselectZone(); + } else { + if (!confirmAndClearResults()) return; + selectZone(zoneId); + } + }, [disabled, selectedZoneId, deselectZone, selectZone, confirmAndClearResults]); + + const handleRemoveZone = useCallback((e, zoneId) => { + e.stopPropagation(); + if (!confirmAndClearResults()) return; + removeZone(zoneId); + }, [removeZone, confirmAndClearResults]); // 드래그 순서 변경 (ref 기반 - dataTransfer보다 안정적) const dragIndexRef = useRef(null); @@ -46,11 +68,16 @@ export default function ZoneDrawPanel({ disabled }) { e.preventDefault(); const fromIndex = dragIndexRef.current; if (fromIndex !== null && fromIndex !== toIndex) { + if (!confirmAndClearResults()) { + dragIndexRef.current = null; + setDragOverIndex(null); + return; + } reorderZones(fromIndex, toIndex); } dragIndexRef.current = null; setDragOverIndex(null); - }, [reorderZones]); + }, [reorderZones, confirmAndClearResults]); const handleDragEnd = useCallback(() => { dragIndexRef.current = null; @@ -105,8 +132,9 @@ export default function ZoneDrawPanel({ disabled }) { return (
  • handleZoneClick(zone.id)} onDragStart={(e) => handleDragStart(e, index)} onDragOver={(e) => handleDragOver(e, index)} onDrop={(e) => handleDrop(e, index)} @@ -116,10 +144,13 @@ export default function ZoneDrawPanel({ disabled }) { 구역 {zone.name} {zone.type} + {selectedZoneId === zone.id && ( + 편집 중 + )} +
  • +
    +
    , + document.body + ); +} diff --git a/src/components/common/AlertModal.scss b/src/components/common/AlertModal.scss new file mode 100644 index 00000000..03ba9c86 --- /dev/null +++ b/src/components/common/AlertModal.scss @@ -0,0 +1,57 @@ +.alert-modal-overlay { + position: fixed; + inset: 0; + z-index: 300; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; +} + +.alert-modal { + width: 360px; + background: rgba(20, 24, 32, 0.98); + border-radius: 8px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5); + color: #fff; + overflow: hidden; + + &__message { + padding: 32px 24px 24px; + font-size: 14px; + line-height: 1.6; + text-align: center; + color: var(--gray-scaleD); + } + + &__error-code { + padding: 0 24px 16px; + font-size: 11px; + text-align: center; + color: var(--gray-scale7, #888); + font-family: 'Courier New', monospace; + } + + &__footer { + display: flex; + justify-content: center; + padding: 0 24px 24px; + } + + &__confirm { + min-width: 100px; + height: 36px; + background-color: var(--primary1); + border: none; + border-radius: 4px; + color: #fff; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.15s; + + &:hover { + background-color: var(--primary2); + } + } +} diff --git a/src/components/common/LoadingOverlay.jsx b/src/components/common/LoadingOverlay.jsx new file mode 100644 index 00000000..6939ce74 --- /dev/null +++ b/src/components/common/LoadingOverlay.jsx @@ -0,0 +1,20 @@ +/** + * 전체 화면 로딩 오버레이 + * - 반투명 배경으로 다른 이벤트 차단 + * - CSS border spinner (indeterminate) + * - createPortal로 document.body에 렌더 + */ +import { createPortal } from 'react-dom'; +import './LoadingOverlay.scss'; + +export default function LoadingOverlay({ message = '조회중...' }) { + return createPortal( +
    +
    +
    +

    {message}

    +
    +
    , + document.body, + ); +} diff --git a/src/components/common/LoadingOverlay.scss b/src/components/common/LoadingOverlay.scss new file mode 100644 index 00000000..721b7c72 --- /dev/null +++ b/src/components/common/LoadingOverlay.scss @@ -0,0 +1,45 @@ +/** + * 전체 화면 로딩 오버레이 스타일 + */ +.loading-overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(0, 0, 0, 0.45); + + .loading-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 1.6rem; + padding: 3rem 4rem; + background: rgba(20, 25, 35, 0.92); + border-radius: 1.2rem; + border: 1px solid rgba(74, 158, 255, 0.25); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + } + + .spinner { + width: 48px; + height: 48px; + border: 4px solid rgba(255, 255, 255, 0.1); + border-top-color: #4a9eff; + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + + .loading-text { + font-size: 1.3rem; + color: rgba(255, 255, 255, 0.7); + margin: 0; + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/src/components/layout/Header.jsx b/src/components/layout/Header.jsx index 2f609dce..b05987b7 100644 --- a/src/components/layout/Header.jsx +++ b/src/components/layout/Header.jsx @@ -1,10 +1,28 @@ +import { useRef } from 'react'; import { Link } from 'react-router-dom'; +import { showAlert } from '../common/AlertModal'; + +const SAMPLE_ALERTS = [ + { message: '서버 응답 시간이 초과되었습니다.\n잠시 후 다시 시도해 주세요.', errorCode: 'ERR_TIMEOUT_504' }, + { message: '선박 위치 데이터를 수신할 수 없습니다.\n네트워크 연결 상태를 확인해 주세요.', errorCode: 'ERR_WS_DISCONNECTED_1006' }, + { message: '요청 권한이 없습니다.\n관리자에게 문의해 주세요.', errorCode: 'ERR_AUTH_FORBIDDEN_403' }, + { message: '항적 데이터 조회에 실패했습니다.\n검색 조건을 확인해 주세요.', errorCode: 'ERR_TRACK_QUERY_4001' }, +]; /** * 헤더 컴포넌트 * - 로고, 알람, 설정(드롭다운), 마이페이지 */ export default function Header() { + const alertIndexRef = useRef(0); + + const handleAlarmClick = (e) => { + e.preventDefault(); + const alert = SAMPLE_ALERTS[alertIndexRef.current % SAMPLE_ALERTS.length]; + alertIndexRef.current++; + showAlert(alert.message, alert.errorCode); + }; + return (