Merge branch 'feature/area-search' into develop

This commit is contained in:
LHT 2026-02-12 06:27:34 +09:00
커밋 34d5f6ef9e
49개의 변경된 파일5216개의 추가작업 그리고 683개의 파일을 삭제

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

파일 보기

@ -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",

파일 보기

@ -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 (
<>
<ToastContainer />
<AlertModalContainer />
<Routes>
{/* =====================
구현 영역 (메인)

파일 보기

@ -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();
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,11 +165,16 @@ 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) => {
@ -140,7 +185,6 @@ export default function AreaSearchPage({ isOpen, onToggle }) {
});
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 + )
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 (
<aside className={`slidePanel area-search-panel ${isOpen ? '' : 'is-closed'}`}>
<button type="button" className="toogle" onClick={onToggle} aria-label="패널 토글">
@ -220,10 +311,9 @@ export default function AreaSearchPage({ isOpen, onToggle }) {
</div>
<div className="panelBody">
{/* 조회 기간 */}
{/* 조회 기간 (공유) */}
<div className="query-section">
<h3 className="section-title">조회 기간</h3>
<p className="section-desc">D-7 ~ D-1 (인메모리 캐시 기반)</p>
<div className="query-row">
<label htmlFor="area-start-date" className="query-label">시작</label>
@ -268,32 +358,7 @@ export default function AreaSearchPage({ isOpen, onToggle }) {
/>
</div>
</div>
</div>
{/* 구역 설정 */}
<ZoneDrawPanel disabled={isLoading} />
{/* 검색 모드 */}
<div className="mode-section">
<h3 className="section-title">검색 조건</h3>
<div className="mode-options">
{Object.entries(SEARCH_MODE_LABELS).map(([mode, label]) => (
<label key={mode} className="mode-radio">
<input
type="radio"
name="searchMode"
value={mode}
checked={searchMode === mode}
onChange={() => setSearchMode(mode)}
disabled={isLoading}
/>
<span>{label}</span>
</label>
))}
</div>
</div>
{/* 조회 버튼 */}
<div className="btnBox">
<button
type="button"
@ -304,80 +369,37 @@ export default function AreaSearchPage({ isOpen, onToggle }) {
{isLoading ? '조회 중...' : '조회'}
</button>
</div>
{/* 결과 영역 */}
<div className="result-section">
{errorMessage && <div className="error-message">{errorMessage}</div>}
{isLoading && <div className="loading-message">데이터를 불러오는 중입니다...</div>}
{queryCompleted && tracks.length > 0 && (
<div className="result-content">
{summary && (
<div className="result-summary">
<span>검색 결과: {summary.totalVessels ?? tracks.length}</span>
{summary.processingTimeMs != null && (
<span className="processing-time">
({(summary.processingTimeMs / 1000).toFixed(2)})
</span>
)}
</div>
)}
{!summary && (
<div className="result-summary">
<span>검색 결과: {tracks.length}</span>
</div>
)}
<button type="button" className="btn-csv" onClick={handleExportCSV}>
CSV 내보내기
</button>
<ul className="vessel-list">
{tracks.map((track) => {
const isDisabled = disabledVesselIds.has(track.vesselId);
const isHighlighted = highlightedVesselId === track.vesselId;
const color = getShipKindColor(track.shipKindCode);
const rgbStr = `rgb(${color[0]}, ${color[1]}, ${color[2]})`;
return (
<li
key={track.vesselId}
className={`vessel-item ${isDisabled ? 'disabled' : ''} ${isHighlighted ? 'highlighted' : ''}`}
onMouseEnter={() => handleHighlightVessel(track.vesselId)}
onMouseLeave={() => handleHighlightVessel(null)}
>
{/* 탭 바 */}
<div className="analysis-tab-bar">
<button
type="button"
className="vessel-toggle"
onClick={() => handleToggleVessel(track.vesselId)}
className={`analysis-tab ${activeTab === ANALYSIS_TABS.AREA ? 'active' : ''}`}
onClick={() => handleTabChange(ANALYSIS_TABS.AREA)}
disabled={isLoading}
>
<span className="vessel-color" style={{ backgroundColor: rgbStr }} />
<span className="vessel-name">
{track.shipName || track.targetId}
</span>
<span className="vessel-info">
{getShipKindName(track.shipKindCode)} / {getSignalSourceName(track.sigSrcCd)}
</span>
구역분석
</button>
<button
type="button"
className={`analysis-tab ${activeTab === ANALYSIS_TABS.STS ? 'active' : ''}`}
onClick={() => handleTabChange(ANALYSIS_TABS.STS)}
disabled={isLoading}
>
STS분석
</button>
</li>
);
})}
</ul>
</div>
)}
{queryCompleted && tracks.length === 0 && !errorMessage && (
<div className="empty-message">조건에 맞는 선박이 없습니다.</div>
{/* 탭 컨텐츠 */}
{activeTab === ANALYSIS_TABS.AREA ? (
<AreaSearchTab isLoading={areaLoading} errorMessage={errorMessage} />
) : (
<StsAnalysisTab isLoading={stsLoading} errorMessage={errorMessage} />
)}
</div>
{!isLoading && !queryCompleted && !errorMessage && (
<div className="empty-message">
구역을 설정하고 조회 버튼을 클릭하세요.
</div>
)}
</div>
</div>
{isLoading && <LoadingOverlay message="조회중..." />}
</aside>
);
}

파일 보기

@ -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,51 +103,12 @@
}
}
}
}
// 검색 모드
.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;
margin-top: 2rem;
gap: 1rem;
.btn {
min-width: 12rem;
@ -185,6 +140,85 @@
}
}
}
}
// (segmented control)
.analysis-tab-bar {
display: flex;
margin-bottom: 1.2rem;
background-color: var(--tertiary1, rgba(0, 0, 0, 0.3));
border-radius: 0.6rem;
padding: 0.3rem;
gap: 0.3rem;
.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.15s;
&:hover:not(:disabled) {
color: var(--white, #fff);
}
&.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);
}
}
}
// 결과 영역
.result-section {
@ -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,30 +255,21 @@
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;
justify-content: space-between;
margin-bottom: 0.8rem;
flex-shrink: 0;
.result-summary-text {
font-size: var(--fs-m, 1.3rem);
font-weight: var(--fw-bold, 700);
color: var(--white, #fff);
@ -253,6 +278,24 @@
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);
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);
}
}
}

파일 보기

@ -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 (
<>
{/* 구역 설정 */}
<ZoneDrawPanel disabled={isLoading} />
{/* 검색 모드 */}
<div className="search-mode">
{Object.entries(SEARCH_MODE_LABELS).map(([mode, label]) => (
<label key={mode} className={`mode-chip ${searchMode === mode ? 'active' : ''}`}>
<input
type="radio"
name="searchMode"
value={mode}
checked={searchMode === mode}
onChange={() => {
if (!useAreaSearchStore.getState().confirmAndClearResults()) return;
setSearchMode(mode);
}}
disabled={isLoading}
/>
<span>{label}</span>
</label>
))}
</div>
{/* 결과 영역 */}
<div className="result-section">
{errorMessage && <div className="error-message">{errorMessage}</div>}
{isLoading && <div className="loading-message">데이터를 불러오는 중입니다...</div>}
{queryCompleted && tracks.length > 0 && (
<div className="result-content">
<div className="result-summary">
<span className="result-summary-text">
검색결과: {summary?.totalVessels ?? tracks.length}
{summary?.processingTimeMs != null && (
<span className="processing-time">
({(summary.processingTimeMs / 1000).toFixed(2)})
</span>
)}
</span>
<button type="button" className="btn-csv" onClick={handleExportCSV}>
CSV 내보내기
</button>
</div>
<ul className="vessel-list" ref={listRef}>
{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 (
<li
key={track.vesselId}
className={`vessel-item ${isDisabled ? 'disabled' : ''} ${isHighlighted ? 'highlighted' : ''}`}
onMouseEnter={(e) => {
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);
}}
>
<button
type="button"
className="vessel-toggle"
onClick={() => handleToggleVessel(track.vesselId)}
>
<span className="vessel-color" style={{ backgroundColor: rgbStr }} />
<span className="vessel-name">
{track.shipName || track.targetId}
</span>
<span className="vessel-info">
{getShipKindName(track.shipKindCode)} / {getSignalSourceName(track.sigSrcCd)}
{hasRevisits && (
<span className="visit-count">{totalVisits}</span>
)}
</span>
</button>
<button
type="button"
className="vessel-detail-btn"
onClick={(e) => {
e.stopPropagation();
setDetailVesselId(track.vesselId);
}}
title="상세 보기"
>
&#9654;
</button>
</li>
);
})}
</ul>
</div>
)}
{queryCompleted && tracks.length === 0 && !errorMessage && (
<div className="empty-message">조건에 맞는 선박이 없습니다.</div>
)}
{!isLoading && !queryCompleted && !errorMessage && (
<div className="empty-message">
구역을 설정하고 조회 버튼을 클릭하세요.
</div>
)}
</div>
{detailVesselId && (
<VesselDetailModal
vesselId={detailVesselId}
onClose={() => setDetailVesselId(null)}
/>
)}
</>
);
}

파일 보기

@ -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() {
<input
type="checkbox"
checked={showPaths}
onChange={() => setShowPaths(!showPaths)}
onChange={handleTogglePaths}
disabled={!hasData}
/>
<span>{PATH_LABEL}</span>
@ -234,7 +254,7 @@ export default function AreaSearchTimeline() {
<input
type="checkbox"
checked={showTrail}
onChange={() => setShowTrail(!showTrail)}
onChange={handleToggleTrail}
disabled={!hasData}
/>
<span>{TRAIL_LABEL}</span>

파일 보기

@ -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 (
<div
className="area-search-tooltip"
@ -74,32 +89,45 @@ export default function AreaSearchTooltip() {
<span>{sourceName}</span>
</div>
{zones.length > 0 && hits.length > 0 && (
{sortedHits.length > 0 && (
<div className="area-search-tooltip__zones">
{zones.map((zone) => {
const hit = hits.find((h) => h.polygonId === zone.id);
if (!hit) return null;
const zoneColor = ZONE_COLORS[zone.colorIndex]?.label || '#ffd43b';
{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 (
<div key={zone.id} className="area-search-tooltip__zone">
<div key={`${hit.polygonId}-${hit.visitIndex}-${idx}`} className="area-search-tooltip__zone">
<div className="area-search-tooltip__zone-header">
<span className="area-search-tooltip__visit-seq">{idx + 1}.</span>
<span
className="area-search-tooltip__zone-name"
style={{ color: zoneColor }}
>
{zone.name}
{zoneName}
</span>
{visitLabel && (
<span className="area-search-tooltip__visit-label">{visitLabel}</span>
)}
</div>
<div className="area-search-tooltip__zone-row">
<span className="area-search-tooltip__zone-label">IN</span>
<span className="area-search-tooltip__zone-label">{idx + 1}-IN</span>
<span>{formatTimestamp(hit.entryTimestamp)}</span>
{entryPos && (
<span className="area-search-tooltip__pos">{entryPos}</span>
)}
</div>
<div className="area-search-tooltip__zone-row">
<span className="area-search-tooltip__zone-label">OUT</span>
<span className="area-search-tooltip__zone-label">{idx + 1}-OUT</span>
<span>{formatTimestamp(hit.exitTimestamp)}</span>
{exitPos && (
<span className="area-search-tooltip__pos">{exitPos}</span>

파일 보기

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

파일 보기

@ -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개만) */}
<ZoneDrawPanel disabled={isLoading} maxZones={1} />
{/* STS 파라미터 */}
<div className="sts-params">
<div className="sts-param">
<div className="sts-param__header">
<span className="sts-param__label">최소 접촉 시간</span>
<span className="sts-param__value">{minContactDuration}</span>
</div>
<input
type="range"
className="sts-param__slider"
min={STS_LIMITS.DURATION_MIN}
max={STS_LIMITS.DURATION_MAX}
step={10}
value={minContactDuration}
onChange={handleDurationChange}
disabled={isLoading}
/>
<div className="sts-param__range">
<span>{STS_LIMITS.DURATION_MIN}</span>
<span>{STS_LIMITS.DURATION_MAX}</span>
</div>
</div>
<div className="sts-param">
<div className="sts-param__header">
<span className="sts-param__label">최대 접촉 거리</span>
<span className="sts-param__value">{maxContactDistance}m</span>
</div>
<input
type="range"
className="sts-param__slider"
min={STS_LIMITS.DISTANCE_MIN}
max={STS_LIMITS.DISTANCE_MAX}
step={50}
value={maxContactDistance}
onChange={handleDistanceChange}
disabled={isLoading}
/>
<div className="sts-param__range">
<span>{STS_LIMITS.DISTANCE_MIN}m</span>
<span>{STS_LIMITS.DISTANCE_MAX}m</span>
</div>
</div>
</div>
{/* 결과 영역 */}
<div className="result-section">
{errorMessage && <div className="error-message">{errorMessage}</div>}
{isLoading && <div className="loading-message">데이터를 불러오는 중입니다...</div>}
{queryCompleted && groupedContacts.length > 0 && (
<div className="result-content">
{summary && (
<div className="sts-summary">
<span>접촉 {summary.totalContactPairs}</span>
<span className="sts-summary__sep">|</span>
<span>관련 {summary.totalVesselsInvolved}</span>
<span className="sts-summary__sep">|</span>
<span>구역 {summary.totalVesselsInPolygon}</span>
{summary.processingTimeMs != null && (
<>
<span className="sts-summary__sep">|</span>
<span className="sts-summary__time">
{(summary.processingTimeMs / 1000).toFixed(1)}
</span>
</>
)}
</div>
)}
<StsContactList onDetailClick={handleDetailClick} />
</div>
)}
{queryCompleted && groupedContacts.length === 0 && !errorMessage && (
<div className="empty-message">접촉 의심 쌍이 없습니다.</div>
)}
{!isLoading && !queryCompleted && !errorMessage && (
<div className="empty-message">
구역을 설정하고 조회 버튼을 클릭하세요.
</div>
)}
</div>
{detailGroupIndex !== null && (
<StsContactDetailModal
groupIndex={detailGroupIndex}
onClose={handleDetailClose}
/>
)}
</>
);
}

파일 보기

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

파일 보기

@ -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(
<div className="sts-detail-overlay" onClick={onClose}>
<div
className="sts-detail-modal"
style={{ left: position.x, top: position.y }}
onClick={(e) => e.stopPropagation()}
>
{/* 헤더 */}
<div className="sts-detail-modal__header" onMouseDown={handleMouseDown}>
<div className="sts-detail-modal__title">
<VesselBadge vessel={vessel1} track={vessel1Track} />
<span className="sts-detail-modal__arrow"></span>
<VesselBadge vessel={vessel2} track={vessel2Track} />
</div>
<button type="button" className="sts-detail-modal__close" onClick={onClose}>
&times;
</button>
</div>
{/* 콘텐츠 */}
<div className="sts-detail-modal__content" ref={contentRef}>
<div className="sts-detail-modal__map" ref={mapContainerRef} />
<div
className="sts-detail-modal__risk-bar"
style={{ backgroundColor: `rgba(${riskColor.join(',')})` }}
/>
{/* 접촉 요약 — 그리드 2열 */}
<div className="sts-detail-modal__section">
<h4 className="sts-detail-modal__section-title">접촉 요약</h4>
<div className="sts-detail-modal__summary-grid">
<div className="sts-detail-modal__stat-item">
<span className="stat-label">접촉 기간</span>
<span className="stat-value">{formatTimestamp(primaryContact.contactStartTimestamp)} ~ {formatTimestamp(lastContact.contactEndTimestamp)}</span>
</div>
<div className="sts-detail-modal__stat-item">
<span className="stat-label"> 접촉 시간</span>
<span className="stat-value">{formatDuration(group.totalDurationMinutes)}</span>
</div>
<div className="sts-detail-modal__stat-item">
<span className="stat-label">평균 거리</span>
<span className="stat-value">{formatDistance(group.avgDistanceMeters)}</span>
</div>
{group.contacts.length > 1 && (
<div className="sts-detail-modal__stat-item">
<span className="stat-label">접촉 횟수</span>
<span className="stat-value">{group.contacts.length}</span>
</div>
)}
</div>
</div>
{/* 특이사항 */}
{activeIndicators.length > 0 && (
<div className="sts-detail-modal__section">
<h4 className="sts-detail-modal__section-title">특이사항</h4>
<div className="sts-detail-modal__indicators">
{activeIndicators.map(({ key, detail }) => (
<span key={key} className={`sts-detail-modal__badge sts-detail-modal__badge--${key}`}>
{detail}
</span>
))}
</div>
</div>
)}
{/* 접촉 이력 (2개 이상) */}
{group.contacts.length > 1 && (
<div className="sts-detail-modal__section">
<h4 className="sts-detail-modal__section-title">접촉 이력 ({group.contacts.length})</h4>
<div className="sts-detail-modal__contact-list">
{group.contacts.map((c, ci) => (
<div key={ci} className="sts-detail-modal__contact-item">
<span className="sts-detail-modal__contact-num">#{ci + 1}</span>
<span>{formatTimestamp(c.contactStartTimestamp)} ~ {formatTimestamp(c.contactEndTimestamp)}</span>
<span className="sts-detail-modal__contact-sep">|</span>
<span>{formatDuration(c.contactDurationMinutes)}</span>
<span className="sts-detail-modal__contact-sep">|</span>
<span>평균 {formatDistance(c.avgDistanceMeters)}</span>
</div>
))}
</div>
</div>
)}
{/* 거리 통계 — 3열 그리드 */}
<div className="sts-detail-modal__section">
<h4 className="sts-detail-modal__section-title">거리 통계</h4>
<div className="sts-detail-modal__stats-grid">
<div className="sts-detail-modal__stat-item">
<span className="stat-label">최소</span>
<span className="stat-value">{formatDistance(group.minDistanceMeters)}</span>
</div>
<div className="sts-detail-modal__stat-item">
<span className="stat-label">평균</span>
<span className="stat-value">{formatDistance(group.avgDistanceMeters)}</span>
</div>
<div className="sts-detail-modal__stat-item">
<span className="stat-label">최대</span>
<span className="stat-value">{formatDistance(group.maxDistanceMeters)}</span>
</div>
</div>
<div className="sts-detail-modal__stats-grid" style={{ marginTop: 6 }}>
<div className="sts-detail-modal__stat-item">
<span className="stat-label">측정</span>
<span className="stat-value">{group.totalContactPointCount} 포인트</span>
</div>
{group.contactCenterPoint && (
<div className="sts-detail-modal__stat-item" style={{ gridColumn: 'span 2' }}>
<span className="stat-label">중심 좌표</span>
<span className="stat-value sts-detail-modal__pos">{formatPosition(group.contactCenterPoint)}</span>
</div>
)}
</div>
</div>
{/* 선박 상세 — 2열 그리드 */}
<VesselDetailSection label="선박 1" vessel={vessel1} track={vessel1Track} />
<VesselDetailSection label="선박 2" vessel={vessel2} track={vessel2Track} />
</div>
<div className="sts-detail-modal__footer">
<button type="button" className="sts-detail-modal__save-btn" onClick={handleSaveImage}>
이미지 저장
</button>
</div>
</div>
</div>,
document.body,
);
}
function VesselBadge({ vessel, track }) {
const kindName = getShipKindName(track.shipKindCode);
const flagUrl = getNationalFlagUrl(vessel.nationalCode);
return (
<span className="sts-detail-modal__vessel-badge">
<span className="sts-detail-modal__kind">{kindName}</span>
{flagUrl && (
<img
className="sts-detail-modal__flag"
src={flagUrl}
alt=""
onError={(e) => { e.target.style.display = 'none'; }}
/>
)}
<span className="sts-detail-modal__name">
{vessel.vesselName || vessel.vesselId || '-'}
</span>
</span>
);
}
function VesselDetailSection({ label, vessel, track }) {
const kindName = getShipKindName(track.shipKindCode);
const sourceName = getSignalSourceName(track.sigSrcCd);
const color = getShipKindColor(track.shipKindCode);
return (
<div className="sts-detail-modal__section">
<h4 className="sts-detail-modal__section-title">
<span
className="sts-detail-modal__track-dot"
style={{ backgroundColor: `rgb(${color[0]},${color[1]},${color[2]})` }}
/>
{label} {vessel.vesselName || vessel.vesselId}
</h4>
<div className="sts-detail-modal__vessel-grid">
<div className="sts-detail-modal__vessel-grid-item">
<span className="vessel-item-label">선종</span>
<span className="vessel-item-value">{kindName}</span>
</div>
<div className="sts-detail-modal__vessel-grid-item">
<span className="vessel-item-label">신호원</span>
<span className="vessel-item-value">{sourceName}</span>
</div>
<div className="sts-detail-modal__vessel-grid-item">
<span className="vessel-item-label">구역 체류</span>
<span className="vessel-item-value">{formatDuration(vessel.insidePolygonDurationMinutes)}</span>
</div>
<div className="sts-detail-modal__vessel-grid-item">
<span className="vessel-item-label">평균 속력</span>
<span className="vessel-item-value">{vessel.estimatedAvgSpeedKnots?.toFixed(1) ?? '-'} kn</span>
</div>
<div className="sts-detail-modal__vessel-grid-item">
<span className="vessel-item-label">진입 시각</span>
<span className="vessel-item-value">{formatTimestamp(vessel.insidePolygonStartTs)}</span>
</div>
<div className="sts-detail-modal__vessel-grid-item">
<span className="vessel-item-label">퇴출 시각</span>
<span className="vessel-item-value">{formatTimestamp(vessel.insidePolygonEndTs)}</span>
</div>
</div>
</div>
);
}

파일 보기

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

파일 보기

@ -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 (
<li
className={`sts-card ${isHighlighted ? 'highlighted' : ''} ${isDisabled ? 'disabled' : ''}`}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
>
<div
className="sts-card__risk-bar"
style={{ backgroundColor: `rgba(${riskColor.join(',')})` }}
/>
<div className="sts-card__body">
{/* vessel1 */}
<div className="sts-card__vessel">
<span className="sts-card__kind">{v1Kind}</span>
{v1Flag && (
<img
className="sts-card__flag"
src={v1Flag}
alt=""
onError={(e) => { e.target.style.display = 'none'; }}
/>
)}
<span className="sts-card__name">{vessel1.vesselName || vessel1.vesselId}</span>
</div>
{/* 접촉 요약 (그룹 합산) */}
<div className="sts-card__contact-summary">
<span className="sts-card__arrow"></span>
<span>{formatDuration(group.totalDurationMinutes)}</span>
<span className="sts-card__sep">|</span>
<span>평균 {formatDistance(group.avgDistanceMeters)}</span>
{group.contacts.length > 1 && (
<span className="sts-card__count">{group.contacts.length}</span>
)}
</div>
{/* vessel2 + 버튼들 */}
<div className="sts-card__vessel">
<span className="sts-card__kind">{v2Kind}</span>
{v2Flag && (
<img
className="sts-card__flag"
src={v2Flag}
alt=""
onError={(e) => { e.target.style.display = 'none'; }}
/>
)}
<span className="sts-card__name">{vessel2.vesselName || vessel2.vesselId}</span>
<button
type="button"
className="sts-card__expand-btn"
onClick={handleExpand}
title="상세 정보"
>
{isExpanded ? '▲' : '▼'}
</button>
<button
type="button"
className="sts-card__detail-btn"
onClick={handleDetail}
title="상세 모달"
>
</button>
</div>
{/* 접촉 시간대 */}
<div className="sts-card__time">
{formatTimestamp(firstContact.contactStartTimestamp)} ~ {formatTimestamp(lastContact.contactEndTimestamp)}
</div>
{/* Indicator 뱃지 */}
{activeIndicators.length > 0 && (
<div className="sts-card__indicators">
{activeIndicators.map(({ key, detail }) => (
<span key={key} className={`sts-card__badge sts-card__badge--${key}`}>
{detail}
</span>
))}
</div>
)}
{/* 확장 상세 */}
{isExpanded && (
<div className="sts-card__detail">
{/* 그룹 내 개별 접촉 목록 (2개 이상) */}
{group.contacts.length > 1 && (
<div className="sts-card__sub-contacts">
<span className="sts-card__sub-title">접촉 이력 ({group.contacts.length})</span>
{group.contacts.map((c, ci) => (
<div key={ci} className="sts-card__sub-contact">
<span className="sts-card__sub-num">#{ci + 1}</span>
<span>{formatTimestamp(c.contactStartTimestamp)} ~ {formatTimestamp(c.contactEndTimestamp)}</span>
<span className="sts-card__sep">|</span>
<span>{formatDuration(c.contactDurationMinutes)}</span>
<span className="sts-card__sep">|</span>
<span>평균 {formatDistance(c.avgDistanceMeters)}</span>
</div>
))}
</div>
)}
<div className="sts-card__detail-section">
<div className="sts-card__detail-row">
<span className="sts-card__detail-label">거리</span>
<span>
최소 {formatDistance(group.minDistanceMeters)} / 평균 {formatDistance(group.avgDistanceMeters)} / 최대 {formatDistance(group.maxDistanceMeters)}
</span>
</div>
<div className="sts-card__detail-row">
<span className="sts-card__detail-label">측정</span>
<span>{group.totalContactPointCount} 포인트</span>
</div>
{group.contactCenterPoint && (
<div className="sts-card__detail-row">
<span className="sts-card__detail-label">중심</span>
<span className="sts-card__pos">{formatPosition(group.contactCenterPoint)}</span>
</div>
)}
</div>
<VesselDetail label="선박1" vessel={group.vessel1} />
<VesselDetail label="선박2" vessel={group.vessel2} />
</div>
)}
</div>
</li>
);
}
function VesselDetail({ label, vessel }) {
return (
<div className="sts-card__vessel-detail">
<div className="sts-card__vessel-detail-header">
<span className="sts-card__detail-label">{label}</span>
<span>{vessel.vesselName || vessel.vesselId}</span>
</div>
<div className="sts-card__detail-row">
<span className="sts-card__detail-sublabel">구역체류</span>
<span>{formatDuration(vessel.insidePolygonDurationMinutes)}</span>
<span className="sts-card__sep">|</span>
<span>평균 {vessel.estimatedAvgSpeedKnots?.toFixed(1) ?? '-'}kn</span>
</div>
<div className="sts-card__detail-row">
<span className="sts-card__detail-sublabel">진입</span>
<span>{formatTimestamp(vessel.insidePolygonStartTs)}</span>
</div>
<div className="sts-card__detail-row">
<span className="sts-card__detail-sublabel">퇴출</span>
<span>{formatTimestamp(vessel.insidePolygonEndTs)}</span>
</div>
</div>
);
}
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 (
<ul className="sts-contact-list" ref={listRef}>
{groupedContacts.map((group, idx) => (
<GroupCard key={group.pairKey} group={group} index={idx} onDetailClick={onDetailClick} />
))}
</ul>
);
}

파일 보기

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

파일 보기

@ -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;
// 퀀 INOUT
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(
<div className="vessel-detail-overlay" onClick={onClose}>
<div
className="vessel-detail-modal"
style={{ left: position.x, top: position.y }}
onClick={(e) => e.stopPropagation()}
>
{/* 헤더 (드래그 핸들) */}
<div className="vessel-detail-modal__header" onMouseDown={handleMouseDown}>
<div className="vessel-detail-modal__title">
<span className="vessel-detail-modal__kind">{kindName}</span>
{flagUrl && (
<span className="vessel-detail-modal__flag">
<img src={flagUrl} alt="국기" onError={(e) => { e.target.style.display = 'none'; }} />
</span>
)}
<span className="vessel-detail-modal__name">
{track.shipName || track.targetId || '-'}
</span>
<span className="vessel-detail-modal__source">{sourceName}</span>
</div>
<button type="button" className="vessel-detail-modal__close" onClick={onClose}>
&times;
</button>
</div>
{/* 콘텐츠 (이미지 캡처 영역) */}
<div className="vessel-detail-modal__content" ref={contentRef}>
{/* OL 지도 */}
<div className="vessel-detail-modal__map" ref={mapContainerRef} />
{/* 방문 이력 */}
<div className="vessel-detail-modal__visits">
<h4 className="vessel-detail-modal__visits-title">방문 이력 (시간순)</h4>
<div className="vessel-detail-modal__visits-list">
{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 (
<div key={`${hit.polygonId}-${hit.visitIndex}-${idx}`} className="vessel-detail-modal__visit">
<span className="vessel-detail-modal__visit-seq">{idx + 1}.</span>
<div className="vessel-detail-modal__visit-body">
<div className="vessel-detail-modal__visit-zone">
<span className="vessel-detail-modal__visit-dot" style={{ backgroundColor: zoneColor }} />
<span style={{ color: zoneColor, fontWeight: 700 }}>{zoneName}</span>
{visitLabel && <span className="vessel-detail-modal__visit-idx">{visitLabel}</span>}
</div>
<div className="vessel-detail-modal__visit-row">
<span className="vessel-detail-modal__visit-label in">{idx + 1}-IN</span>
<span>{formatTimestamp(hit.entryTimestamp)}</span>
{entryPos && <span className="vessel-detail-modal__visit-pos">{entryPos}</span>}
</div>
<div className="vessel-detail-modal__visit-row">
<span className="vessel-detail-modal__visit-label out">{idx + 1}-OUT</span>
<span>{formatTimestamp(hit.exitTimestamp)}</span>
{exitPos && <span className="vessel-detail-modal__visit-pos">{exitPos}</span>}
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
{/* 하단 버튼 */}
<div className="vessel-detail-modal__footer">
<button type="button" className="vessel-detail-modal__save-btn" onClick={handleSaveImage}>
이미지 저장
</button>
</div>
</div>
</div>,
document.body,
);
}

파일 보기

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

파일 보기

@ -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 (
<li
key={zone.id}
className={`zone-item${dragIndexRef.current === index ? ' dragging' : ''}${dragOverIndex === index ? ' drag-over' : ''}`}
className={`zone-item${dragIndexRef.current === index ? ' dragging' : ''}${dragOverIndex === index ? ' drag-over' : ''}${selectedZoneId === zone.id ? ' selected' : ''}`}
draggable
onClick={() => 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 }) {
<span className="zone-color" style={{ backgroundColor: color.label }} />
<span className="zone-name">구역 {zone.name}</span>
<span className="zone-type">{zone.type}</span>
{selectedZoneId === zone.id && (
<span className="edit-hint">편집 </span>
)}
<button
type="button"
className="zone-delete"
onClick={() => removeZone(zone.id)}
onClick={(e) => handleRemoveZone(e, zone.id)}
disabled={disabled}
title="구역 삭제"
>

파일 보기

@ -84,6 +84,12 @@
padding-top: calc(0.8rem - 2px);
}
&.selected {
background-color: rgba(74, 158, 255, 0.1);
border-left: 3px solid var(--primary1, #4a9eff);
padding-left: calc(0.4rem - 3px);
}
&:last-child {
border-bottom: none;
}
@ -113,6 +119,12 @@
color: var(--tertiary4, #999);
}
.edit-hint {
font-size: var(--fs-xs, 1.1rem);
color: var(--primary1, #4a9eff);
font-weight: var(--fw-bold, 700);
}
.zone-delete {
background: none;
border: none;

파일 보기

@ -1,11 +1,10 @@
/**
* 항적분석 Deck.gl 레이어 관리
* 구조: 리플레이(useReplayLayer) 패턴 적용
*
* - React hook으로 currentTime 구독 프레임 리렌더
* - immediateRender() 즉시 반영
* - TripsLayer GPU 기반 궤적 표시
* - 정적(PathLayer) / 동적(IconLayer, TextLayer) 레이어 분리
* 성능 최적화:
* - currentTime은 zustand.subscribe로 React 렌더 바이패스
* - 정적 레이어(PathLayer) 캐싱 필터 변경 시에만 재생성
* - 동적 레이어(IconLayer, TextLayer, TripsLayer) 프레임 갱신
*/
import { useEffect, useRef, useCallback } from 'react';
import { TripsLayer } from '@deck.gl/geo-layers';
@ -18,15 +17,18 @@ import {
} 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시간
const TRAIL_LENGTH_MS = 3600000;
const RENDER_INTERVAL_MS = 100; // 재생 중 렌더링 쓰로틀 (~10fps)
export default function useAreaSearchLayer() {
const tripsDataRef = useRef([]);
const startTimeRef = useRef(0);
// React hook 구독 (매 프레임 리렌더)
// 정적 레이어 캐시 (필터/하이라이트 변경 시에만 갱신)
const staticLayerCacheRef = useRef({ layers: [], deps: null });
// React 구독: 필터/상태 (비빈번 변경만)
const queryCompleted = useAreaSearchStore((s) => s.queryCompleted);
const tracks = useAreaSearchStore((s) => s.tracks);
const disabledVesselIds = useAreaSearchStore((s) => s.disabledVesselIds);
@ -34,64 +36,24 @@ export default function useAreaSearchLayer() {
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);
// currentTime — React 구독 제거, zustand.subscribe로 대체
/**
* 트랙 필터링 (선종 + 개별 비활성화)
* 프레임 렌더링 (zustand.subscribe에서 직접 호출, React 리렌더 없음)
* currentTime은 getState() 읽어 useCallback deps 안정화
*/
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(() => {
const renderFrame = useCallback(() => {
if (!queryCompleted || tracks.length === 0) return;
// 현재 위치 계산
const allPositions = useAreaSearchStore.getState().getCurrentPositions(currentTime);
const filteredPositions = getFilteredPositions(allPositions);
// 선종별 카운트 → ShipLegend용 (replayStore 패턴)
// ShipLegend는 areaSearchStore.tracks를 직접 참조하므로 별도 저장 불필요
const ct = useAreaSearchAnimationStore.getState().currentTime;
const allPositions = useAreaSearchStore.getState().getCurrentPositions(ct);
const filteredPositions = allPositions.filter(
(p) => shipKindCodeFilter.has(p.shipKindCode),
);
const layers = [];
// 1. TripsLayer 궤적 표시
// 1. TripsLayer 궤적 (동적 — currentTime 의존)
if (showTrail && tripsDataRef.current.length > 0) {
const iconVesselIds = new Set(filteredPositions.map((p) => p.vesselId));
const filteredTripsData = tripsDataRef.current.filter(
@ -99,8 +61,6 @@ export default function useAreaSearchLayer() {
);
if (filteredTripsData.length > 0) {
const relativeCurrentTime = currentTime - startTimeRef.current;
layers.push(
new TripsLayer({
id: AREA_SEARCH_LAYER_IDS.TRIPS_TRAIL,
@ -114,28 +74,39 @@ export default function useAreaSearchLayer() {
capRounded: true,
fadeTrail: true,
trailLength: TRAIL_LENGTH_MS,
currentTime: relativeCurrentTime,
currentTime: ct - startTimeRef.current,
}),
);
}
}
// 2. 정적 항적 레이어 (PathLayer)
// 2. 정적 PathLayer (캐싱 — 필터/하이라이트 변경 시에만 재생성)
if (showPaths) {
const filteredTracks = getFilteredTracks();
const deps = staticLayerCacheRef.current.deps;
const needsRebuild = !deps
|| deps.tracks !== tracks
|| deps.disabledVesselIds !== disabledVesselIds
|| deps.shipKindCodeFilter !== shipKindCodeFilter
|| deps.highlightedVesselId !== highlightedVesselId;
const staticLayers = createStaticTrackLayers({
if (needsRebuild) {
const filteredTracks = tracks.filter((t) =>
!disabledVesselIds.has(t.vesselId) && shipKindCodeFilter.has(t.shipKindCode),
);
staticLayerCacheRef.current = {
layers: createStaticTrackLayers({
tracks: filteredTracks,
showPoints: false,
highlightedVesselId,
onPathHover: (vesselId) => {
useAreaSearchStore.getState().setHighlightedVesselId(vesselId);
},
layerIds: {
path: AREA_SEARCH_LAYER_IDS.PATH,
},
});
layers.push(...staticLayers);
layerIds: { path: AREA_SEARCH_LAYER_IDS.PATH },
}),
deps: { tracks, disabledVesselIds, shipKindCodeFilter, highlightedVesselId },
};
}
layers.push(...staticLayerCacheRef.current.layers);
}
// 3. 동적 가상 선박 레이어 (IconLayer + TextLayer)
@ -162,39 +133,80 @@ export default function useAreaSearchLayer() {
registerAreaSearchLayers(layers);
shipBatchRenderer.immediateRender();
}, [queryCompleted, tracks, currentTime, showPaths, showTrail, highlightedVesselId, getFilteredTracks, getFilteredPositions]);
}, [queryCompleted, tracks, showPaths, showTrail, highlightedVesselId, disabledVesselIds, shipKindCodeFilter]);
/**
* 쿼리 완료 TripsLayer 데이터 빌드
* 쿼리 완료 TripsLayer 데이터 빌드 (1)
*/
useEffect(() => {
if (!queryCompleted) {
unregisterAreaSearchLayers();
tripsDataRef.current = [];
staticLayerCacheRef.current = { layers: [], deps: null };
shipBatchRenderer.immediateRender();
return;
}
buildTripsData();
}, [queryCompleted, buildTripsData]);
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),
}));
}, [queryCompleted, tracks]);
/**
* currentTime 변경 애니메이션 렌더링 ( 프레임)
* currentTime 구독 (zustand.subscribe React 리렌더 바이패스)
* 재생 : ~10fps 쓰로틀 (RENDER_INTERVAL_MS)
* seek/정지: 즉시 렌더 (슬라이더 조작 반응성 유지)
*/
useEffect(() => {
if (!queryCompleted) return;
requestAnimatedRender();
}, [currentTime, queryCompleted, requestAnimatedRender]);
renderFrame();
let lastRenderTime = 0;
let pendingRafId = null;
const unsub = useAreaSearchAnimationStore.subscribe(
(s) => s.currentTime,
() => {
const isPlaying = useAreaSearchAnimationStore.getState().isPlaying;
if (!isPlaying) {
renderFrame();
return;
}
const now = performance.now();
if (now - lastRenderTime >= RENDER_INTERVAL_MS) {
lastRenderTime = now;
renderFrame();
} else if (!pendingRafId) {
pendingRafId = requestAnimationFrame(() => {
pendingRafId = null;
lastRenderTime = performance.now();
renderFrame();
});
}
},
);
return () => {
unsub();
if (pendingRafId) cancelAnimationFrame(pendingRafId);
};
}, [queryCompleted, renderFrame]);
/**
* 필터 변경 재렌더링
*/
useEffect(() => {
if (!queryCompleted) return;
requestAnimatedRender();
}, [showPaths, showTrail, shipKindCodeFilter, disabledVesselIds, highlightedVesselId, queryCompleted, requestAnimatedRender]);
/**
* 컴포넌트 언마운트 클린업
* 언마운트 클린업
*/
useEffect(() => {
return () => {

파일 보기

@ -0,0 +1,288 @@
/**
* STS 분석 Deck.gl 레이어 관리
*
* 정적 레이어: PathLayer (항적), ScatterplotLayer (접촉 포인트)
* 동적 레이어: TripsLayer (궤적), IconLayer (가상 선박), TextLayer (라벨)
*
* 성능 최적화:
* - currentTime은 zustand.subscribe로 React 렌더 바이패스
* - 정적 레이어 캐싱
*/
import { useEffect, useRef, useCallback } from 'react';
import { TripsLayer } from '@deck.gl/geo-layers';
import { ScatterplotLayer } from '@deck.gl/layers';
import { useStsStore } from '../stores/stsStore';
import { useAreaSearchAnimationStore } from '../stores/areaSearchAnimationStore';
import { STS_LAYER_IDS } from '../types/sts.types';
import { getContactRiskColor } from '../types/sts.types';
import {
registerStsLayers,
unregisterStsLayers,
} from '../utils/stsLayerRegistry';
import { createStaticTrackLayers, createVirtualShipLayers } from '../../map/layers/trackLayer';
import { shipBatchRenderer } from '../../map/ShipBatchRenderer';
const TRAIL_LENGTH_MS = 3600000;
const RENDER_INTERVAL_MS = 100;
export default function useStsLayer() {
const tripsDataRef = useRef([]);
const startTimeRef = useRef(0);
const staticLayerCacheRef = useRef({ layers: [], deps: null });
const contactLayerCacheRef = useRef({ layers: [], deps: null });
// React 구독: 그룹 기반
const queryCompleted = useStsStore((s) => s.queryCompleted);
const tracks = useStsStore((s) => s.tracks);
const groupedContacts = useStsStore((s) => s.groupedContacts);
const disabledGroupIndices = useStsStore((s) => s.disabledGroupIndices);
const highlightedGroupIndex = useStsStore((s) => s.highlightedGroupIndex);
const showPaths = useStsStore((s) => s.showPaths);
const showTrail = useStsStore((s) => s.showTrail);
/**
* 접촉 포인트 레이어 (그룹 기반, disabled/highlight 변경 재빌드)
*/
const buildContactLayers = useCallback(() => {
const deps = contactLayerCacheRef.current.deps;
const needsRebuild = !deps
|| deps.groupedContacts !== groupedContacts
|| deps.disabledGroupIndices !== disabledGroupIndices
|| deps.highlightedGroupIndex !== highlightedGroupIndex;
if (!needsRebuild) return contactLayerCacheRef.current.layers;
const layers = [];
// disabled가 아닌 그룹의 모든 하위 contacts를 flat
const enabledContacts = [];
groupedContacts.forEach((group, gIdx) => {
if (disabledGroupIndices.has(gIdx)) return;
group.contacts.forEach((c) => {
enabledContacts.push({
...c,
_groupIdx: gIdx,
});
});
});
if (enabledContacts.length === 0) {
contactLayerCacheRef.current = {
layers: [],
deps: { groupedContacts, disabledGroupIndices, highlightedGroupIndex },
};
return layers;
}
// ScatterplotLayer — 접촉 발생 지점
layers.push(
new ScatterplotLayer({
id: STS_LAYER_IDS.CONTACT_POINT,
data: enabledContacts.filter((c) => c.contactCenterPoint),
getPosition: (d) => d.contactCenterPoint,
getRadius: (d) => d._groupIdx === highlightedGroupIndex ? 800 : 500,
getFillColor: (d) => getContactRiskColor(d.indicators),
radiusMinPixels: 4,
radiusMaxPixels: 12,
pickable: true,
updateTriggers: {
getRadius: highlightedGroupIndex,
},
}),
);
contactLayerCacheRef.current = {
layers,
deps: { groupedContacts, disabledGroupIndices, highlightedGroupIndex },
};
return layers;
}, [groupedContacts, disabledGroupIndices, highlightedGroupIndex]);
/**
* 프레임 렌더링
*/
const renderFrame = useCallback(() => {
if (!queryCompleted || tracks.length === 0) return;
const ct = useAreaSearchAnimationStore.getState().currentTime;
const allPositions = useStsStore.getState().getCurrentPositions(ct);
const layers = [];
// 1. TripsLayer 궤적
if (showTrail && tripsDataRef.current.length > 0) {
const iconVesselIds = new Set(allPositions.map((p) => p.vesselId));
const filteredTripsData = tripsDataRef.current.filter(
(d) => iconVesselIds.has(d.vesselId),
);
if (filteredTripsData.length > 0) {
layers.push(
new TripsLayer({
id: STS_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: ct - startTimeRef.current,
}),
);
}
}
// 2. 정적 PathLayer (캐싱)
if (showPaths) {
const disabledVesselIds = useStsStore.getState().getDisabledVesselIds();
// 접촉 쌍의 양쪽 선박 항적 하이라이트
let stsHighlightedVesselIds = null;
if (highlightedGroupIndex !== null && groupedContacts[highlightedGroupIndex]) {
const g = groupedContacts[highlightedGroupIndex];
stsHighlightedVesselIds = new Set([g.vessel1.vesselId, g.vessel2.vesselId]);
}
const deps = staticLayerCacheRef.current.deps;
const needsRebuild = !deps
|| deps.tracks !== tracks
|| deps.disabledGroupIndices !== disabledGroupIndices
|| deps.highlightedGroupIndex !== highlightedGroupIndex;
if (needsRebuild) {
const filteredTracks = tracks.filter((t) => !disabledVesselIds.has(t.vesselId));
staticLayerCacheRef.current = {
layers: createStaticTrackLayers({
tracks: filteredTracks,
showPoints: false,
highlightedVesselIds: stsHighlightedVesselIds,
layerIds: { path: STS_LAYER_IDS.TRACK_PATH },
onPathHover: (vesselId) => {
if (!vesselId) {
useStsStore.getState().setHighlightedGroupIndex(null);
return;
}
const groups = useStsStore.getState().groupedContacts;
const idx = groups.findIndex(
(g) => g.vessel1.vesselId === vesselId || g.vessel2.vesselId === vesselId,
);
useStsStore.getState().setHighlightedGroupIndex(idx >= 0 ? idx : null);
},
}),
deps: { tracks, disabledGroupIndices, highlightedGroupIndex },
};
}
layers.push(...staticLayerCacheRef.current.layers);
}
// 3. 접촉 포인트 레이어 (캐싱)
layers.push(...buildContactLayers());
// 4. 동적 가상 선박 레이어
const dynamicLayers = createVirtualShipLayers({
currentPositions: allPositions,
showVirtualShip: allPositions.length > 0,
showLabels: allPositions.length > 0,
layerIds: {
icon: STS_LAYER_IDS.VIRTUAL_SHIP,
label: STS_LAYER_IDS.VIRTUAL_SHIP_LABEL,
},
});
layers.push(...dynamicLayers);
registerStsLayers(layers);
shipBatchRenderer.immediateRender();
}, [queryCompleted, tracks, groupedContacts, showPaths, showTrail, highlightedGroupIndex, disabledGroupIndices, buildContactLayers]);
/**
* 쿼리 완료 TripsLayer 데이터 빌드
*/
useEffect(() => {
if (!queryCompleted) {
unregisterStsLayers();
tripsDataRef.current = [];
staticLayerCacheRef.current = { layers: [], deps: null };
contactLayerCacheRef.current = { layers: [], deps: null };
shipBatchRenderer.immediateRender();
return;
}
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),
}));
}, [queryCompleted, tracks]);
/**
* currentTime 구독 (zustand.subscribe)
*/
useEffect(() => {
if (!queryCompleted) return;
renderFrame();
let lastRenderTime = 0;
let pendingRafId = null;
const unsub = useAreaSearchAnimationStore.subscribe(
(s) => s.currentTime,
() => {
const isPlaying = useAreaSearchAnimationStore.getState().isPlaying;
if (!isPlaying) {
renderFrame();
return;
}
const now = performance.now();
if (now - lastRenderTime >= RENDER_INTERVAL_MS) {
lastRenderTime = now;
renderFrame();
} else if (!pendingRafId) {
pendingRafId = requestAnimationFrame(() => {
pendingRafId = null;
lastRenderTime = performance.now();
renderFrame();
});
}
},
);
return () => {
unsub();
if (pendingRafId) cancelAnimationFrame(pendingRafId);
};
}, [queryCompleted, renderFrame]);
/**
* 하이라이트/비활성 상태 변경 즉시 리렌더
* currentTime subscribe와 별도로, UI 인터랙션 반응 보장
*/
useEffect(() => {
if (!queryCompleted) return;
renderFrame();
}, [queryCompleted, highlightedGroupIndex, disabledGroupIndices, showPaths, buildContactLayers]); // eslint-disable-line react-hooks/exhaustive-deps
/**
* 언마운트 클린업
*/
useEffect(() => {
return () => {
unregisterStsLayers();
tripsDataRef.current = [];
};
}, []);
}

파일 보기

@ -18,6 +18,7 @@ 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';
import { setZoneSource, getZoneSource, setZoneLayer, getZoneLayer } from '../utils/zoneLayerRefs';
/**
* 3857 좌표를 4326 좌표로 변환하고 폐곡선 보장
@ -48,8 +49,6 @@ function createZoneStyle(index) {
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);
@ -68,8 +67,8 @@ export default function useZoneDraw() {
zIndex: 55,
});
map.addLayer(layer);
sourceRef.current = source;
layerRef.current = layer;
setZoneSource(source);
setZoneLayer(layer);
// 기존 zones가 있으면 동기화
const { zones } = useAreaSearchStore.getState();
@ -85,8 +84,8 @@ export default function useZoneDraw() {
drawRef.current = null;
}
map.removeLayer(layer);
sourceRef.current = null;
layerRef.current = null;
setZoneSource(null);
setZoneLayer(null);
};
}, [map]);
@ -95,7 +94,7 @@ export default function useZoneDraw() {
const unsub = useAreaSearchStore.subscribe(
(s) => s.zones,
(zones) => {
const source = sourceRef.current;
const source = getZoneSource();
if (!source) return;
source.clear();
@ -114,7 +113,8 @@ export default function useZoneDraw() {
const unsub = useAreaSearchStore.subscribe(
(s) => s.showZones,
(show) => {
if (layerRef.current) layerRef.current.setVisible(show);
const layer = getZoneLayer();
if (layer) layer.setVisible(show);
},
);
return unsub;
@ -130,7 +130,7 @@ export default function useZoneDraw() {
if (!drawType) return;
const source = sourceRef.current;
const source = getZoneSource();
if (!source) return;
// source를 Draw에 전달하지 않음
@ -151,8 +151,10 @@ export default function useZoneDraw() {
let geom = feature.getGeometry();
const typeName = drawType;
// Circle → Polygon 변환
// Circle → Polygon 변환 (center/radius 보존)
let circleMeta = null;
if (drawType === ZONE_DRAW_TYPES.CIRCLE) {
circleMeta = { center: geom.getCenter(), radius: geom.getRadius() };
const polyGeom = fromCircle(geom, 64);
feature.setGeometry(polyGeom);
geom = polyGeom;
@ -186,6 +188,7 @@ export default function useZoneDraw() {
source: 'draw',
coordinates,
olFeature: feature,
circleMeta,
});
// addZone → activeDrawType: null → subscription → removeInteraction
}, 0);
@ -242,7 +245,7 @@ export default function useZoneDraw() {
(s) => s.zones,
(zones, prevZones) => {
if (!prevZones || zones.length >= prevZones.length) return;
const source = sourceRef.current;
const source = getZoneSource();
if (!source) return;
const currentIds = new Set(zones.map((z) => z.id));

파일 보기

@ -0,0 +1,513 @@
/**
* 구역 편집 인터랙션
*
* - 클릭으로 구역 선택/해제
* - Polygon: OL Modify (꼭짓점 드래그, 중점 삽입) + 우클릭 꼭짓점 삭제
* - Box: BoxResizeInteraction (모서리 드래그, 직사각형 유지)
* - Circle: CircleResizeInteraction (테두리 드래그, 원형 재생성)
* - 모든 유형: OL Translate (내부 드래그 전체 이동)
* - ESC: 선택 해제, Delete: 구역 삭제
* - 편집 완료 store 좌표 동기화
*/
import { useEffect, useRef, useCallback } from 'react';
import { Modify, Translate } from 'ol/interaction';
import Collection from 'ol/Collection';
import { Style, Fill, Stroke, Circle as CircleStyle } from 'ol/style';
import { transform } from 'ol/proj';
import { useMapStore } from '../../stores/mapStore';
import { useAreaSearchStore } from '../stores/areaSearchStore';
import { ZONE_COLORS, ZONE_DRAW_TYPES } from '../types/areaSearch.types';
import { getZoneSource } from '../utils/zoneLayerRefs';
import BoxResizeInteraction from '../interactions/BoxResizeInteraction';
import CircleResizeInteraction from '../interactions/CircleResizeInteraction';
/** 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;
}
/** 선택된 구역의 하이라이트 스타일 */
function createSelectedStyle(colorIndex) {
const color = ZONE_COLORS[colorIndex] || ZONE_COLORS[0];
return new Style({
fill: new Fill({ color: `rgba(${color.fill[0]},${color.fill[1]},${color.fill[2]},0.25)` }),
stroke: new Stroke({
color: `rgba(${color.stroke[0]},${color.stroke[1]},${color.stroke[2]},1)`,
width: 3,
lineDash: [8, 4],
}),
});
}
/** Modify 인터랙션의 꼭짓점 핸들 스타일 */
const MODIFY_STYLE = new Style({
image: new CircleStyle({
radius: 6,
fill: new Fill({ color: '#ffffff' }),
stroke: new Stroke({ color: '#4a9eff', width: 2 }),
}),
});
/** 기본 구역 스타일 복원 */
function createNormalStyle(colorIndex) {
const color = ZONE_COLORS[colorIndex] || ZONE_COLORS[0];
return new Style({
fill: new Fill({ color: `rgba(${color.fill.join(',')})` }),
stroke: new Stroke({ color: `rgba(${color.stroke.join(',')})`, width: 2 }),
});
}
/** 호버 스타일 (스트로크 강조) */
function createHoverStyle(colorIndex) {
const color = ZONE_COLORS[colorIndex] || ZONE_COLORS[0];
return new Style({
fill: new Fill({ color: `rgba(${color.fill.join(',')})` }),
stroke: new Stroke({
color: `rgba(${color.stroke[0]},${color.stroke[1]},${color.stroke[2]},1)`,
width: 3,
}),
});
}
/** 점(p)과 선분(a-b) 사이 최소 픽셀 거리 */
function pointToSegmentDist(p, a, b) {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return Math.hypot(p[0] - a[0], p[1] - a[1]);
let t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
}
const HANDLE_TOLERANCE = 12;
/** Polygon 꼭짓점/변 근접 검사 */
function isNearPolygonHandle(map, pixel, feature) {
const coords = feature.getGeometry().getCoordinates()[0];
const n = coords.length - 1;
for (let i = 0; i < n; i++) {
const vp = map.getPixelFromCoordinate(coords[i]);
if (Math.hypot(pixel[0] - vp[0], pixel[1] - vp[1]) < HANDLE_TOLERANCE) {
return true;
}
}
for (let i = 0; i < n; i++) {
const p1 = map.getPixelFromCoordinate(coords[i]);
const p2 = map.getPixelFromCoordinate(coords[(i + 1) % n]);
if (pointToSegmentDist(pixel, p1, p2) < HANDLE_TOLERANCE) {
return true;
}
}
return false;
}
/** Feature에서 좌표를 추출하여 store에 동기화 */
function syncZoneToStore(zoneId, feature, zone) {
const geom = feature.getGeometry();
const coords3857 = geom.getCoordinates()[0];
const coords4326 = toWgs84Polygon(coords3857);
let circleMeta;
if (zone.type === ZONE_DRAW_TYPES.CIRCLE && zone.circleMeta) {
// 폴리곤 중심에서 첫 번째 점까지의 거리로 반지름 재계산
const center = computeCentroid(coords3857);
const dx = coords3857[0][0] - center[0];
const dy = coords3857[0][1] - center[1];
circleMeta = { center, radius: Math.sqrt(dx * dx + dy * dy) };
}
useAreaSearchStore.getState().updateZoneGeometry(zoneId, coords4326, circleMeta);
}
/** 다각형 중심점 계산 */
function computeCentroid(coords) {
let sumX = 0, sumY = 0;
const n = coords.length - 1; // 마지막(닫힘) 좌표 제외
for (let i = 0; i < n; i++) {
sumX += coords[i][0];
sumY += coords[i][1];
}
return [sumX / n, sumY / n];
}
export default function useZoneEdit() {
const map = useMapStore((s) => s.map);
const mapRef = useRef(null);
const modifyRef = useRef(null);
const translateRef = useRef(null);
const customResizeRef = useRef(null);
const selectedCollectionRef = useRef(new Collection());
const clickListenerRef = useRef(null);
const contextMenuRef = useRef(null);
const keydownRef = useRef(null);
const hoveredZoneIdRef = useRef(null);
useEffect(() => { mapRef.current = map; }, [map]);
/** 인터랙션 모두 제거 */
const removeInteractions = useCallback(() => {
const m = mapRef.current;
if (!m) return;
if (modifyRef.current) { m.removeInteraction(modifyRef.current); modifyRef.current = null; }
if (translateRef.current) { m.removeInteraction(translateRef.current); translateRef.current = null; }
if (customResizeRef.current) { m.removeInteraction(customResizeRef.current); customResizeRef.current = null; }
selectedCollectionRef.current.clear();
}, []);
/** 선택된 구역에 대해 인터랙션 설정 */
const setupInteractions = useCallback((currentMap, zone) => {
removeInteractions();
if (!zone || !zone.olFeature) return;
const feature = zone.olFeature;
const collection = selectedCollectionRef.current;
collection.push(feature);
// 선택 스타일 적용
feature.setStyle(createSelectedStyle(zone.colorIndex));
// Translate (모든 유형 공통 — 내부 드래그로 이동)
const translate = new Translate({ features: collection });
translate.on('translateend', () => {
// Circle의 경우 center 업데이트
if (zone.type === ZONE_DRAW_TYPES.CIRCLE && customResizeRef.current) {
const coords = feature.getGeometry().getCoordinates()[0];
const newCenter = computeCentroid(coords);
customResizeRef.current.setCenter(newCenter);
}
syncZoneToStore(zone.id, feature, zone);
});
currentMap.addInteraction(translate);
translateRef.current = translate;
// 형상별 편집 인터랙션
if (zone.type === ZONE_DRAW_TYPES.POLYGON) {
const modify = new Modify({
features: collection,
style: MODIFY_STYLE,
deleteCondition: () => false, // 기본 삭제 비활성화 (우클릭으로 대체)
});
modify.on('modifyend', () => {
syncZoneToStore(zone.id, feature, zone);
});
currentMap.addInteraction(modify);
modifyRef.current = modify;
} else if (zone.type === ZONE_DRAW_TYPES.BOX) {
const boxResize = new BoxResizeInteraction({
feature,
onResize: () => syncZoneToStore(zone.id, feature, zone),
});
currentMap.addInteraction(boxResize);
customResizeRef.current = boxResize;
} else if (zone.type === ZONE_DRAW_TYPES.CIRCLE) {
const center = zone.circleMeta?.center || computeCentroid(feature.getGeometry().getCoordinates()[0]);
const circleResize = new CircleResizeInteraction({
feature,
center,
onResize: (f) => {
// 리사이즈 후 circleMeta 업데이트
const coords = f.getGeometry().getCoordinates()[0];
const newCenter = computeCentroid(coords);
const dx = coords[0][0] - newCenter[0];
const dy = coords[0][1] - newCenter[1];
const newRadius = Math.sqrt(dx * dx + dy * dy);
const coords4326 = toWgs84Polygon(coords);
useAreaSearchStore.getState().updateZoneGeometry(zone.id, coords4326, { center: newCenter, radius: newRadius });
},
});
currentMap.addInteraction(circleResize);
customResizeRef.current = circleResize;
}
}, [removeInteractions]);
/** 구역 선택 해제 시 스타일 복원 */
const restoreStyle = useCallback((zoneId) => {
const { zones } = useAreaSearchStore.getState();
const zone = zones.find(z => z.id === zoneId);
if (zone && zone.olFeature) {
zone.olFeature.setStyle(createNormalStyle(zone.colorIndex));
}
}, []);
// selectedZoneId 변경 구독 → 인터랙션 설정/해제
useEffect(() => {
if (!map) return;
let prevSelectedId = null;
const unsub = useAreaSearchStore.subscribe(
(s) => s.selectedZoneId,
(zoneId) => {
// 이전 선택 스타일 복원
if (prevSelectedId) restoreStyle(prevSelectedId);
prevSelectedId = zoneId;
if (!zoneId) {
removeInteractions();
return;
}
const { zones } = useAreaSearchStore.getState();
const zone = zones.find(z => z.id === zoneId);
if (zone) {
setupInteractions(map, zone);
}
},
);
return () => {
unsub();
if (prevSelectedId) restoreStyle(prevSelectedId);
removeInteractions();
};
}, [map, setupInteractions, removeInteractions, restoreStyle]);
// Drawing 모드 진입 시 편집 해제
useEffect(() => {
const unsub = useAreaSearchStore.subscribe(
(s) => s.activeDrawType,
(drawType) => {
if (drawType) {
useAreaSearchStore.getState().deselectZone();
}
},
);
return unsub;
}, []);
// 맵 singleclick → 구역 선택/해제
useEffect(() => {
if (!map) return;
const handleClick = (evt) => {
// Drawing 중이면 무시
if (useAreaSearchStore.getState().activeDrawType) return;
// 구역 그리기 직후 singleclick 방지 (OL singleclick 250ms 지연 레이스 컨디션)
if (Date.now() - useAreaSearchStore.getState()._lastZoneAddedAt < 500) return;
const source = getZoneSource();
if (!source) return;
// 클릭 지점의 feature 탐색
let clickedZone = null;
const { zones } = useAreaSearchStore.getState();
map.forEachFeatureAtPixel(evt.pixel, (feature) => {
if (clickedZone) return; // 이미 찾았으면 무시
const zone = zones.find(z => z.olFeature === feature);
if (zone) clickedZone = zone;
}, { layerFilter: (layer) => layer.getSource() === source });
const { selectedZoneId } = useAreaSearchStore.getState();
if (clickedZone) {
if (clickedZone.id === selectedZoneId) return; // 이미 선택됨
// 결과 표시 중이면 confirmAndClearResults
if (!useAreaSearchStore.getState().confirmAndClearResults()) return;
useAreaSearchStore.getState().selectZone(clickedZone.id);
} else {
// 빈 영역 클릭 → 선택 해제
if (selectedZoneId) {
useAreaSearchStore.getState().deselectZone();
}
}
};
map.on('singleclick', handleClick);
clickListenerRef.current = handleClick;
return () => {
map.un('singleclick', handleClick);
clickListenerRef.current = null;
};
}, [map]);
// 우클릭 꼭짓점 삭제 (Polygon 전용)
useEffect(() => {
if (!map) return;
const handleContextMenu = (e) => {
const { selectedZoneId, zones } = useAreaSearchStore.getState();
if (!selectedZoneId) return;
const zone = zones.find(z => z.id === selectedZoneId);
if (!zone || zone.type !== ZONE_DRAW_TYPES.POLYGON) return;
const feature = zone.olFeature;
if (!feature) return;
const geom = feature.getGeometry();
const coords = geom.getCoordinates()[0];
const vertexCount = coords.length - 1; // 마지막 닫힘 좌표 제외
if (vertexCount <= 3) return; // 최소 삼각형 유지
const pixel = map.getEventPixel(e);
const clickCoord = map.getCoordinateFromPixel(pixel);
// 가장 가까운 꼭짓점 탐색
let minDist = Infinity;
let minIdx = -1;
for (let i = 0; i < vertexCount; i++) {
const dx = coords[i][0] - clickCoord[0];
const dy = coords[i][1] - clickCoord[1];
const dist = dx * dx + dy * dy;
if (dist < minDist) { minDist = dist; minIdx = i; }
}
// 픽셀 거리 검증 (10px 이내)
const vPixel = map.getPixelFromCoordinate(coords[minIdx]);
if (Math.hypot(pixel[0] - vPixel[0], pixel[1] - vPixel[1]) > 10) return;
e.preventDefault();
const newCoords = [...coords];
newCoords.splice(minIdx, 1);
if (minIdx === 0) {
newCoords[newCoords.length - 1] = [...newCoords[0]];
}
geom.setCoordinates([newCoords]);
syncZoneToStore(zone.id, feature, zone);
};
const viewport = map.getViewport();
viewport.addEventListener('contextmenu', handleContextMenu);
contextMenuRef.current = handleContextMenu;
return () => {
viewport.removeEventListener('contextmenu', handleContextMenu);
contextMenuRef.current = null;
};
}, [map]);
// 키보드: ESC → 선택 해제, Delete → 구역 삭제
useEffect(() => {
const handleKeyDown = (e) => {
const { selectedZoneId, activeDrawType } = useAreaSearchStore.getState();
if (e.key === 'Escape' && selectedZoneId && !activeDrawType) {
useAreaSearchStore.getState().deselectZone();
}
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedZoneId && !activeDrawType) {
useAreaSearchStore.getState().deselectZone();
useAreaSearchStore.getState().removeZone(selectedZoneId);
}
};
window.addEventListener('keydown', handleKeyDown);
keydownRef.current = handleKeyDown;
return () => {
window.removeEventListener('keydown', handleKeyDown);
keydownRef.current = null;
};
}, []);
// pointermove → 호버 피드백 (커서 + 스타일)
useEffect(() => {
if (!map) return;
const viewport = map.getViewport();
const handlePointerMove = (evt) => {
if (evt.dragging) return;
// Drawing 중이면 호버 해제
if (useAreaSearchStore.getState().activeDrawType) {
if (hoveredZoneIdRef.current) {
restoreStyle(hoveredZoneIdRef.current);
hoveredZoneIdRef.current = null;
}
viewport.style.cursor = '';
return;
}
const source = getZoneSource();
if (!source) return;
const { selectedZoneId, zones } = useAreaSearchStore.getState();
// 1. 선택된 구역 — 리사이즈 핸들 / 내부 커서
if (selectedZoneId) {
const zone = zones.find(z => z.id === selectedZoneId);
if (zone && zone.olFeature) {
// Box/Circle: isOverHandle
if (customResizeRef.current && customResizeRef.current.isOverHandle) {
const handle = customResizeRef.current.isOverHandle(map, evt.pixel);
if (handle) {
viewport.style.cursor = handle.cursor;
return;
}
}
// Polygon: 꼭짓점/변 근접
if (zone.type === ZONE_DRAW_TYPES.POLYGON) {
if (isNearPolygonHandle(map, evt.pixel, zone.olFeature)) {
viewport.style.cursor = 'crosshair';
return;
}
}
// 선택된 구역 내부 → move
let overSelected = false;
map.forEachFeatureAtPixel(evt.pixel, (feature) => {
if (feature === zone.olFeature) overSelected = true;
}, { layerFilter: (l) => l.getSource() === source });
if (overSelected) {
viewport.style.cursor = 'move';
return;
}
}
}
// 2. 비선택 구역 호버
let hoveredZone = null;
map.forEachFeatureAtPixel(evt.pixel, (feature) => {
if (hoveredZone) return;
const zone = zones.find(z => z.olFeature === feature && z.id !== selectedZoneId);
if (zone) hoveredZone = zone;
}, { layerFilter: (l) => l.getSource() === source });
if (hoveredZone) {
viewport.style.cursor = 'pointer';
if (hoveredZoneIdRef.current !== hoveredZone.id) {
if (hoveredZoneIdRef.current) restoreStyle(hoveredZoneIdRef.current);
hoveredZoneIdRef.current = hoveredZone.id;
hoveredZone.olFeature.setStyle(createHoverStyle(hoveredZone.colorIndex));
}
} else {
viewport.style.cursor = '';
if (hoveredZoneIdRef.current) {
restoreStyle(hoveredZoneIdRef.current);
hoveredZoneIdRef.current = null;
}
}
};
map.on('pointermove', handlePointerMove);
return () => {
map.un('pointermove', handlePointerMove);
if (hoveredZoneIdRef.current) {
restoreStyle(hoveredZoneIdRef.current);
hoveredZoneIdRef.current = null;
}
viewport.style.cursor = '';
};
}, [map, restoreStyle]);
}

파일 보기

@ -0,0 +1,149 @@
/**
* 사각형(Box) 리사이즈 커스텀 인터랙션
*
* OL Modify는 사각형 제약을 지원하지 않으므로 PointerInteraction을 확장.
* - 모서리 드래그: 대각 꼭짓점 고정, 자유 리사이즈
* - 드래그: 반대쪽 고정, 1 리사이즈
*/
import PointerInteraction from 'ol/interaction/Pointer';
const CORNER_TOLERANCE = 16;
const EDGE_TOLERANCE = 12;
/** 점(p)과 선분(a-b) 사이 최소 픽셀 거리 */
function pointToSegmentDist(p, a, b) {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return Math.hypot(p[0] - a[0], p[1] - a[1]);
let t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
}
export default class BoxResizeInteraction extends PointerInteraction {
constructor(options) {
super({
handleDownEvent: (evt) => BoxResizeInteraction.prototype._handleDown.call(this, evt),
handleDragEvent: (evt) => BoxResizeInteraction.prototype._handleDrag.call(this, evt),
handleUpEvent: (evt) => BoxResizeInteraction.prototype._handleUp.call(this, evt),
});
this.feature_ = options.feature;
this.onResize_ = options.onResize || null;
// corner mode
this.mode_ = null; // 'corner' | 'edge'
this.draggedIndex_ = null;
this.anchorCoord_ = null;
// edge mode
this.edgeIndex_ = null;
this.bbox_ = null;
}
_handleDown(evt) {
const pixel = evt.pixel;
const coords = this.feature_.getGeometry().getCoordinates()[0];
// 1. 모서리 감지 (우선)
for (let i = 0; i < 4; i++) {
const vp = evt.map.getPixelFromCoordinate(coords[i]);
if (Math.hypot(pixel[0] - vp[0], pixel[1] - vp[1]) < CORNER_TOLERANCE) {
this.mode_ = 'corner';
this.draggedIndex_ = i;
this.anchorCoord_ = coords[(i + 2) % 4];
return true;
}
}
// 2. 변 감지
for (let i = 0; i < 4; i++) {
const j = (i + 1) % 4;
const p1 = evt.map.getPixelFromCoordinate(coords[i]);
const p2 = evt.map.getPixelFromCoordinate(coords[j]);
if (pointToSegmentDist(pixel, p1, p2) < EDGE_TOLERANCE) {
this.mode_ = 'edge';
this.edgeIndex_ = i;
const xs = coords.slice(0, 4).map(c => c[0]);
const ys = coords.slice(0, 4).map(c => c[1]);
this.bbox_ = {
minX: Math.min(...xs), maxX: Math.max(...xs),
minY: Math.min(...ys), maxY: Math.max(...ys),
};
return true;
}
}
return false;
}
_handleDrag(evt) {
const coord = evt.coordinate;
if (this.mode_ === 'corner') {
const anchor = this.anchorCoord_;
const minX = Math.min(coord[0], anchor[0]);
const maxX = Math.max(coord[0], anchor[0]);
const minY = Math.min(coord[1], anchor[1]);
const maxY = Math.max(coord[1], anchor[1]);
this.feature_.getGeometry().setCoordinates([[
[minX, maxY], [maxX, maxY], [maxX, minY], [minX, minY], [minX, maxY],
]]);
} else if (this.mode_ === 'edge') {
let { minX, maxX, minY, maxY } = this.bbox_;
// Edge 0: top(TL→TR), 1: right(TR→BR), 2: bottom(BR→BL), 3: left(BL→TL)
switch (this.edgeIndex_) {
case 0: maxY = coord[1]; break;
case 1: maxX = coord[0]; break;
case 2: minY = coord[1]; break;
case 3: minX = coord[0]; break;
}
const x1 = Math.min(minX, maxX), x2 = Math.max(minX, maxX);
const y1 = Math.min(minY, maxY), y2 = Math.max(minY, maxY);
this.feature_.getGeometry().setCoordinates([[
[x1, y2], [x2, y2], [x2, y1], [x1, y1], [x1, y2],
]]);
}
}
_handleUp() {
if (this.mode_) {
this.mode_ = null;
this.draggedIndex_ = null;
this.anchorCoord_ = null;
this.edgeIndex_ = null;
this.bbox_ = null;
if (this.onResize_) this.onResize_(this.feature_);
return true;
}
return false;
}
/**
* 호버 감지: 픽셀이 리사이즈 핸들 위인지 확인
* @returns {{ cursor: string }} | null
*/
isOverHandle(map, pixel) {
const coords = this.feature_.getGeometry().getCoordinates()[0];
// 모서리 감지
const cornerCursors = ['nwse-resize', 'nesw-resize', 'nwse-resize', 'nesw-resize'];
for (let i = 0; i < 4; i++) {
const vp = map.getPixelFromCoordinate(coords[i]);
if (Math.hypot(pixel[0] - vp[0], pixel[1] - vp[1]) < CORNER_TOLERANCE) {
return { cursor: cornerCursors[i] };
}
}
// 변 감지
const edgeCursors = ['ns-resize', 'ew-resize', 'ns-resize', 'ew-resize'];
for (let i = 0; i < 4; i++) {
const j = (i + 1) % 4;
const p1 = map.getPixelFromCoordinate(coords[i]);
const p2 = map.getPixelFromCoordinate(coords[j]);
if (pointToSegmentDist(pixel, p1, p2) < EDGE_TOLERANCE) {
return { cursor: edgeCursors[i] };
}
}
return null;
}
}

파일 보기

@ -0,0 +1,90 @@
/**
* (Circle) 리사이즈 커스텀 인터랙션
*
* 원은 64 Polygon으로 저장되어 있으므로, 테두리 드래그
* 중심에서 드래그 좌표까지의 거리를 반지름으로 계산하고
* fromCircle() Polygon을 재생성.
*
* 감지 방식: 개별 꼭짓점이 아닌, 중심~포인터 거리와 반지름 비교 (테두리 전체 감지)
*/
import PointerInteraction from 'ol/interaction/Pointer';
import { fromCircle } from 'ol/geom/Polygon';
import OlCircle from 'ol/geom/Circle';
const PIXEL_TOLERANCE = 16;
const MIN_RADIUS = 100; // 최소 반지름 (미터)
export default class CircleResizeInteraction extends PointerInteraction {
constructor(options) {
super({
handleDownEvent: (evt) => CircleResizeInteraction.prototype._handleDown.call(this, evt),
handleDragEvent: (evt) => CircleResizeInteraction.prototype._handleDrag.call(this, evt),
handleUpEvent: (evt) => CircleResizeInteraction.prototype._handleUp.call(this, evt),
});
this.feature_ = options.feature;
this.center_ = options.center; // EPSG:3857 [x, y]
this.onResize_ = options.onResize || null;
this.dragging_ = false;
}
/** 중심~포인터 픽셀 거리와 표시 반지름 비교 */
_isNearEdge(map, pixel) {
const centerPixel = map.getPixelFromCoordinate(this.center_);
const coords = this.feature_.getGeometry().getCoordinates()[0];
const edgePixel = map.getPixelFromCoordinate(coords[0]);
const radiusPixels = Math.hypot(
edgePixel[0] - centerPixel[0],
edgePixel[1] - centerPixel[1],
);
const distFromCenter = Math.hypot(
pixel[0] - centerPixel[0],
pixel[1] - centerPixel[1],
);
return Math.abs(distFromCenter - radiusPixels) < PIXEL_TOLERANCE;
}
_handleDown(evt) {
if (this._isNearEdge(evt.map, evt.pixel)) {
this.dragging_ = true;
return true;
}
return false;
}
_handleDrag(evt) {
if (!this.dragging_) return;
const coord = evt.coordinate;
const dx = coord[0] - this.center_[0];
const dy = coord[1] - this.center_[1];
const newRadius = Math.max(Math.sqrt(dx * dx + dy * dy), MIN_RADIUS);
const circleGeom = new OlCircle(this.center_, newRadius);
const polyGeom = fromCircle(circleGeom, 64);
this.feature_.setGeometry(polyGeom);
}
_handleUp() {
if (this.dragging_) {
this.dragging_ = false;
if (this.onResize_) this.onResize_(this.feature_);
return true;
}
return false;
}
/** 외부에서 center 업데이트 (Translate 후) */
setCenter(center) {
this.center_ = center;
}
/**
* 호버 감지: 픽셀이 리사이즈 핸들(테두리) 위인지 확인
* @returns {{ cursor: string }} | null
*/
isOverHandle(map, pixel) {
if (this._isNearEdge(map, pixel)) {
return { cursor: 'nesw-resize' };
}
return null;
}
}

파일 보기

@ -0,0 +1,80 @@
/**
* STS(Ship-to-Ship) 접촉 검출 REST API 서비스
*
* POST /api/v2/tracks/vessel-contacts
* 응답 변환: trackQueryApi.convertToProcessedTracks() 재사용
*/
import { convertToProcessedTracks } from '../../tracking/services/trackQueryApi';
const API_ENDPOINT = '/api/v2/tracks/vessel-contacts';
/**
* Unix /밀리초 밀리초 변환
*/
function toMs(ts) {
if (!ts) return null;
const num = typeof ts === 'number' ? ts : parseInt(ts, 10);
return num < 10000000000 ? num * 1000 : num;
}
/**
* STS 접촉 검출 API 호출
*
* @param {Object} params
* @param {string} params.startTime ISO 8601
* @param {string} params.endTime ISO 8601
* @param {{id: string, name: string, coordinates: number[][]}} params.polygon 단일 폴리곤
* @param {number} params.minContactDurationMinutes 30~360
* @param {number} params.maxContactDistanceMeters 50~5000
* @returns {Promise<{contacts: Array, tracks: Array, summary: Object}>}
*/
export async function fetchVesselContacts(params) {
const request = {
startTime: params.startTime,
endTime: params.endTime,
polygon: params.polygon,
minContactDurationMinutes: params.minContactDurationMinutes,
maxContactDistanceMeters: params.maxContactDistanceMeters,
};
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();
// tracks 변환
const rawTracks = Array.isArray(result.tracks) ? result.tracks : [];
const tracks = convertToProcessedTracks(rawTracks);
// contacts: timestamp 초→밀리초 변환
const rawContacts = Array.isArray(result.contacts) ? result.contacts : [];
const contacts = rawContacts.map((c) => ({
...c,
contactStartTimestamp: toMs(c.contactStartTimestamp),
contactEndTimestamp: toMs(c.contactEndTimestamp),
vessel1: {
...c.vessel1,
insidePolygonStartTs: toMs(c.vessel1?.insidePolygonStartTs),
insidePolygonEndTs: toMs(c.vessel1?.insidePolygonEndTs),
},
vessel2: {
...c.vessel2,
insidePolygonStartTs: toMs(c.vessel2?.insidePolygonStartTs),
insidePolygonEndTs: toMs(c.vessel2?.insidePolygonEndTs),
},
}));
return {
contacts,
tracks,
summary: result.summary || null,
};
}

파일 보기

@ -8,7 +8,8 @@
*/
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
import { SEARCH_MODES, MAX_ZONES, ZONE_NAMES, ALL_SHIP_KIND_CODES } from '../types/areaSearch.types';
import { SEARCH_MODES, MAX_ZONES, ZONE_NAMES, ALL_SHIP_KIND_CODES, ANALYSIS_TABS } from '../types/areaSearch.types';
import { showLiveShips } from '../../utils/liveControl';
/**
* 지점 사이 선박 위치를 시간 기반 보간
@ -36,10 +37,17 @@ function calculateHeading(p1, p2) {
let zoneIdCounter = 0;
// 커서 기반 선형 탐색용 (vesselId → lastIndex)
// 재생 중 시간은 단조 증가 → O(1~2) 전진, seek 시 이진탐색 fallback
const positionCursors = new Map();
export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({
// 탭 상태
activeTab: ANALYSIS_TABS.AREA,
// 검색 조건
zones: [],
searchMode: SEARCH_MODES.ANY,
searchMode: SEARCH_MODES.SEQUENTIAL,
// 검색 결과
tracks: [],
@ -54,6 +62,8 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({
showZones: true,
activeDrawType: null,
areaSearchTooltip: null,
selectedZoneId: null,
_lastZoneAddedAt: 0,
// 필터 상태
showPaths: true,
@ -65,20 +75,28 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({
addZone: (zone) => {
const { zones } = get();
if (zones.length >= MAX_ZONES) return;
const idx = zones.length;
// 사용 중인 colorIndex를 피해 첫 번째 빈 인덱스 할당
const usedColors = new Set(zones.map(z => z.colorIndex));
let colorIndex = 0;
while (usedColors.has(colorIndex)) colorIndex++;
const newZone = {
...zone,
id: `zone-${++zoneIdCounter}`,
name: ZONE_NAMES[idx] || `${idx + 1}`,
colorIndex: idx,
name: ZONE_NAMES[colorIndex] || `${colorIndex + 1}`,
colorIndex,
circleMeta: zone.circleMeta || null,
};
set({ zones: [...zones, newZone], activeDrawType: null });
set({ zones: [...zones, newZone], activeDrawType: null, _lastZoneAddedAt: Date.now() });
},
removeZone: (zoneId) => {
const { zones } = get();
const { zones, selectedZoneId } = get();
const filtered = zones.filter(z => z.id !== zoneId);
set({ zones: filtered });
const updates = { zones: filtered };
if (selectedZoneId === zoneId) updates.selectedZoneId = null;
set(updates);
},
clearZones: () => set({ zones: [] }),
@ -93,20 +111,57 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({
set({ zones: newZones });
},
// ========== 탭 전환 ==========
setActiveTab: (tab) => set({ activeTab: tab }),
// ========== 검색 조건 ==========
setSearchMode: (mode) => set({ searchMode: mode }),
setActiveDrawType: (type) => set({ activeDrawType: type }),
setShowZones: (show) => set({ showZones: show }),
// ========== 구역 편집 ==========
selectZone: (zoneId) => set({ selectedZoneId: zoneId }),
deselectZone: () => set({ selectedZoneId: null }),
updateZoneGeometry: (zoneId, coordinates4326, circleMeta) => {
const { zones } = get();
const updated = zones.map(z => {
if (z.id !== zoneId) return z;
const patch = { ...z, coordinates: coordinates4326 };
if (circleMeta !== undefined) patch.circleMeta = circleMeta;
return patch;
});
set({ zones: updated });
},
/**
* 조회 조건 변경 결과 초기화 확인
* 결과가 없으면 true 반환, 있으면 confirm 초기화
* @returns {boolean} 진행 허용 여부
*/
confirmAndClearResults: () => {
const { queryCompleted } = get();
if (!queryCompleted) return true;
const ok = window.confirm('조회 조건을 변경하면 기존 결과가 초기화됩니다.\n계속하시겠습니까?');
if (!ok) return false;
get().clearResults();
showLiveShips();
return true;
},
// ========== 검색 결과 ==========
setTracks: (tracks) => {
if (tracks.length === 0) {
set({ tracks: [], queryCompleted: true });
set({ tracks: [], queryCompleted: true, selectedZoneId: null });
return;
}
set({ tracks, queryCompleted: true });
set({ tracks, queryCompleted: true, selectedZoneId: null });
},
setHitDetails: (hitDetails) => set({ hitDetails }),
@ -148,7 +203,9 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({
},
/**
* 현재 시간의 모든 선박 위치 계산 (이진 탐색 + 선형 보간)
* 현재 시간의 모든 선박 위치 계산 (커서 기반 선형 탐색 + 보간)
* 재생 : 커서에서 선형 전진 O(1~2)
* seek/역방향: 이진 탐색 fallback O(log n)
*/
getCurrentPositions: (currentTime) => {
const { tracks, disabledVesselIds } = get();
@ -163,16 +220,32 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({
const lastTime = timestampsMs[timestampsMs.length - 1];
if (currentTime < firstTime || currentTime > lastTime) return;
// 커서 기반 탐색: 이전 인덱스에서 선형 전진 시도
let cursor = positionCursors.get(vesselId);
if (cursor === undefined
|| cursor >= timestampsMs.length
|| (cursor > 0 && timestampsMs[cursor - 1] > currentTime)) {
// 커서 없음 or 무효 or 시간 역행 → 이진 탐색 fallback
let left = 0;
let right = timestampsMs.length - 1;
while (left < right) {
const mid = Math.floor((left + right) / 2);
const mid = (left + right) >> 1;
if (timestampsMs[mid] < currentTime) left = mid + 1;
else right = mid;
}
cursor = left;
} else {
// 선형 전진 (재생 중 1~2칸)
while (cursor < timestampsMs.length - 1 && timestampsMs[cursor] < currentTime) {
cursor++;
}
}
const idx1 = Math.max(0, left - 1);
const idx2 = Math.min(timestampsMs.length - 1, left);
positionCursors.set(vesselId, cursor);
const idx1 = Math.max(0, cursor - 1);
const idx2 = Math.min(timestampsMs.length - 1, cursor);
let position, heading, speed;
@ -202,6 +275,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({
// ========== 초기화 ==========
clearResults: () => {
positionCursors.clear();
set({
tracks: [],
hitDetails: {},
@ -217,9 +291,11 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({
},
reset: () => {
positionCursors.clear();
set({
activeTab: ANALYSIS_TABS.AREA,
zones: [],
searchMode: SEARCH_MODES.ANY,
searchMode: SEARCH_MODES.SEQUENTIAL,
tracks: [],
hitDetails: {},
summary: null,
@ -230,6 +306,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({
showZones: true,
activeDrawType: null,
areaSearchTooltip: null,
selectedZoneId: null,
showPaths: true,
showTrail: false,
shipKindCodeFilter: new Set(ALL_SHIP_KIND_CODES),

파일 보기

@ -0,0 +1,275 @@
/**
* STS(Ship-to-Ship) 분석 상태 관리 스토어
*
* - STS 파라미터 (최소 접촉 시간, 최대 접촉 거리)
* - 결과 데이터 (contacts, groupedContacts, tracks, summary)
* - 그룹핑: 동일 선박 쌍의 여러 접촉을 하나의 그룹으로
* - 커서 기반 위치 보간 (getCurrentPositions)
*/
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
import { STS_DEFAULTS } from '../types/sts.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];
}
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;
}
/**
* contacts 배열을 선박 기준으로 그룹핑
*/
function groupContactsByPair(contacts) {
const groupMap = new Map();
contacts.forEach((contact) => {
const v1Id = contact.vessel1.vesselId;
const v2Id = contact.vessel2.vesselId;
const pairKey = v1Id < v2Id ? `${v1Id}_${v2Id}` : `${v2Id}_${v1Id}`;
if (!groupMap.has(pairKey)) {
groupMap.set(pairKey, {
pairKey,
vessel1: v1Id < v2Id ? contact.vessel1 : contact.vessel2,
vessel2: v1Id < v2Id ? contact.vessel2 : contact.vessel1,
contacts: [],
});
}
groupMap.get(pairKey).contacts.push(contact);
});
return [...groupMap.values()].map((group) => {
group.contacts.sort((a, b) => a.contactStartTimestamp - b.contactStartTimestamp);
// 합산 통계
group.totalDurationMinutes = group.contacts.reduce(
(s, c) => s + (c.contactDurationMinutes || 0), 0,
);
// 가중 평균 거리
let totalWeight = 0;
let weightedSum = 0;
group.contacts.forEach((c) => {
const w = c.contactPointCount || 1;
weightedSum += (c.avgDistanceMeters || 0) * w;
totalWeight += w;
});
group.avgDistanceMeters = totalWeight > 0 ? weightedSum / totalWeight : 0;
group.minDistanceMeters = Math.min(...group.contacts.map((c) => c.minDistanceMeters));
group.maxDistanceMeters = Math.max(...group.contacts.map((c) => c.maxDistanceMeters));
group.contactCenterPoint = group.contacts[0].contactCenterPoint;
group.totalContactPointCount = group.contacts.reduce(
(s, c) => s + (c.contactPointCount || 0), 0,
);
// indicators OR 합산
group.indicators = {};
group.contacts.forEach((c) => {
if (c.indicators) {
Object.entries(c.indicators).forEach(([k, v]) => {
if (v) group.indicators[k] = true;
});
}
});
return group;
});
}
const positionCursors = new Map();
export const useStsStore = create(subscribeWithSelector((set, get) => ({
// STS 파라미터
minContactDurationMinutes: STS_DEFAULTS.MIN_CONTACT_DURATION,
maxContactDistanceMeters: STS_DEFAULTS.MAX_CONTACT_DISTANCE,
// 결과
contacts: [],
groupedContacts: [],
tracks: [],
summary: null,
// UI
isLoading: false,
queryCompleted: false,
highlightedGroupIndex: null,
disabledGroupIndices: new Set(),
expandedGroupIndex: null,
// 필터 상태
showPaths: true,
showTrail: false,
// ========== 파라미터 설정 ==========
setMinContactDuration: (val) => set({ minContactDurationMinutes: val }),
setMaxContactDistance: (val) => set({ maxContactDistanceMeters: val }),
// ========== 결과 설정 ==========
setResults: ({ contacts, tracks, summary }) => {
positionCursors.clear();
const groupedContacts = groupContactsByPair(contacts);
set({
contacts,
groupedContacts,
tracks,
summary,
queryCompleted: true,
disabledGroupIndices: new Set(),
highlightedGroupIndex: null,
expandedGroupIndex: null,
});
},
setLoading: (loading) => set({ isLoading: loading }),
// ========== 그룹 UI ==========
setHighlightedGroupIndex: (idx) => set({ highlightedGroupIndex: idx }),
setExpandedGroupIndex: (idx) => {
const { expandedGroupIndex } = get();
set({ expandedGroupIndex: expandedGroupIndex === idx ? null : idx });
},
toggleGroupEnabled: (idx) => {
const { disabledGroupIndices } = get();
const newDisabled = new Set(disabledGroupIndices);
if (newDisabled.has(idx)) newDisabled.delete(idx);
else newDisabled.add(idx);
set({ disabledGroupIndices: newDisabled });
},
// ========== 필터 ==========
setShowPaths: (show) => set({ showPaths: show }),
setShowTrail: (show) => set({ showTrail: show }),
getDisabledVesselIds: () => {
const { groupedContacts, disabledGroupIndices } = get();
const ids = new Set();
disabledGroupIndices.forEach((idx) => {
const g = groupedContacts[idx];
if (g) {
ids.add(g.vessel1.vesselId);
ids.add(g.vessel2.vesselId);
}
});
return ids;
},
getCurrentPositions: (currentTime) => {
const { tracks } = get();
const disabledVesselIds = get().getDisabledVesselIds();
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 cursor = positionCursors.get(vesselId);
if (cursor === undefined
|| cursor >= timestampsMs.length
|| (cursor > 0 && timestampsMs[cursor - 1] > currentTime)) {
let left = 0;
let right = timestampsMs.length - 1;
while (left < right) {
const mid = (left + right) >> 1;
if (timestampsMs[mid] < currentTime) left = mid + 1;
else right = mid;
}
cursor = left;
} else {
while (cursor < timestampsMs.length - 1 && timestampsMs[cursor] < currentTime) {
cursor++;
}
}
positionCursors.set(vesselId, cursor);
const idx1 = Math.max(0, cursor - 1);
const idx2 = Math.min(timestampsMs.length - 1, cursor);
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: () => {
positionCursors.clear();
set({
contacts: [],
groupedContacts: [],
tracks: [],
summary: null,
queryCompleted: false,
disabledGroupIndices: new Set(),
highlightedGroupIndex: null,
expandedGroupIndex: null,
showPaths: true,
showTrail: false,
});
},
reset: () => {
positionCursors.clear();
set({
minContactDurationMinutes: STS_DEFAULTS.MIN_CONTACT_DURATION,
maxContactDistanceMeters: STS_DEFAULTS.MAX_CONTACT_DISTANCE,
contacts: [],
groupedContacts: [],
tracks: [],
summary: null,
isLoading: false,
queryCompleted: false,
disabledGroupIndices: new Set(),
highlightedGroupIndex: null,
expandedGroupIndex: null,
showPaths: true,
showTrail: false,
});
},
})));
export default useStsStore;

파일 보기

@ -2,6 +2,13 @@
* 항적분석(구역 검색) 상수 타입 정의
*/
// ========== 분석 탭 ==========
export const ANALYSIS_TABS = {
AREA: 'area',
STS: 'sts',
};
// ========== 검색 모드 ==========
export const SEARCH_MODES = {
@ -29,9 +36,9 @@ export const ZONE_DRAW_TYPES = {
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' },
{ fill: [255, 59, 48, 0.15], stroke: [255, 59, 48, 0.8], label: '#FF3B30' },
{ fill: [0, 199, 190, 0.15], stroke: [0, 199, 190, 0.8], label: '#00C7BE' },
{ fill: [255, 204, 0, 0.15], stroke: [255, 204, 0, 0.8], label: '#FFCC00' },
];
// ========== 조회기간 제약 ==========
@ -58,7 +65,7 @@ export function getQueryDateRange() {
// ========== 배속 옵션 ==========
export const PLAYBACK_SPEED_OPTIONS = [1, 5, 10, 50, 100, 1000];
export const PLAYBACK_SPEED_OPTIONS = [1, 10, 50, 100, 500, 1000];
// ========== 선종 코드 전체 목록 (필터 초기값) ==========

파일 보기

@ -0,0 +1,106 @@
/**
* STS(Ship-to-Ship) 분석 상수 유틸리티
*/
import { getShipKindName } from '../../tracking/types/trackQuery.types';
// ========== 파라미터 기본값 / 범위 ==========
export const STS_DEFAULTS = {
MIN_CONTACT_DURATION: 60,
MAX_CONTACT_DISTANCE: 500,
};
export const STS_LIMITS = {
DURATION_MIN: 30,
DURATION_MAX: 360,
DISTANCE_MIN: 50,
DISTANCE_MAX: 5000,
};
// ========== 레이어 ID ==========
export const STS_LAYER_IDS = {
TRACK_PATH: 'sts-track-path-layer',
CONTACT_LINE: 'sts-contact-line-layer',
CONTACT_POINT: 'sts-contact-point-layer',
TRIPS_TRAIL: 'sts-trips-trail-layer',
VIRTUAL_SHIP: 'sts-virtual-ship-layer',
VIRTUAL_SHIP_LABEL: 'sts-virtual-ship-label-layer',
};
// ========== Indicator 라벨 ==========
export const INDICATOR_LABELS = {
lowSpeedContact: '저속',
differentVesselTypes: '이종',
differentNationalities: '외국적',
nightTimeContact: '야간',
};
/**
* indicator 뱃지에 맥락 정보를 포함한 텍스트 생성
* : "저속 1.2/0.8kn", "이종 어선↔화물선"
*/
export function getIndicatorDetail(key, contact) {
const { vessel1, vessel2 } = contact;
switch (key) {
case 'lowSpeedContact': {
const s1 = vessel1.estimatedAvgSpeedKnots;
const s2 = vessel2.estimatedAvgSpeedKnots;
if (s1 != null && s2 != null) {
return `저속 ${s1.toFixed(1)}/${s2.toFixed(1)}kn`;
}
return '저속';
}
case 'differentVesselTypes': {
const name1 = getShipKindName(vessel1.shipKindCode);
const name2 = getShipKindName(vessel2.shipKindCode);
return `이종 ${name1}\u2194${name2}`;
}
case 'differentNationalities': {
const n1 = vessel1.nationalCode || '?';
const n2 = vessel2.nationalCode || '?';
return `외국적 ${n1}\u2194${n2}`;
}
case 'nightTimeContact':
return '야간';
default:
return INDICATOR_LABELS[key] || key;
}
}
/**
* 거리 포맷 (미터 읽기 좋은 형태)
*/
export function formatDistance(meters) {
if (meters == null) return '-';
if (meters >= 1000) return `${(meters / 1000).toFixed(1)}km`;
return `${Math.round(meters)}m`;
}
/**
* 시간 포맷 ( 시분)
*/
export function formatDuration(minutes) {
if (minutes == null) return '-';
if (minutes < 60) return `${minutes}`;
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return m > 0 ? `${h}시간 ${m}` : `${h}시간`;
}
// ========== Indicator 위험도 색상 ==========
/**
* contact의 indicators 활성 개수에 따라 위험도 색상 반환
* 3+: 빨강, 2: 주황, 1: 노랑, 0: 회색
*/
export function getContactRiskColor(indicators) {
if (!indicators) return [150, 150, 150, 200];
const count = Object.values(indicators).filter(Boolean).length;
if (count >= 3) return [231, 76, 60, 220];
if (count === 2) return [243, 156, 18, 220];
if (count === 1) return [241, 196, 15, 200];
return [150, 150, 150, 200];
}

파일 보기

@ -30,14 +30,25 @@ function escapeCsvField(value) {
}
/**
* 검색 결과를 CSV로 내보내기
* 검색 결과를 CSV로 내보내기 (다중 방문 동적 컬럼 지원)
*
* @param {Array} tracks ProcessedTrack 배열
* @param {Object} hitDetails { vesselId: [{ polygonId, entryTimestamp, exitTimestamp }] }
* @param {Object} hitDetails { vesselId: [{ polygonId, visitIndex, entryTimestamp, exitTimestamp }] }
* @param {Array} zones 구역 배열
*/
export function exportSearchResultToCSV(tracks, hitDetails, zones) {
const zoneNames = zones.map((z) => z.name);
// 구역별 최대 방문 횟수 계산
const maxVisitsPerZone = {};
zones.forEach((z) => { maxVisitsPerZone[z.id] = 1; });
Object.values(hitDetails).forEach((hits) => {
const countByZone = {};
(Array.isArray(hits) ? hits : []).forEach((h) => {
countByZone[h.polygonId] = (countByZone[h.polygonId] || 0) + 1;
});
for (const [zoneId, count] of Object.entries(countByZone)) {
maxVisitsPerZone[zoneId] = Math.max(maxVisitsPerZone[zoneId] || 0, count);
}
});
// 헤더 구성
const baseHeaders = [
@ -46,11 +57,21 @@ export function exportSearchResultToCSV(tracks, hitDetails, zones) {
];
const zoneHeaders = [];
zoneNames.forEach((name) => {
zones.forEach((zone) => {
const max = maxVisitsPerZone[zone.id] || 1;
if (max === 1) {
zoneHeaders.push(
`구역${name}_진입시각`, `구역${name}_진입위치`,
`구역${name}_진출시각`, `구역${name}_진출위치`,
`구역${zone.name}_진입시각`, `구역${zone.name}_진입위치`,
`구역${zone.name}_진출시각`, `구역${zone.name}_진출위치`,
);
} else {
for (let v = 1; v <= max; v++) {
zoneHeaders.push(
`구역${zone.name}_${v}차_진입시각`, `구역${zone.name}_${v}차_진입위치`,
`구역${zone.name}_${v}차_진출시각`, `구역${zone.name}_${v}차_진출위치`,
);
}
}
});
const headers = [...baseHeaders, ...zoneHeaders];
@ -72,7 +93,13 @@ export function exportSearchResultToCSV(tracks, hitDetails, zones) {
const hits = hitDetails[track.vesselId] || [];
const zoneData = [];
zones.forEach((zone) => {
const hit = hits.find((h) => h.polygonId === zone.id);
const max = maxVisitsPerZone[zone.id] || 1;
const zoneHits = hits
.filter((h) => h.polygonId === zone.id)
.sort((a, b) => (a.visitIndex || 1) - (b.visitIndex || 1));
for (let v = 0; v < max; v++) {
const hit = zoneHits[v];
if (hit) {
zoneData.push(
formatTimestamp(hit.entryTimestamp),
@ -83,6 +110,7 @@ export function exportSearchResultToCSV(tracks, hitDetails, zones) {
} else {
zoneData.push('', '', '', '');
}
}
});
return [...baseRow, ...zoneData];

파일 보기

@ -0,0 +1,19 @@
/**
* STS 분석 레이어 전역 레지스트리
* 참조: src/areaSearch/utils/areaSearchLayerRegistry.js
*
* useStsLayer 훅이 레이어를 등록하면
* useShipLayer의 handleBatchRender에서 가져와 deck.gl에 병합
*/
export function registerStsLayers(layers) {
window.__stsLayers__ = layers;
}
export function getStsLayers() {
return window.__stsLayers__ || [];
}
export function unregisterStsLayers() {
window.__stsLayers__ = [];
}

파일 보기

@ -0,0 +1,12 @@
/**
* 구역 VectorSource/VectorLayer 모듈 스코프 참조
* useZoneDraw와 useZoneEdit 공유
*/
let _source = null;
let _layer = null;
export function setZoneSource(source) { _source = source; }
export function getZoneSource() { return _source; }
export function setZoneLayer(layer) { _layer = layer; }
export function getZoneLayer() { return _layer; }

파일 보기

@ -0,0 +1,48 @@
import { useState } from 'react';
import { createPortal } from 'react-dom';
import './AlertModal.scss';
let showAlertFn = null;
export function showAlert(message, errorCode) {
if (showAlertFn) {
showAlertFn(message, errorCode);
}
}
export function AlertModalContainer() {
const [alert, setAlert] = useState(null);
useState(() => {
showAlertFn = (message, errorCode) => {
setAlert({ message, errorCode });
};
return () => {
showAlertFn = null;
};
});
const handleClose = () => {
setAlert(null);
};
if (!alert) return null;
return createPortal(
<div className="alert-modal-overlay" onClick={handleClose}>
<div className="alert-modal" onClick={(e) => e.stopPropagation()}>
<div className="alert-modal__message">{alert.message}</div>
{alert.errorCode && (
<div className="alert-modal__error-code">오류 코드: {alert.errorCode}</div>
)}
<div className="alert-modal__footer">
<button className="alert-modal__confirm" onClick={handleClose}>
확인
</button>
</div>
</div>
</div>,
document.body
);
}

파일 보기

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

파일 보기

@ -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(
<div className="loading-overlay">
<div className="loading-content">
<div className="spinner" />
<p className="loading-text">{message}</p>
</div>
</div>,
document.body,
);
}

파일 보기

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

파일 보기

@ -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 (
<header id="header">
<div className="logoArea">
@ -16,10 +34,10 @@ export default function Header() {
<aside>
<ul>
<li>
<Link to="/main" className="alram" title="알람">
<a href="#" className="alram" title="알람" onClick={handleAlarmClick}>
<i className="badge"></i>
<span className="blind">알람</span>
</Link>
</a>
</li>
{/*<li className="setWrap">*/}
{/* <Link to="/signal" className="set" title="설정">*/}

파일 보기

@ -18,6 +18,7 @@ import { useMapStore } from '../stores/mapStore';
import { getTrackQueryLayers } from '../tracking/utils/trackQueryLayerUtils';
import { getReplayLayers } from '../replay/utils/replayLayerRegistry';
import { getAreaSearchLayers } from '../areaSearch/utils/areaSearchLayerRegistry';
import { getStsLayers } from '../areaSearch/utils/stsLayerRegistry';
import { shipBatchRenderer } from '../map/ShipBatchRenderer';
/**
@ -60,7 +61,7 @@ export default function useShipLayer(map) {
controller: false,
layers: [],
useDevicePixels: true,
pickingRadius: 12,
pickingRadius: 20,
onError: (error) => {
console.error('[Deck.gl] Error:', error);
},
@ -143,9 +144,12 @@ export default function useShipLayer(map) {
// 항적분석 레이어 (전역 레지스트리)
const areaSearchLayers = getAreaSearchLayers();
// 병합: 선박 + 항적 + 리플레이 + 항적분석 레이어
// STS 분석 레이어 (전역 레지스트리)
const stsLayers = getStsLayers();
// 병합: 선박 + 항적 + 리플레이 + 항적분석 + STS 레이어
deckRef.current.setProps({
layers: [...shipLayers, ...trackLayers, ...replayLayers, ...areaSearchLayers],
layers: [...shipLayers, ...trackLayers, ...replayLayers, ...areaSearchLayers, ...stsLayers],
});
}, [map, getSelectedShips]);

파일 보기

@ -31,11 +31,15 @@ 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 useStsLayer from '../areaSearch/hooks/useStsLayer';
import useZoneDraw from '../areaSearch/hooks/useZoneDraw';
import useZoneEdit from '../areaSearch/hooks/useZoneEdit';
import { useAreaSearchStore } from '../areaSearch/stores/areaSearchStore';
import { useStsStore } from '../areaSearch/stores/stsStore';
import { useAreaSearchAnimationStore } from '../areaSearch/stores/areaSearchAnimationStore';
import { unregisterAreaSearchLayers } from '../areaSearch/utils/areaSearchLayerRegistry';
import { AREA_SEARCH_LAYER_IDS } from '../areaSearch/types/areaSearch.types';
import { STS_LAYER_IDS } from '../areaSearch/types/sts.types';
import AreaSearchTimeline from '../areaSearch/components/AreaSearchTimeline';
import AreaSearchTooltip from '../areaSearch/components/AreaSearchTooltip';
import useMeasure from './measure/useMeasure';
@ -73,11 +77,15 @@ export default function MapContainer() {
//
useReplayLayer();
// +
// + STS + +
useAreaSearchLayer();
useStsLayer();
useZoneDraw();
useZoneEdit();
const areaSearchCompleted = useAreaSearchStore((s) => s.queryCompleted);
const stsCompleted = useStsStore((s) => s.queryCompleted);
const analysisCompleted = areaSearchCompleted || stsCompleted;
//
useMeasure();
@ -150,6 +158,7 @@ export default function MapContainer() {
useReplayStore.getState().setHighlightedVesselId(null);
useAreaSearchStore.getState().setAreaSearchTooltip(null);
useAreaSearchStore.getState().setHighlightedVesselId(null);
useStsStore.getState().setHighlightedGroupIndex(null);
return;
}
@ -171,6 +180,7 @@ export default function MapContainer() {
useReplayStore.getState().setHighlightedVesselId(null);
useAreaSearchStore.getState().setAreaSearchTooltip(null);
useAreaSearchStore.getState().setHighlightedVesselId(null);
useStsStore.getState().setHighlightedGroupIndex(null);
return;
}
@ -184,6 +194,13 @@ export default function MapContainer() {
useAreaSearchStore.getState().setHighlightedVesselId(null);
}
// STS STS
if (layerId !== STS_LAYER_IDS.TRACK_PATH &&
layerId !== STS_LAYER_IDS.VIRTUAL_SHIP &&
layerId !== STS_LAYER_IDS.CONTACT_POINT) {
useStsStore.getState().setHighlightedGroupIndex(null);
}
//
if (layerId === 'ship-icon-layer') {
useShipStore.getState().setHoverInfo({ ship: obj, x: clientX, y: clientY });
@ -264,6 +281,29 @@ export default function MapContainer() {
return;
}
// STS / /
if (layerId === STS_LAYER_IDS.TRACK_PATH ||
layerId === STS_LAYER_IDS.VIRTUAL_SHIP ||
layerId === STS_LAYER_IDS.CONTACT_POINT) {
useShipStore.getState().setHoverInfo(null);
useTrackQueryStore.getState().setHighlightedVesselId(null);
useTrackQueryStore.getState().clearHoveredPoint();
useReplayStore.getState().setHighlightedVesselId(null);
// vesselId ( )
const vesselId = obj?.vesselId;
if (vesselId) {
const groups = useStsStore.getState().groupedContacts;
const groupIdx = groups.findIndex(
(g) => g.vessel1.vesselId === vesselId || g.vessel2.vesselId === vesselId,
);
useStsStore.getState().setHighlightedGroupIndex(groupIdx >= 0 ? groupIdx : null);
} else {
useStsStore.getState().setHighlightedGroupIndex(null);
}
return;
}
// (PathLayer)
if (layerId === 'track-path-layer') {
useShipStore.getState().setHoverInfo(null);
@ -294,6 +334,7 @@ export default function MapContainer() {
useTrackQueryStore.getState().setHighlightedVesselId(null);
useTrackQueryStore.getState().clearHoveredPoint();
useReplayStore.getState().setHighlightedVesselId(null);
useStsStore.getState().setHighlightedGroupIndex(null);
}, HOVER_THROTTLE_MS);
}, [pickAny]);
@ -320,6 +361,7 @@ export default function MapContainer() {
useReplayStore.getState().setHighlightedVesselId(null);
useAreaSearchStore.getState().setAreaSearchTooltip(null);
useAreaSearchStore.getState().setHighlightedVesselId(null);
useStsStore.getState().setHighlightedGroupIndex(null);
}, []);
/**
@ -509,7 +551,7 @@ export default function MapContainer() {
<GlobalTrackQueryViewer />
<ReplayLoadingOverlay />
{areaSearchCompleted && <AreaSearchTooltip />}
{areaSearchCompleted && <AreaSearchTimeline />}
{analysisCompleted && <AreaSearchTimeline />}
{replayCompleted && (
<ReplayTimeline
fromDate={replayQuery?.startTime}

파일 보기

@ -37,16 +37,26 @@ const MAX_POINTS_PER_TRACK = 800;
* @param {Object} params
* @param {Array} params.tracks - 항적 데이터 배열
* @param {boolean} params.showPoints - 포인트 표시 여부
* @param {string} [params.highlightedVesselId] - 하이라이트할 선박 ID
* @param {string} [params.highlightedVesselId] - 하이라이트할 선박 ID (단일)
* @param {Set<string>} [params.highlightedVesselIds] - 하이라이트할 선박 ID 집합 (복수)
* @param {Function} [params.onPathHover] - 항적 호버 콜백
*/
export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselId, onPathHover, layerIds }) {
export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselId, highlightedVesselIds, onPathHover, layerIds }) {
const layers = [];
if (!tracks || tracks.length === 0) return layers;
const pathId = layerIds?.path || 'track-path-layer';
const pointId = layerIds?.point || 'track-point-layer';
const isHighlighted = (vesselId) =>
highlightedVesselIds?.has(vesselId) ||
(highlightedVesselId && highlightedVesselId === vesselId);
// Set을 직렬화하여 Deck.gl updateTriggers가 변경을 감지하도록
const highlightKey = highlightedVesselIds
? [...highlightedVesselIds].sort().join(',')
: null;
// 1. PathLayer - 전체 경로 (시간 무관)
const pathData = tracks.map((track) => ({
path: track.geometry,
@ -59,23 +69,11 @@ export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselI
id: pathId,
data: pathData,
getPath: (d) => d.path,
getColor: (d) => {
// 하이라이트된 선박이면 노란색
if (highlightedVesselId && highlightedVesselId === d.vesselId) {
return [255, 255, 0, 255];
}
return d.color;
},
getWidth: (d) => {
// 하이라이트된 선박이면 더 두껍게
if (highlightedVesselId && highlightedVesselId === d.vesselId) {
return 4;
}
return 2;
},
getColor: (d) => isHighlighted(d.vesselId) ? [255, 255, 0, 255] : d.color,
getWidth: (d) => isHighlighted(d.vesselId) ? 4 : 2,
widthUnits: 'pixels',
widthMinPixels: 4,
widthMaxPixels: 8,
widthMinPixels: 1,
widthMaxPixels: 6,
jointRounded: true,
capRounded: true,
pickable: true,
@ -87,8 +85,8 @@ export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselI
}
},
updateTriggers: {
getColor: [highlightedVesselId],
getWidth: [highlightedVesselId],
getColor: [highlightedVesselId, highlightKey],
getWidth: [highlightedVesselId, highlightKey],
},
})
);

파일 보기

@ -1,23 +1,11 @@
/**
* 리플레이 Deck.gl 레이어 관리
* 참조: mda-react-front/src/tracking/components/ReplayV2.tsx
*
* 구조:
* 1. queryCompleted 정적 항적 레이어 생성
* 2. animationStore.currentTime 변경 requestAnimatedRender()
* 3. animationStore.getCurrentVesselPositions() 현재 위치 계산
* 4. deck.gl 레이어 생성 전역 레지스트리 등록 shipBatchRenderer.immediateRender()
*
* 궤적 표시:
* - TripsLayer (@deck.gl/geo-layers): GPU 기반 페이딩 궤적
* - 기존 경로 데이터(mergedTrackStore) 직접 사용, 프레임 기록 불필요
*
* 필터링:
* - filterModules.custom: 선박 아이콘 표시
* - filterModules.path: 항적 라인 표시
* - filterModules.label: 선명 라벨 표시
* - shipKindCodeFilter: 선종 필터
* - vesselStates: 선박 상태 (NORMAL/SELECTED/DELETED)
* 성능 최적화:
* - currentTime은 zustand.subscribe로 React 렌더 바이패스
* - 정적 레이어(PathLayer) 캐싱 필터 변경 시에만 재생성
* - 동적 레이어(IconLayer, TextLayer, TripsLayer) 렌더 갱신
* - 재생 ~10fps 쓰로틀, seek 즉시 렌더
*/
import { useEffect, useRef, useCallback } from 'react';
import { TripsLayer } from '@deck.gl/geo-layers';
@ -33,34 +21,16 @@ import { shipBatchRenderer } from '../../map/ShipBatchRenderer';
import { hideLiveShips, showLiveShips } from '../../utils/liveControl';
import { SIGNAL_KIND_CODE_NORMAL } from '../../types/constants';
/**
* TripsLayer 궤적 길이 (밀리초 단위, startTime 기준 상대시간)
* 1시간 = 3,600,000ms
* 배속과 무관하게 동일한 시각적 궤적 길이 보장 (시간 기반)
*/
const TRAIL_LENGTH_MS = 3600000;
const RENDER_INTERVAL_MS = 100; // 재생 중 렌더링 쓰로틀 (~10fps)
/**
* 좌표 이상치(GPS 점프) 판별 삭제 그룹 분류용
*
* 조건 1: 인접 포인트 거리 > 100km가 (조회일수 × 5) 이상
* - 1 조회 5, 3 조회 15
* 조건 2: 해당 선박의 평균 속도 > 100노트
* - 평균 속도 = 전체 경로 길이 / (항적 종료시간 - 시작시간)
*
* 하나라도 해당하면 이상치 선박으로 판정
* 포인트 제거 없이 그대로 렌더링하되, 그룹 분류만 '삭제' 설정
*
* @param {Array} geometry - [[lon, lat], ...] 좌표 배열
* @param {Array} timestampsMs - 밀리초 타임스탬프 배열
* @param {number} queryDays - 조회 기간 ()
* @returns {boolean} true면 이상치 선박 (삭제 그룹 대상)
*/
const MAX_DIST_DEG = 1.0; // ~100~110km (맨해튼 거리, 도 단위)
const OUTLIER_PER_DAY = 5; // 1일당 허용 이상치 건수
const MAX_AVG_SPEED_KNOTS = 50; // 평균 속도 임계값 (노트) — 상선 최고속도 ~25노트의 2배
const DEG_TO_NM = 60; // 1도 ≈ 60해리 (위도 기준 근사)
const MS_TO_HOURS = 1 / 3600000; // 밀리초 → 시간
// ========== 이상치 판별 유틸 ==========
const MAX_DIST_DEG = 1.0;
const OUTLIER_PER_DAY = 5;
const MAX_AVG_SPEED_KNOTS = 50;
const DEG_TO_NM = 60;
const MS_TO_HOURS = 1 / 3600000;
function isOutlierVessel(geometry, timestampsMs, queryDays) {
if (!geometry || geometry.length < 2) return false;
@ -79,12 +49,11 @@ function isOutlierVessel(geometry, timestampsMs, queryDays) {
if (dist > MAX_DIST_DEG) {
outlierCount++;
if (outlierCount >= outlierThreshold) {
return true; // 조건 1 충족 — 조기 종료
return true;
}
}
}
// 조건 2: 해당 선박의 평균 속도 > 100노트
const startTime = timestampsMs[0];
const endTime = timestampsMs[timestampsMs.length - 1];
const durationHours = (endTime - startTime) * MS_TO_HOURS;
@ -94,16 +63,13 @@ function isOutlierVessel(geometry, timestampsMs, queryDays) {
const avgSpeedKnots = totalDistNm / durationHours;
if (avgSpeedKnots > MAX_AVG_SPEED_KNOTS) {
return true; // 조건 2 충족
return true;
}
}
return false;
}
/**
* 선박 상태에 따라 표시 여부 결정
*/
function shouldShowVessel(vesselId, filterModule, vesselStates, deletedVesselIds, selectedVesselIds) {
let state = VesselState.NORMAL;
if (vesselStates.has(vesselId)) {
@ -126,46 +92,32 @@ function shouldShowVessel(vesselId, filterModule, vesselStates, deletedVesselIds
}
}
/**
* 리플레이 레이어
*/
// ========== 메인 훅 ==========
export default function useReplayLayer() {
const staticLayersRef = useRef([]);
const tracksRef = useRef([]);
// TripsLayer용: startTime 기준 상대 타임스탬프 (32비트 float 안전)
const tripsDataRef = useRef([]);
const startTimeRef = useRef(0);
// 정적 레이어 캐시
const staticLayerCacheRef = useRef({ layers: [], deps: null });
// React 구독: 필터/상태 (비빈번 변경)
const queryCompleted = useReplayStore((s) => s.queryCompleted);
// animationStore에서 currentTime, playbackSpeed 구독
const currentTime = useAnimationStore((s) => s.currentTime);
const playbackSpeed = useAnimationStore((s) => s.playbackSpeed);
// 필터 상태 구독
const filterModules = useReplayStore((s) => s.filterModules);
const shipKindCodeFilter = useReplayStore((s) => s.shipKindCodeFilter);
const vesselStates = useReplayStore((s) => s.vesselStates);
const deletedVesselIds = useReplayStore((s) => s.deletedVesselIds);
const selectedVesselIds = useReplayStore((s) => s.selectedVesselIds);
// 하이라이트 상태 구독
const highlightedVesselId = useReplayStore((s) => s.highlightedVesselId);
const setHighlightedVesselId = useReplayStore((s) => s.setHighlightedVesselId);
// 항적표시 토글 구독
const isTrailEnabled = usePlaybackTrailStore((s) => s.isEnabled);
// currentTime — React 구독 제거, zustand.subscribe로 대체
/**
* 항적/아이콘 호버 하이라이트 설정
*/
const handlePathHover = useCallback((vesselId) => {
setHighlightedVesselId(vesselId);
}, [setHighlightedVesselId]);
/**
* 가상 선박 아이콘 호버 툴팁 표시
*/
const handleIconHover = useCallback((shipData, x, y) => {
if (shipData) {
useShipStore.getState().setHoverInfo({
@ -187,60 +139,9 @@ export default function useReplayLayer() {
}, [setHighlightedVesselId]);
/**
* 트랙 필터링
* 프레임 렌더링 (zustand.subscribe에서 직접 호출, React 리렌더 없음)
*/
const filterTracks = useCallback((tracks) => {
const replayState = useReplayStore.getState();
const { filterModules, shipKindCodeFilter, vesselStates, deletedVesselIds, selectedVesselIds } = replayState;
const pathFilter = filterModules[FilterModuleType.PATH];
return tracks.filter((track) => {
if (!shipKindCodeFilter.has(track.shipKindCode)) {
return false;
}
return shouldShowVessel(
track.vesselId,
pathFilter,
vesselStates,
deletedVesselIds,
selectedVesselIds
);
});
}, []);
/**
* 현재 위치 필터링
*/
const filterPositions = useCallback((positions) => {
const replayState = useReplayStore.getState();
const { filterModules, shipKindCodeFilter, vesselStates, deletedVesselIds, selectedVesselIds } = replayState;
const customFilter = filterModules[FilterModuleType.CUSTOM];
const labelFilter = filterModules[FilterModuleType.LABEL];
const iconPositions = [];
const labelPositions = [];
positions.forEach((pos) => {
if (!shipKindCodeFilter.has(pos.shipKindCode)) {
return;
}
if (shouldShowVessel(pos.vesselId, customFilter, vesselStates, deletedVesselIds, selectedVesselIds)) {
iconPositions.push(pos);
}
if (shouldShowVessel(pos.vesselId, labelFilter, vesselStates, deletedVesselIds, selectedVesselIds)) {
labelPositions.push(pos);
}
});
return { iconPositions, labelPositions };
}, []);
/**
* 애니메이션 렌더링 요청
*/
const requestAnimatedRender = useCallback(() => {
const renderFrame = useCallback(() => {
const currentPositions = useAnimationStore.getState().getCurrentVesselPositions();
if (currentPositions.length === 0 && tracksRef.current.length === 0) {
@ -258,9 +159,25 @@ export default function useReplayLayer() {
}));
// 위치 필터링
const { iconPositions, labelPositions } = filterPositions(formattedPositions);
const pathFilter = filterModules[FilterModuleType.PATH];
const customFilter = filterModules[FilterModuleType.CUSTOM];
const labelFilter = filterModules[FilterModuleType.LABEL];
// 선종별 카운트 계산 → replayStore에 저장
const iconPositions = [];
const labelPositions = [];
formattedPositions.forEach((pos) => {
if (!shipKindCodeFilter.has(pos.shipKindCode)) return;
if (shouldShowVessel(pos.vesselId, customFilter, vesselStates, deletedVesselIds, selectedVesselIds)) {
iconPositions.push(pos);
}
if (shouldShowVessel(pos.vesselId, labelFilter, vesselStates, deletedVesselIds, selectedVesselIds)) {
labelPositions.push(pos);
}
});
// 선종별 카운트
const counts = {};
iconPositions.forEach((pos) => {
const code = pos.shipKindCode || SIGNAL_KIND_CODE_NORMAL;
@ -268,25 +185,20 @@ export default function useReplayLayer() {
});
useReplayStore.getState().setReplayShipCounts(counts);
// 트랙 필터링
const filteredTracks = filterTracks(tracksRef.current);
const layers = [];
// ===== TripsLayer 궤적 표시 =====
// 1. TripsLayer 궤적
if (isTrailEnabled && tripsDataRef.current.length > 0) {
// 아이콘(CUSTOM 필터)에 표시되는 선박만 궤적 표시
// (선종 필터 + 선박 상태 필터가 이미 iconPositions에 적용됨)
const iconVesselIds = new Set(iconPositions.map((p) => p.vesselId));
const filteredTripsData = tripsDataRef.current.filter(
(d) => iconVesselIds.has(d.vesselId)
(d) => iconVesselIds.has(d.vesselId),
);
if (filteredTripsData.length > 0) {
const relativeCurrentTime = useAnimationStore.getState().currentTime - startTimeRef.current;
const tripsLayer = new TripsLayer({
layers.push(
new TripsLayer({
id: 'replay-trips-trail',
data: filteredTripsData,
getPath: (d) => d.path,
@ -299,23 +211,50 @@ export default function useReplayLayer() {
fadeTrail: true,
trailLength: TRAIL_LENGTH_MS,
currentTime: relativeCurrentTime,
});
layers.push(tripsLayer);
}),
);
}
}
// 현재 하이라이트 상태
// 2. 정적 PathLayer (캐싱)
const currentHighlightedVesselId = useReplayStore.getState().highlightedVesselId;
const cacheDeps = staticLayerCacheRef.current.deps;
const needsRebuild = !cacheDeps
|| cacheDeps.tracks !== tracksRef.current
|| cacheDeps.shipKindCodeFilter !== shipKindCodeFilter
|| cacheDeps.vesselStates !== vesselStates
|| cacheDeps.deletedVesselIds !== deletedVesselIds
|| cacheDeps.selectedVesselIds !== selectedVesselIds
|| cacheDeps.filterModules !== filterModules
|| cacheDeps.highlightedVesselId !== currentHighlightedVesselId;
// 정적 레이어 재생성 (필터 적용 + 하이라이트)
const staticLayers = createStaticTrackLayers({
if (needsRebuild) {
const filteredTracks = tracksRef.current.filter((track) => {
if (!shipKindCodeFilter.has(track.shipKindCode)) return false;
return shouldShowVessel(track.vesselId, pathFilter, vesselStates, deletedVesselIds, selectedVesselIds);
});
staticLayerCacheRef.current = {
layers: createStaticTrackLayers({
tracks: filteredTracks,
showPoints: false,
highlightedVesselId: currentHighlightedVesselId,
onPathHover: handlePathHover,
});
}),
deps: {
tracks: tracksRef.current,
shipKindCodeFilter,
vesselStates,
deletedVesselIds,
selectedVesselIds,
filterModules,
highlightedVesselId: currentHighlightedVesselId,
},
};
}
layers.push(...staticLayerCacheRef.current.layers);
// 동적 레이어 생성 (아이콘/라벨 분리 + 호버 콜백)
// 3. 동적 가상 선박 레이어
const dynamicLayers = createVirtualShipLayers({
currentPositions: iconPositions,
showVirtualShip: iconPositions.length > 0,
@ -324,25 +263,24 @@ export default function useReplayLayer() {
onPathHover: handlePathHover,
});
// 라벨 레이어 별도 생성
// 4. 라벨 레이어 별도
const labelLayers = createVirtualShipLayers({
currentPositions: labelPositions,
showVirtualShip: false,
showLabels: labelPositions.length > 0,
});
registerReplayLayers([...layers, ...staticLayers, ...dynamicLayers, ...labelLayers]);
registerReplayLayers([...layers, ...dynamicLayers, ...labelLayers]);
shipBatchRenderer.immediateRender();
}, [filterTracks, filterPositions, handlePathHover, handleIconHover, isTrailEnabled]);
}, [filterModules, shipKindCodeFilter, vesselStates, deletedVesselIds, selectedVesselIds, highlightedVesselId, isTrailEnabled, handlePathHover, handleIconHover]);
/**
* tracks + tripsData 빌드 함수 (queryCompleted 또는 청크 추가 호출)
* tracks + tripsData 빌드 (queryCompleted 또는 청크 추가 )
*/
const rebuildTracksAndTripsData = useCallback(() => {
const chunks = useMergedTrackStore.getState().vesselChunks;
if (chunks.size === 0) return;
// vesselChunks → tracks 배열 변환
const tracks = [];
chunks.forEach((vc, vesselId) => {
const path = useMergedTrackStore.getState().getMergedPath(vesselId);
@ -362,25 +300,19 @@ export default function useReplayLayer() {
tracksRef.current = tracks;
// 시간 범위 업데이트 (animationStore)
useAnimationStore.getState().updateTimeRange();
// TripsLayer 데이터 준비: startTime 기준 상대 타임스탬프 (32비트 float 안전)
const { startTime, endTime } = useAnimationStore.getState();
startTimeRef.current = startTime;
// 조회 기간 (일) 계산 — 조건 1의 동적 임계값에 사용
const queryDays = Math.max(1, Math.ceil((endTime - startTime) / (24 * 60 * 60 * 1000)));
// TripsLayer 데이터 생성 — 포인트 제거 없이 전체 경로 사용
// vesselId당 1개 항목
const tripsData = [];
const outlierVesselIds = []; // 이상치 선박 (삭제 그룹 대상)
const outlierVesselIds = [];
tracks.forEach((track) => {
if (track.geometry.length < 2) return;
// 이상치 판별
if (isOutlierVessel(track.geometry, track.timestampsMs, queryDays)) {
outlierVesselIds.push(track.vesselId);
}
@ -394,7 +326,6 @@ export default function useReplayLayer() {
});
tripsDataRef.current = tripsData;
// 이상치 선박을 '삭제' 그룹으로 자동 설정
if (outlierVesselIds.length > 0) {
const { setVesselState } = useReplayStore.getState();
outlierVesselIds.forEach((vesselId) => {
@ -404,64 +335,69 @@ export default function useReplayLayer() {
}, []);
/**
* 쿼리 완료 라이브 선박 숨기기 + 데이터 빌드
* 쿼리 완료 데이터 빌드 + 자동 재생
* renderFrame 제외 rebuildTracksAndTripsData가 vesselStates를 변경하므로
* renderFrame이 deps에 있으면 무한 루프 발생
*/
useEffect(() => {
if (!queryCompleted) {
unregisterReplayLayers();
staticLayersRef.current = [];
tracksRef.current = [];
tripsDataRef.current = [];
startTimeRef.current = 0;
staticLayerCacheRef.current = { layers: [], deps: null };
showLiveShips();
shipBatchRenderer.immediateRender();
return;
}
// 리플레이 모드 시작 → 라이브 선박 숨기기
hideLiveShips();
rebuildTracksAndTripsData();
requestAnimatedRender();
// 자동 재생 시작
useAnimationStore.getState().play();
}, [queryCompleted, requestAnimatedRender, rebuildTracksAndTripsData]);
}, [queryCompleted, rebuildTracksAndTripsData]);
/**
* currentTime 변경 애니메이션 렌더링
* currentTime 구독 (zustand.subscribe React 리렌더 바이패스)
* 재생 : ~10fps 쓰로틀, seek/정지: 즉시 렌더
* renderFrame이 deps 필터 변경 자동 재구독 + 즉시 렌더
*/
useEffect(() => {
if (!queryCompleted) return;
requestAnimatedRender();
}, [currentTime, queryCompleted, requestAnimatedRender]);
renderFrame();
let lastRenderTime = 0;
let pendingRafId = null;
const unsub = useAnimationStore.subscribe(
(s) => s.currentTime,
() => {
const isPlaying = useAnimationStore.getState().isPlaying;
if (!isPlaying) {
renderFrame();
return;
}
const now = performance.now();
if (now - lastRenderTime >= RENDER_INTERVAL_MS) {
lastRenderTime = now;
renderFrame();
} else if (!pendingRafId) {
pendingRafId = requestAnimationFrame(() => {
pendingRafId = null;
lastRenderTime = performance.now();
renderFrame();
});
}
},
);
return () => {
unsub();
if (pendingRafId) cancelAnimationFrame(pendingRafId);
};
}, [queryCompleted, renderFrame]);
/**
* 필터 변경 재렌더링
*/
useEffect(() => {
if (!queryCompleted) return;
requestAnimatedRender();
}, [filterModules, shipKindCodeFilter, vesselStates, deletedVesselIds, selectedVesselIds, queryCompleted, requestAnimatedRender]);
/**
* 항적표시 토글 변경 재렌더링
*/
useEffect(() => {
if (!queryCompleted) return;
requestAnimatedRender();
}, [isTrailEnabled, queryCompleted, requestAnimatedRender]);
/**
* 하이라이트 변경 레이어 재렌더링
*/
useEffect(() => {
if (!queryCompleted) return;
requestAnimatedRender();
}, [highlightedVesselId, queryCompleted, requestAnimatedRender]);
/**
* 키보드 이벤트 리스너 (Delete/Insert 키로 선박 상태 변경)
* 키보드 이벤트 (Delete/Insert 키로 선박 상태 변경)
*/
useEffect(() => {
if (!queryCompleted) return;
@ -470,14 +406,14 @@ export default function useReplayLayer() {
const currentHighlightedId = useReplayStore.getState().highlightedVesselId;
if (!currentHighlightedId) return;
const { vesselStates, deletedVesselIds, selectedVesselIds, setVesselState } = useReplayStore.getState();
const { vesselStates: vs, deletedVesselIds: dvi, selectedVesselIds: svi, setVesselState } = useReplayStore.getState();
let currentState = VesselState.NORMAL;
if (vesselStates.has(currentHighlightedId)) {
currentState = vesselStates.get(currentHighlightedId);
} else if (deletedVesselIds.has(currentHighlightedId)) {
if (vs.has(currentHighlightedId)) {
currentState = vs.get(currentHighlightedId);
} else if (dvi.has(currentHighlightedId)) {
currentState = VesselState.DELETED;
} else if (selectedVesselIds.has(currentHighlightedId)) {
} else if (svi.has(currentHighlightedId)) {
currentState = VesselState.SELECTED;
}
@ -502,7 +438,7 @@ export default function useReplayLayer() {
if (targetState !== null) {
setVesselState(currentHighlightedId, targetState);
useReplayStore.getState().setHighlightedVesselId(null);
requestAnimatedRender();
renderFrame();
}
};
@ -510,15 +446,14 @@ export default function useReplayLayer() {
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [queryCompleted, requestAnimatedRender]);
}, [queryCompleted, renderFrame]);
/**
* 컴포넌트 언마운트 클린업
* 언마운트 클린업
*/
useEffect(() => {
return () => {
unregisterReplayLayers();
staticLayersRef.current = [];
tracksRef.current = [];
tripsDataRef.current = [];
showLiveShips();

파일 보기

@ -73,6 +73,9 @@ function getTimeRangeFromVessels() {
return { start: minTime, end: maxTime };
}
// 커서 기반 선형 탐색용 (vesselId → lastIndex)
const positionCursors = new Map();
/**
* 애니메이션 스토어
*/
@ -192,6 +195,7 @@ const useAnimationStore = create(subscribeWithSelector((set, get) => ({
const timeRange = getTimeRangeFromVessels();
const startTime = timeRange?.start || 0;
positionCursors.clear();
set({
isPlaying: false,
playbackState: PlaybackState.STOPPED,
@ -278,8 +282,9 @@ const useAnimationStore = create(subscribeWithSelector((set, get) => ({
},
/**
* 현재 시간의 선박 위치 계산 (핵심 메서드)
* 참조: mda-react-front/src/tracking/stores/animationStore.ts - getCurrentVesselPositions
* 현재 시간의 선박 위치 계산 (커서 기반 선형 탐색 + 보간)
* 재생 : 커서에서 선형 전진 O(1~2)
* seek/역방향: 이진 탐색 fallback O(log n)
*/
getCurrentVesselPositions: () => {
const { currentTime } = get();
@ -296,26 +301,36 @@ const useAnimationStore = create(subscribeWithSelector((set, get) => ({
const firstTime = timestampsMs[0];
const lastTime = timestampsMs[timestampsMs.length - 1];
// 시간 범위 체크
if (currentTime < firstTime || currentTime > lastTime) {
return;
}
// 이진 탐색으로 현재 시간에 해당하는 인덱스 찾기
// 커서 기반 탐색
let cursor = positionCursors.get(vesselId);
if (cursor === undefined
|| cursor >= timestampsMs.length
|| (cursor > 0 && timestampsMs[cursor - 1] > currentTime)) {
// 커서 없음 or 무효 or 시간 역행 → 이진 탐색 fallback
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;
const mid = (left + right) >> 1;
if (timestampsMs[mid] < currentTime) left = mid + 1;
else right = mid;
}
cursor = left;
} else {
right = mid;
// 선형 전진 (재생 중 1~2칸)
while (cursor < timestampsMs.length - 1 && timestampsMs[cursor] < currentTime) {
cursor++;
}
}
const idx1 = Math.max(0, left - 1);
const idx2 = Math.min(timestampsMs.length - 1, left);
positionCursors.set(vesselId, cursor);
const idx1 = Math.max(0, cursor - 1);
const idx2 = Math.min(timestampsMs.length - 1, cursor);
let finalPosition;
let heading;
@ -344,7 +359,6 @@ const useAnimationStore = create(subscribeWithSelector((set, get) => ({
heading,
speed,
timestamp: currentTime,
// 추가 정보
shipKindCode: vessel.shipKindCode || '000027',
shipName: vessel.shipName || '',
sigSrcCd: vessel.sigSrcCd,
@ -363,6 +377,7 @@ const useAnimationStore = create(subscribeWithSelector((set, get) => ({
cancelAnimationFrame(animationFrameId);
}
positionCursors.clear();
set({
isPlaying: false,
playbackState: PlaybackState.IDLE,

파일 보기

@ -239,7 +239,7 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({
lastModalPos: null,
/** 다크시그널(소실신호) 표시 여부 */
darkSignalVisible: true,
darkSignalVisible: false,
/** 다크시그널 선박 수 */
darkSignalCount: 0,

파일 보기

@ -1337,6 +1337,11 @@ balanced-match@^1.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
base64-arraybuffer@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz#1c37589a7c4b0746e34bd1feb951da2df01c1bdc"
integrity sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==
base64-js@^1.1.2:
version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
@ -1515,6 +1520,13 @@ crypt@0.0.2:
resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b"
integrity sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==
css-line-break@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/css-line-break/-/css-line-break-2.1.0.tgz#bfef660dfa6f5397ea54116bb3cb4873edbc4fa0"
integrity sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==
dependencies:
utrie "^1.0.2"
data-view-buffer@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.2.tgz#211a03ba95ecaf7798a8c7198d79536211f88570"
@ -2211,6 +2223,14 @@ hasown@^2.0.2:
dependencies:
function-bind "^1.1.2"
html2canvas@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/html2canvas/-/html2canvas-1.4.1.tgz#7cef1888311b5011d507794a066041b14669a543"
integrity sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==
dependencies:
css-line-break "^2.1.0"
text-segmentation "^1.0.3"
http-parser-js@>=0.5.1:
version "0.5.10"
resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.10.tgz#b3277bd6d7ed5588e20ea73bf724fcbe44609075"
@ -3394,6 +3414,13 @@ supports-preserve-symlinks-flag@^1.0.0:
resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
text-segmentation@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/text-segmentation/-/text-segmentation-1.0.3.tgz#52a388159efffe746b24a63ba311b6ac9f2d7943"
integrity sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==
dependencies:
utrie "^1.0.2"
text-table@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
@ -3512,6 +3539,13 @@ util-deprecate@~1.0.1:
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
utrie@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/utrie/-/utrie-1.0.2.tgz#d42fe44de9bc0119c25de7f564a6ed1b2c87a645"
integrity sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==
dependencies:
base64-arraybuffer "^1.0.2"
vite@^5.2.10:
version "5.4.21"
resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.21.tgz#84a4f7c5d860b071676d39ba513c0d598fdc7027"