Merge branch 'feature/area-search' into develop
This commit is contained in:
커밋
34d5f6ef9e
BIN
.yarn-offline-cache/base64-arraybuffer-1.0.2.tgz
Normal file
BIN
.yarn-offline-cache/base64-arraybuffer-1.0.2.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/css-line-break-2.1.0.tgz
Normal file
BIN
.yarn-offline-cache/css-line-break-2.1.0.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/html2canvas-1.4.1.tgz
Normal file
BIN
.yarn-offline-cache/html2canvas-1.4.1.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/text-segmentation-1.0.3.tgz
Normal file
BIN
.yarn-offline-cache/text-segmentation-1.0.3.tgz
Normal file
Binary file not shown.
BIN
.yarn-offline-cache/utrie-1.0.2.tgz
Normal file
BIN
.yarn-offline-cache/utrie-1.0.2.tgz
Normal file
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();
|
||||
useAreaSearchAnimationStore.getState().reset();
|
||||
showLiveShips();
|
||||
if (isOpen) return;
|
||||
const areaState = useAreaSearchStore.getState();
|
||||
const stsState = useStsStore.getState();
|
||||
if (areaState.queryCompleted || stsState.queryCompleted) {
|
||||
areaState.clearResults();
|
||||
stsState.clearResults();
|
||||
useAreaSearchAnimationStore.getState().reset();
|
||||
unregisterAreaSearchLayers();
|
||||
unregisterStsLayers();
|
||||
showLiveShips();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// ========== 탭 전환 ==========
|
||||
|
||||
const handleTabChange = useCallback((newTab) => {
|
||||
if (newTab === activeTab) return;
|
||||
|
||||
const areaState = useAreaSearchStore.getState();
|
||||
const stsState = useStsStore.getState();
|
||||
|
||||
// 현재 탭 결과가 있으면 초기화 확인
|
||||
const hasResults = activeTab === ANALYSIS_TABS.AREA
|
||||
? areaState.queryCompleted
|
||||
: stsState.queryCompleted;
|
||||
|
||||
if (hasResults) {
|
||||
const confirmed = window.confirm('탭을 전환하면 현재 결과가 초기화됩니다.\n계속하시겠습니까?');
|
||||
if (!confirmed) return;
|
||||
|
||||
if (activeTab === ANALYSIS_TABS.AREA) {
|
||||
areaState.clearResults();
|
||||
unregisterAreaSearchLayers();
|
||||
} else {
|
||||
stsState.clearResults();
|
||||
unregisterStsLayers();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
useAreaSearchAnimationStore.getState().reset();
|
||||
showLiveShips();
|
||||
}
|
||||
|
||||
// 탭 전환 시 zones 초기화 (이전 탭 구역이 남지 않도록)
|
||||
areaState.clearZones();
|
||||
setErrorMessage('');
|
||||
areaState.setActiveTab(newTab);
|
||||
}, [activeTab]);
|
||||
|
||||
// ========== 날짜 핸들러 ==========
|
||||
|
||||
const handleStartDateChange = useCallback((newStartDate) => {
|
||||
setStartDate(newStartDate);
|
||||
@ -104,9 +141,12 @@ export default function AreaSearchPage({ isOpen, onToggle }) {
|
||||
}
|
||||
}, [startDate, startTime, endTime]);
|
||||
|
||||
const executeQuery = useCallback(async () => {
|
||||
// ========== 조회 실행 ==========
|
||||
|
||||
const executeAreaSearch = useCallback(async () => {
|
||||
const from = new Date(`${startDate}T${startTime}:00`);
|
||||
const to = new Date(`${endDate}T${endTime}:00`);
|
||||
const searchMode = useAreaSearchStore.getState().searchMode;
|
||||
|
||||
try {
|
||||
setErrorMessage('');
|
||||
@ -125,22 +165,26 @@ export default function AreaSearchPage({ isOpen, onToggle }) {
|
||||
polygons,
|
||||
});
|
||||
|
||||
if (result.tracks.length === 0) {
|
||||
useAreaSearchStore.getState().setLoading(false);
|
||||
showToast('조회 결과가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
useAreaSearchStore.getState().setTracks(result.tracks);
|
||||
useAreaSearchStore.getState().setHitDetails(result.hitDetails);
|
||||
useAreaSearchStore.getState().setSummary(result.summary);
|
||||
|
||||
if (result.tracks.length > 0) {
|
||||
let minTime = Infinity;
|
||||
let maxTime = -Infinity;
|
||||
result.tracks.forEach((t) => {
|
||||
if (t.timestampsMs.length > 0) {
|
||||
minTime = Math.min(minTime, t.timestampsMs[0]);
|
||||
maxTime = Math.max(maxTime, t.timestampsMs[t.timestampsMs.length - 1]);
|
||||
}
|
||||
});
|
||||
setTimeRange(minTime, maxTime);
|
||||
hideLiveShips();
|
||||
}
|
||||
let minTime = Infinity;
|
||||
let maxTime = -Infinity;
|
||||
result.tracks.forEach((t) => {
|
||||
if (t.timestampsMs.length > 0) {
|
||||
minTime = Math.min(minTime, t.timestampsMs[0]);
|
||||
maxTime = Math.max(maxTime, t.timestampsMs[t.timestampsMs.length - 1]);
|
||||
}
|
||||
});
|
||||
setTimeRange(minTime, maxTime);
|
||||
hideLiveShips();
|
||||
|
||||
useAreaSearchStore.getState().setLoading(false);
|
||||
} catch (error) {
|
||||
@ -148,7 +192,58 @@ export default function AreaSearchPage({ isOpen, onToggle }) {
|
||||
useAreaSearchStore.getState().setLoading(false);
|
||||
setErrorMessage(`조회 실패: ${error.message}`);
|
||||
}
|
||||
}, [startDate, startTime, endDate, endTime, zones, searchMode, setTimeRange]);
|
||||
}, [startDate, startTime, endDate, endTime, zones, setTimeRange]);
|
||||
|
||||
const executeStsSearch = useCallback(async () => {
|
||||
const from = new Date(`${startDate}T${startTime}:00`);
|
||||
const to = new Date(`${endDate}T${endTime}:00`);
|
||||
const stsState = useStsStore.getState();
|
||||
|
||||
try {
|
||||
setErrorMessage('');
|
||||
stsState.setLoading(true);
|
||||
|
||||
const zone = zones[0];
|
||||
const polygon = {
|
||||
id: zone.id,
|
||||
name: zone.name,
|
||||
coordinates: zone.coordinates,
|
||||
};
|
||||
|
||||
const result = await fetchVesselContacts({
|
||||
startTime: toKstISOString(from),
|
||||
endTime: toKstISOString(to),
|
||||
polygon,
|
||||
minContactDurationMinutes: stsState.minContactDurationMinutes,
|
||||
maxContactDistanceMeters: stsState.maxContactDistanceMeters,
|
||||
});
|
||||
|
||||
if (result.contacts.length === 0) {
|
||||
stsState.setLoading(false);
|
||||
showToast('접촉 의심 쌍이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
stsState.setResults(result);
|
||||
|
||||
let minTime = Infinity;
|
||||
let maxTime = -Infinity;
|
||||
result.tracks.forEach((t) => {
|
||||
if (t.timestampsMs.length > 0) {
|
||||
minTime = Math.min(minTime, t.timestampsMs[0]);
|
||||
maxTime = Math.max(maxTime, t.timestampsMs[t.timestampsMs.length - 1]);
|
||||
}
|
||||
});
|
||||
setTimeRange(minTime, maxTime);
|
||||
hideLiveShips();
|
||||
|
||||
stsState.setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('[STS] 조회 실패:', error);
|
||||
useStsStore.getState().setLoading(false);
|
||||
setErrorMessage(`조회 실패: ${error.message}`);
|
||||
}
|
||||
}, [startDate, startTime, endDate, endTime, zones, setTimeRange]);
|
||||
|
||||
const handleQuery = useCallback(async () => {
|
||||
if (!startDate || !endDate) {
|
||||
@ -173,39 +268,35 @@ export default function AreaSearchPage({ isOpen, onToggle }) {
|
||||
}
|
||||
|
||||
// 기존 조회 결과가 있으면 초기화 확인
|
||||
const { queryCompleted: hasExisting } = useAreaSearchStore.getState();
|
||||
if (hasExisting) {
|
||||
if (queryCompleted) {
|
||||
const confirmed = window.confirm('이전 조회 정보가 초기화됩니다.\n새로운 조건으로 다시 조회하시겠습니까?');
|
||||
if (!confirmed) return;
|
||||
|
||||
// 기존 결과 즉시 클리어 (queryCompleted: false → 레이어 해제 + 타임라인 숨김)
|
||||
useAreaSearchStore.getState().clearResults();
|
||||
if (activeTab === ANALYSIS_TABS.AREA) {
|
||||
useAreaSearchStore.getState().clearResults();
|
||||
} else {
|
||||
useStsStore.getState().clearResults();
|
||||
}
|
||||
useAreaSearchAnimationStore.getState().reset();
|
||||
// showLiveShips() 호출하지 않음 - 라이브 비활성 유지
|
||||
}
|
||||
|
||||
executeQuery();
|
||||
}, [startDate, startTime, endDate, endTime, zones, searchMode, executeQuery]);
|
||||
if (activeTab === ANALYSIS_TABS.AREA) {
|
||||
executeAreaSearch();
|
||||
} else {
|
||||
executeStsSearch();
|
||||
}
|
||||
}, [startDate, startTime, endDate, endTime, zones, activeTab, queryCompleted, executeAreaSearch, executeStsSearch]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
useAreaSearchStore.getState().reset();
|
||||
useStsStore.getState().reset();
|
||||
useAreaSearchAnimationStore.getState().reset();
|
||||
unregisterAreaSearchLayers();
|
||||
unregisterStsLayers();
|
||||
showLiveShips();
|
||||
setErrorMessage('');
|
||||
}, []);
|
||||
|
||||
const handleToggleVessel = useCallback((vesselId) => {
|
||||
useAreaSearchStore.getState().toggleVesselEnabled(vesselId);
|
||||
}, []);
|
||||
|
||||
const handleHighlightVessel = useCallback((vesselId) => {
|
||||
useAreaSearchStore.getState().setHighlightedVesselId(vesselId);
|
||||
}, []);
|
||||
|
||||
const handleExportCSV = useCallback(() => {
|
||||
exportSearchResultToCSV(tracks, hitDetails, zones);
|
||||
}, [tracks, hitDetails, zones]);
|
||||
|
||||
return (
|
||||
<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,116 +358,48 @@ 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 className="btnBox">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-query"
|
||||
onClick={handleQuery}
|
||||
disabled={isLoading || zones.length === 0}
|
||||
>
|
||||
{isLoading ? '조회 중...' : '조회'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 조회 버튼 */}
|
||||
<div className="btnBox">
|
||||
{/* 탭 바 */}
|
||||
<div className="analysis-tab-bar">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-query"
|
||||
onClick={handleQuery}
|
||||
disabled={isLoading || zones.length === 0}
|
||||
className={`analysis-tab ${activeTab === ANALYSIS_TABS.AREA ? 'active' : ''}`}
|
||||
onClick={() => handleTabChange(ANALYSIS_TABS.AREA)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? '조회 중...' : '조회'}
|
||||
구역분석
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`analysis-tab ${activeTab === ANALYSIS_TABS.STS ? 'active' : ''}`}
|
||||
onClick={() => handleTabChange(ANALYSIS_TABS.STS)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
STS분석
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 결과 영역 */}
|
||||
<div className="result-section">
|
||||
{errorMessage && <div className="error-message">{errorMessage}</div>}
|
||||
|
||||
{isLoading && <div className="loading-message">데이터를 불러오는 중입니다...</div>}
|
||||
|
||||
{queryCompleted && tracks.length > 0 && (
|
||||
<div className="result-content">
|
||||
{summary && (
|
||||
<div className="result-summary">
|
||||
<span>검색 결과: {summary.totalVessels ?? tracks.length}척</span>
|
||||
{summary.processingTimeMs != null && (
|
||||
<span className="processing-time">
|
||||
({(summary.processingTimeMs / 1000).toFixed(2)}초)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!summary && (
|
||||
<div className="result-summary">
|
||||
<span>검색 결과: {tracks.length}척</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="button" className="btn-csv" onClick={handleExportCSV}>
|
||||
CSV 내보내기
|
||||
</button>
|
||||
|
||||
<ul className="vessel-list">
|
||||
{tracks.map((track) => {
|
||||
const isDisabled = disabledVesselIds.has(track.vesselId);
|
||||
const isHighlighted = highlightedVesselId === track.vesselId;
|
||||
const color = getShipKindColor(track.shipKindCode);
|
||||
const rgbStr = `rgb(${color[0]}, ${color[1]}, ${color[2]})`;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={track.vesselId}
|
||||
className={`vessel-item ${isDisabled ? 'disabled' : ''} ${isHighlighted ? 'highlighted' : ''}`}
|
||||
onMouseEnter={() => handleHighlightVessel(track.vesselId)}
|
||||
onMouseLeave={() => handleHighlightVessel(null)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="vessel-toggle"
|
||||
onClick={() => handleToggleVessel(track.vesselId)}
|
||||
>
|
||||
<span className="vessel-color" style={{ backgroundColor: rgbStr }} />
|
||||
<span className="vessel-name">
|
||||
{track.shipName || track.targetId}
|
||||
</span>
|
||||
<span className="vessel-info">
|
||||
{getShipKindName(track.shipKindCode)} / {getSignalSourceName(track.sigSrcCd)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{queryCompleted && tracks.length === 0 && !errorMessage && (
|
||||
<div className="empty-message">조건에 맞는 선박이 없습니다.</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !queryCompleted && !errorMessage && (
|
||||
<div className="empty-message">
|
||||
구역을 설정하고 조회 버튼을 클릭하세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 탭 컨텐츠 */}
|
||||
{activeTab === ANALYSIS_TABS.AREA ? (
|
||||
<AreaSearchTab isLoading={areaLoading} errorMessage={errorMessage} />
|
||||
) : (
|
||||
<StsAnalysisTab isLoading={stsLoading} errorMessage={errorMessage} />
|
||||
)}
|
||||
</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,79 +103,119 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 검색 모드
|
||||
.mode-section {
|
||||
background-color: var(--secondary1, rgba(255, 255, 255, 0.05));
|
||||
border-radius: 0.6rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.2rem;
|
||||
|
||||
.section-title {
|
||||
font-size: var(--fs-m, 1.3rem);
|
||||
font-weight: var(--fw-bold, 700);
|
||||
color: var(--white, #fff);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mode-options {
|
||||
.btnBox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 2rem;
|
||||
gap: 1rem;
|
||||
|
||||
.mode-radio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
cursor: pointer;
|
||||
.btn {
|
||||
min-width: 12rem;
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 0.4rem;
|
||||
font-size: var(--fs-s, 1.2rem);
|
||||
color: var(--tertiary4, #ccc);
|
||||
font-weight: var(--fw-semibold, 600);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
|
||||
input[type='radio'] {
|
||||
accent-color: var(--primary1, #4a9eff);
|
||||
&.btn-primary {
|
||||
background-color: var(--primary1, #4a9eff);
|
||||
color: var(--white, #fff);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--primary2, #3a8eef);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--secondary3, #555);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
&:has(input:checked) span {
|
||||
color: var(--white, #fff);
|
||||
&.btn-query {
|
||||
min-width: 14rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 조회 버튼
|
||||
.btnBox {
|
||||
// 탭 바 (segmented control)
|
||||
.analysis-tab-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 1.2rem;
|
||||
background-color: var(--tertiary1, rgba(0, 0, 0, 0.3));
|
||||
border-radius: 0.6rem;
|
||||
padding: 0.3rem;
|
||||
gap: 0.3rem;
|
||||
|
||||
.btn {
|
||||
min-width: 12rem;
|
||||
padding: 1rem 2rem;
|
||||
.analysis-tab {
|
||||
flex: 1;
|
||||
padding: 0.7rem 0;
|
||||
border: none;
|
||||
border-radius: 0.4rem;
|
||||
background: transparent;
|
||||
color: var(--tertiary4, #999);
|
||||
font-size: var(--fs-s, 1.2rem);
|
||||
font-weight: var(--fw-semibold, 600);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
transition: all 0.15s;
|
||||
|
||||
&.btn-primary {
|
||||
background-color: var(--primary1, #4a9eff);
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--white, #fff);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--primary2, #3a8eef);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--secondary3, #555);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-query {
|
||||
min-width: 14rem;
|
||||
&.active {
|
||||
background-color: rgba(74, 158, 255, 0.2);
|
||||
color: var(--white, #fff);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 검색 모드 (칩 스타일 + 구역 설정과 동일 배경)
|
||||
.search-mode {
|
||||
background-color: var(--secondary1, rgba(255, 255, 255, 0.05));
|
||||
border-radius: 0.6rem;
|
||||
padding: 1.2rem 1.5rem;
|
||||
margin-bottom: 1.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
|
||||
.mode-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 1.2rem;
|
||||
border: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.2));
|
||||
border-radius: 2rem;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: var(--fs-xs, 1.1rem);
|
||||
color: var(--tertiary4, #ccc);
|
||||
transition: all 0.15s;
|
||||
|
||||
input[type='radio'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary1, rgba(255, 255, 255, 0.4));
|
||||
color: var(--white, #fff);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--primary1, #4a9eff);
|
||||
background-color: rgba(74, 158, 255, 0.15);
|
||||
color: var(--white, #fff);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -194,7 +228,7 @@
|
||||
flex-direction: column;
|
||||
background-color: var(--tertiary1, rgba(0, 0, 0, 0.2));
|
||||
border-radius: 0.6rem;
|
||||
padding: 1.5rem;
|
||||
padding: 1.2rem;
|
||||
overflow-y: auto;
|
||||
|
||||
&:has(> .loading-message),
|
||||
@ -221,38 +255,47 @@
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.btn-csv {
|
||||
margin-bottom: 1.2rem;
|
||||
padding: 0.6rem 1.2rem;
|
||||
border: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.2));
|
||||
border-radius: 0.4rem;
|
||||
background: transparent;
|
||||
color: var(--tertiary4, #ccc);
|
||||
font-size: var(--fs-xs, 1.1rem);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary1, #4a9eff);
|
||||
color: var(--white, #fff);
|
||||
}
|
||||
}
|
||||
|
||||
.result-content {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
|
||||
.result-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
margin-bottom: 1.2rem;
|
||||
font-size: var(--fs-m, 1.3rem);
|
||||
font-weight: var(--fw-bold, 700);
|
||||
color: var(--white, #fff);
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.8rem;
|
||||
flex-shrink: 0;
|
||||
|
||||
.processing-time {
|
||||
.result-summary-text {
|
||||
font-size: var(--fs-m, 1.3rem);
|
||||
font-weight: var(--fw-bold, 700);
|
||||
color: var(--white, #fff);
|
||||
|
||||
.processing-time {
|
||||
font-size: var(--fs-xs, 1.1rem);
|
||||
font-weight: normal;
|
||||
color: var(--tertiary4, #999);
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-csv {
|
||||
padding: 0.4rem 1rem;
|
||||
border: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.2));
|
||||
border-radius: 0.4rem;
|
||||
background: transparent;
|
||||
color: var(--tertiary4, #ccc);
|
||||
font-size: var(--fs-xs, 1.1rem);
|
||||
font-weight: normal;
|
||||
color: var(--tertiary4, #999);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary1, #4a9eff);
|
||||
color: var(--white, #fff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -260,8 +303,12 @@
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
.vessel-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--tertiary2, rgba(255, 255, 255, 0.1));
|
||||
|
||||
&.highlighted {
|
||||
@ -276,8 +323,9 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
width: 100%;
|
||||
padding: 1rem 0.4rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.8rem 0.4rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
@ -304,6 +352,33 @@
|
||||
font-size: var(--fs-xs, 1.1rem);
|
||||
color: var(--tertiary4, #999);
|
||||
flex-shrink: 0;
|
||||
|
||||
.visit-count {
|
||||
margin-left: 0.4rem;
|
||||
color: var(--primary1, #4a9eff);
|
||||
font-weight: var(--fw-semibold, 600);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.vessel-detail-btn {
|
||||
flex-shrink: 0;
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--tertiary4, #999);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
border-radius: 0.3rem;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: var(--white, #fff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
188
src/areaSearch/components/AreaSearchTab.jsx
Normal file
188
src/areaSearch/components/AreaSearchTab.jsx
Normal file
@ -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="상세 보기"
|
||||
>
|
||||
▶
|
||||
</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">
|
||||
<span
|
||||
className="area-search-tooltip__zone-name"
|
||||
style={{ color: zoneColor }}
|
||||
>
|
||||
{zone.name}
|
||||
</span>
|
||||
<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 }}
|
||||
>
|
||||
{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 {
|
||||
|
||||
140
src/areaSearch/components/StsAnalysisTab.jsx
Normal file
140
src/areaSearch/components/StsAnalysisTab.jsx
Normal file
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
94
src/areaSearch/components/StsAnalysisTab.scss
Normal file
94
src/areaSearch/components/StsAnalysisTab.scss
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
489
src/areaSearch/components/StsContactDetailModal.jsx
Normal file
489
src/areaSearch/components/StsContactDetailModal.jsx
Normal file
@ -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}>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
319
src/areaSearch/components/StsContactDetailModal.scss
Normal file
319
src/areaSearch/components/StsContactDetailModal.scss
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
261
src/areaSearch/components/StsContactList.jsx
Normal file
261
src/areaSearch/components/StsContactList.jsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
276
src/areaSearch/components/StsContactList.scss
Normal file
276
src/areaSearch/components/StsContactList.scss
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
459
src/areaSearch/components/VesselDetailModal.jsx
Normal file
459
src/areaSearch/components/VesselDetailModal.jsx
Normal file
@ -0,0 +1,459 @@
|
||||
/**
|
||||
* 선박 상세 모달 — 임베디드 OL 지도 + 시간순 방문 이력 + 이미지 저장
|
||||
*/
|
||||
import { useEffect, useRef, useCallback, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import Map from 'ol/Map';
|
||||
import View from 'ol/View';
|
||||
import { XYZ } from 'ol/source';
|
||||
import TileLayer from 'ol/layer/Tile';
|
||||
import VectorSource from 'ol/source/Vector';
|
||||
import VectorLayer from 'ol/layer/Vector';
|
||||
import { Feature } from 'ol';
|
||||
import { Point, LineString, Polygon } from 'ol/geom';
|
||||
import { fromLonLat } from 'ol/proj';
|
||||
import { Style, Fill, Stroke, Circle as CircleStyle, Text } from 'ol/style';
|
||||
import { defaults as defaultControls, ScaleLine } from 'ol/control';
|
||||
import { defaults as defaultInteractions } from 'ol/interaction';
|
||||
import html2canvas from 'html2canvas';
|
||||
|
||||
import { useAreaSearchStore } from '../stores/areaSearchStore';
|
||||
import { ZONE_COLORS } from '../types/areaSearch.types';
|
||||
import { getShipKindColor, getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types';
|
||||
import { formatTimestamp, formatPosition } from './AreaSearchTooltip';
|
||||
import { mapLayerConfig } from '../../map/layers/baseLayer';
|
||||
import './VesselDetailModal.scss';
|
||||
|
||||
function getNationalFlagUrl(nationalCode) {
|
||||
if (!nationalCode) return null;
|
||||
return `/ship/image/small/${nationalCode}.svg`;
|
||||
}
|
||||
|
||||
function createZoneFeatures(zones) {
|
||||
const features = [];
|
||||
zones.forEach((zone) => {
|
||||
const coords3857 = zone.coordinates.map((c) => fromLonLat(c));
|
||||
const polygon = new Polygon([coords3857]);
|
||||
const feature = new Feature({ geometry: polygon });
|
||||
const color = ZONE_COLORS[zone.colorIndex] || ZONE_COLORS[0];
|
||||
feature.setStyle([
|
||||
new Style({
|
||||
fill: new Fill({ color: `rgba(${color.fill.join(',')})` }),
|
||||
stroke: new Stroke({ color: `rgba(${color.stroke.join(',')})`, width: 2 }),
|
||||
}),
|
||||
new Style({
|
||||
geometry: () => {
|
||||
const ext = polygon.getExtent();
|
||||
const center = [(ext[0] + ext[2]) / 2, (ext[1] + ext[3]) / 2];
|
||||
return new Point(center);
|
||||
},
|
||||
text: new Text({
|
||||
text: `${zone.name}구역`,
|
||||
font: 'bold 12px sans-serif',
|
||||
fill: new Fill({ color: color.label || '#fff' }),
|
||||
stroke: new Stroke({ color: 'rgba(0,0,0,0.7)', width: 3 }),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
features.push(feature);
|
||||
});
|
||||
return features;
|
||||
}
|
||||
|
||||
function createTrackFeature(track) {
|
||||
const coords3857 = track.geometry.map((c) => fromLonLat(c));
|
||||
const line = new LineString(coords3857);
|
||||
const feature = new Feature({ geometry: line });
|
||||
const color = getShipKindColor(track.shipKindCode);
|
||||
feature.setStyle(new Style({
|
||||
stroke: new Stroke({
|
||||
color: `rgba(${color[0]},${color[1]},${color[2]},0.8)`,
|
||||
width: 2,
|
||||
}),
|
||||
}));
|
||||
return feature;
|
||||
}
|
||||
|
||||
function createMarkerFeatures(sortedHits) {
|
||||
const features = [];
|
||||
sortedHits.forEach((hit, idx) => {
|
||||
const seqNum = idx + 1;
|
||||
|
||||
if (hit.entryPosition) {
|
||||
const pos3857 = fromLonLat(hit.entryPosition);
|
||||
const f = new Feature({ geometry: new Point(pos3857) });
|
||||
const timeStr = formatTimestamp(hit.entryTimestamp);
|
||||
f.set('_markerType', 'in');
|
||||
f.set('_seqNum', seqNum);
|
||||
f.setStyle(new Style({
|
||||
image: new CircleStyle({
|
||||
radius: 7,
|
||||
fill: new Fill({ color: '#2ecc71' }),
|
||||
stroke: new Stroke({ color: '#fff', width: 2 }),
|
||||
}),
|
||||
text: new Text({
|
||||
text: `${seqNum}-IN ${timeStr}`,
|
||||
font: 'bold 10px sans-serif',
|
||||
fill: new Fill({ color: '#2ecc71' }),
|
||||
stroke: new Stroke({ color: 'rgba(0,0,0,0.8)', width: 3 }),
|
||||
offsetY: -16,
|
||||
textAlign: 'left',
|
||||
offsetX: 10,
|
||||
}),
|
||||
}));
|
||||
features.push(f);
|
||||
}
|
||||
|
||||
if (hit.exitPosition) {
|
||||
const pos3857 = fromLonLat(hit.exitPosition);
|
||||
const f = new Feature({ geometry: new Point(pos3857) });
|
||||
const timeStr = formatTimestamp(hit.exitTimestamp);
|
||||
f.set('_markerType', 'out');
|
||||
f.set('_seqNum', seqNum);
|
||||
f.setStyle(new Style({
|
||||
image: new CircleStyle({
|
||||
radius: 7,
|
||||
fill: new Fill({ color: '#e74c3c' }),
|
||||
stroke: new Stroke({ color: '#fff', width: 2 }),
|
||||
}),
|
||||
text: new Text({
|
||||
text: `${seqNum}-OUT ${timeStr}`,
|
||||
font: 'bold 10px sans-serif',
|
||||
fill: new Fill({ color: '#e74c3c' }),
|
||||
stroke: new Stroke({ color: 'rgba(0,0,0,0.8)', width: 3 }),
|
||||
offsetY: 16,
|
||||
textAlign: 'left',
|
||||
offsetX: 10,
|
||||
}),
|
||||
}));
|
||||
features.push(f);
|
||||
}
|
||||
});
|
||||
return features;
|
||||
}
|
||||
|
||||
/**
|
||||
* 마커 텍스트 겹침 보정 — 포인트(원)는 그대로, 텍스트 offsetY만 조정
|
||||
* 해상도 기반으로 근접 마커를 감지하고 텍스트를 수직 분산 배치
|
||||
*/
|
||||
function adjustOverlappingLabels(features, resolution) {
|
||||
if (!resolution || features.length < 2) return;
|
||||
|
||||
const PROXIMITY_PX = 40;
|
||||
const proximityMap = resolution * PROXIMITY_PX;
|
||||
const LINE_HEIGHT_PX = 16;
|
||||
|
||||
// 피처별 좌표 추출
|
||||
const items = features.map((f) => {
|
||||
const coord = f.getGeometry().getCoordinates();
|
||||
return { feature: f, x: coord[0], y: coord[1] };
|
||||
});
|
||||
|
||||
// 근접 그룹 찾기 (Union-Find 방식)
|
||||
const parent = items.map((_, i) => i);
|
||||
const find = (i) => { while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i]; } return i; };
|
||||
const union = (a, b) => { parent[find(a)] = find(b); };
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
for (let j = i + 1; j < items.length; j++) {
|
||||
const dx = items[i].x - items[j].x;
|
||||
const dy = items[i].y - items[j].y;
|
||||
if (Math.sqrt(dx * dx + dy * dy) < proximityMap) {
|
||||
union(i, j);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 그룹별 텍스트 offsetY 분산 (ol/Map import와 충돌 방지를 위해 plain object 사용)
|
||||
const groups = {};
|
||||
items.forEach((item, i) => {
|
||||
const root = find(i);
|
||||
if (!groups[root]) groups[root] = [];
|
||||
groups[root].push(item);
|
||||
});
|
||||
|
||||
Object.values(groups).forEach((group) => {
|
||||
if (group.length < 2) return;
|
||||
// 시퀀스 번호 순 정렬 후 IN→OUT 순서
|
||||
group.sort((a, b) => {
|
||||
const seqA = a.feature.get('_seqNum');
|
||||
const seqB = b.feature.get('_seqNum');
|
||||
if (seqA !== seqB) return seqA - seqB;
|
||||
const typeA = a.feature.get('_markerType') === 'in' ? 0 : 1;
|
||||
const typeB = b.feature.get('_markerType') === 'in' ? 0 : 1;
|
||||
return typeA - typeB;
|
||||
});
|
||||
|
||||
const totalHeight = group.length * LINE_HEIGHT_PX;
|
||||
const startY = -totalHeight / 2 - 8;
|
||||
|
||||
group.forEach((item, idx) => {
|
||||
const style = item.feature.getStyle();
|
||||
const textStyle = style.getText();
|
||||
if (textStyle) {
|
||||
textStyle.setOffsetY(startY + idx * LINE_HEIGHT_PX);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const MODAL_WIDTH = 680;
|
||||
const MODAL_APPROX_HEIGHT = 780;
|
||||
|
||||
export default function VesselDetailModal({ vesselId, onClose }) {
|
||||
const tracks = useAreaSearchStore((s) => s.tracks);
|
||||
const hitDetails = useAreaSearchStore((s) => s.hitDetails);
|
||||
const zones = useAreaSearchStore((s) => s.zones);
|
||||
|
||||
const mapContainerRef = useRef(null);
|
||||
const mapRef = useRef(null);
|
||||
const contentRef = useRef(null);
|
||||
|
||||
// 드래그 위치 관리
|
||||
const [position, setPosition] = useState(() => ({
|
||||
x: (window.innerWidth - MODAL_WIDTH) / 2,
|
||||
y: Math.max(20, (window.innerHeight - MODAL_APPROX_HEIGHT) / 2),
|
||||
}));
|
||||
const posRef = useRef(position);
|
||||
const dragging = useRef(false);
|
||||
const dragStart = useRef({ x: 0, y: 0 });
|
||||
|
||||
const handleMouseDown = useCallback((e) => {
|
||||
dragging.current = true;
|
||||
dragStart.current = {
|
||||
x: e.clientX - posRef.current.x,
|
||||
y: e.clientY - posRef.current.y,
|
||||
};
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e) => {
|
||||
if (!dragging.current) return;
|
||||
const newPos = {
|
||||
x: e.clientX - dragStart.current.x,
|
||||
y: e.clientY - dragStart.current.y,
|
||||
};
|
||||
posRef.current = newPos;
|
||||
setPosition(newPos);
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
dragging.current = false;
|
||||
};
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const track = useMemo(
|
||||
() => tracks.find((t) => t.vesselId === vesselId),
|
||||
[tracks, vesselId],
|
||||
);
|
||||
const hits = useMemo(() => hitDetails[vesselId] || [], [hitDetails, vesselId]);
|
||||
|
||||
const zoneMap = useMemo(() => {
|
||||
const lookup = {};
|
||||
zones.forEach((z, idx) => {
|
||||
lookup[z.id] = z;
|
||||
lookup[z.name] = z;
|
||||
lookup[idx] = z;
|
||||
lookup[String(idx)] = z;
|
||||
});
|
||||
return lookup;
|
||||
}, [zones]);
|
||||
|
||||
const sortedHits = useMemo(
|
||||
() => [...hits].sort((a, b) => a.entryTimestamp - b.entryTimestamp),
|
||||
[hits],
|
||||
);
|
||||
|
||||
// OL 지도 초기화
|
||||
useEffect(() => {
|
||||
if (!mapContainerRef.current || !track) return;
|
||||
|
||||
const tileSource = new XYZ({
|
||||
url: mapLayerConfig.darkLayer.source.getUrls()[0],
|
||||
minZoom: 6,
|
||||
maxZoom: 11,
|
||||
});
|
||||
const tileLayer = new TileLayer({ source: tileSource, preload: Infinity });
|
||||
|
||||
const zoneSource = new VectorSource({ features: createZoneFeatures(zones) });
|
||||
const zoneLayer = new VectorLayer({ source: zoneSource });
|
||||
|
||||
const trackSource = new VectorSource({ features: [createTrackFeature(track)] });
|
||||
const trackLayer = new VectorLayer({ source: trackSource });
|
||||
|
||||
const markerFeatures = createMarkerFeatures(sortedHits);
|
||||
const markerSource = new VectorSource({ features: markerFeatures });
|
||||
const markerLayer = new VectorLayer({ source: markerSource });
|
||||
|
||||
const map = new Map({
|
||||
target: mapContainerRef.current,
|
||||
layers: [tileLayer, zoneLayer, trackLayer, markerLayer],
|
||||
view: new View({ center: [0, 0], zoom: 7 }),
|
||||
controls: defaultControls({ attribution: false, zoom: false, rotate: false })
|
||||
.extend([new ScaleLine({ units: 'nautical' })]),
|
||||
interactions: defaultInteractions({ doubleClickZoom: false }),
|
||||
});
|
||||
|
||||
// 전체 extent에 맞춤
|
||||
const allSource = new VectorSource();
|
||||
[...zoneSource.getFeatures(), ...trackSource.getFeatures()].forEach((f) => allSource.addFeature(f.clone()));
|
||||
const extent = allSource.getExtent();
|
||||
if (extent && extent[0] !== Infinity) {
|
||||
map.getView().fit(extent, { padding: [50, 50, 50, 50], maxZoom: 14 });
|
||||
}
|
||||
|
||||
// view fit 후 해상도 기반 텍스트 겹침 보정
|
||||
const resolution = map.getView().getResolution();
|
||||
adjustOverlappingLabels(markerFeatures, resolution);
|
||||
|
||||
mapRef.current = map;
|
||||
|
||||
return () => {
|
||||
map.setTarget(null);
|
||||
map.dispose();
|
||||
mapRef.current = null;
|
||||
};
|
||||
}, [track, zones, sortedHits, zoneMap]);
|
||||
|
||||
const handleSaveImage = useCallback(async () => {
|
||||
const el = contentRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const modal = el.parentElement;
|
||||
const saved = {
|
||||
elOverflow: el.style.overflow,
|
||||
modalMaxHeight: modal.style.maxHeight,
|
||||
modalOverflow: modal.style.overflow,
|
||||
};
|
||||
|
||||
// 스크롤 영역 포함 전체 캡처를 위해 일시적으로 제약 해제
|
||||
el.style.overflow = 'visible';
|
||||
modal.style.maxHeight = 'none';
|
||||
modal.style.overflow = 'visible';
|
||||
|
||||
try {
|
||||
const canvas = await html2canvas(el, {
|
||||
backgroundColor: '#141820',
|
||||
useCORS: true,
|
||||
scale: 2,
|
||||
});
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const now = new Date();
|
||||
const name = track?.shipName || track?.targetId || 'vessel';
|
||||
link.href = url;
|
||||
link.download = `항적분석_${name}_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}.png`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, 'image/png');
|
||||
} catch (err) {
|
||||
console.error('[VesselDetailModal] 이미지 저장 실패:', err);
|
||||
} finally {
|
||||
el.style.overflow = saved.elOverflow;
|
||||
modal.style.maxHeight = saved.modalMaxHeight;
|
||||
modal.style.overflow = saved.modalOverflow;
|
||||
}
|
||||
}, [track]);
|
||||
|
||||
if (!track) return null;
|
||||
|
||||
const kindName = getShipKindName(track.shipKindCode);
|
||||
const sourceName = getSignalSourceName(track.sigSrcCd);
|
||||
const flagUrl = getNationalFlagUrl(track.nationalCode);
|
||||
|
||||
return createPortal(
|
||||
<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}>
|
||||
×
|
||||
</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,
|
||||
);
|
||||
}
|
||||
224
src/areaSearch/components/VesselDetailModal.scss
Normal file
224
src/areaSearch/components/VesselDetailModal.scss
Normal file
@ -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({
|
||||
tracks: filteredTracks,
|
||||
showPoints: false,
|
||||
highlightedVesselId,
|
||||
onPathHover: (vesselId) => {
|
||||
useAreaSearchStore.getState().setHighlightedVesselId(vesselId);
|
||||
},
|
||||
layerIds: {
|
||||
path: AREA_SEARCH_LAYER_IDS.PATH,
|
||||
},
|
||||
});
|
||||
layers.push(...staticLayers);
|
||||
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 },
|
||||
}),
|
||||
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 () => {
|
||||
|
||||
288
src/areaSearch/hooks/useStsLayer.js
Normal file
288
src/areaSearch/hooks/useStsLayer.js
Normal file
@ -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));
|
||||
|
||||
513
src/areaSearch/hooks/useZoneEdit.js
Normal file
513
src/areaSearch/hooks/useZoneEdit.js
Normal file
@ -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]);
|
||||
}
|
||||
149
src/areaSearch/interactions/BoxResizeInteraction.js
Normal file
149
src/areaSearch/interactions/BoxResizeInteraction.js
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
90
src/areaSearch/interactions/CircleResizeInteraction.js
Normal file
90
src/areaSearch/interactions/CircleResizeInteraction.js
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
80
src/areaSearch/services/stsApi.js
Normal file
80
src/areaSearch/services/stsApi.js
Normal file
@ -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 left = 0;
|
||||
let right = timestampsMs.length - 1;
|
||||
while (left < right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
if (timestampsMs[mid] < currentTime) left = mid + 1;
|
||||
else right = mid;
|
||||
// 커서 기반 탐색: 이전 인덱스에서 선형 전진 시도
|
||||
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 = (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),
|
||||
|
||||
275
src/areaSearch/stores/stsStore.js
Normal file
275
src/areaSearch/stores/stsStore.js
Normal file
@ -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];
|
||||
|
||||
// ========== 선종 코드 전체 목록 (필터 초기값) ==========
|
||||
|
||||
|
||||
106
src/areaSearch/types/sts.types.js
Normal file
106
src/areaSearch/types/sts.types.js
Normal file
@ -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) => {
|
||||
zoneHeaders.push(
|
||||
`구역${name}_진입시각`, `구역${name}_진입위치`,
|
||||
`구역${name}_진출시각`, `구역${name}_진출위치`,
|
||||
);
|
||||
zones.forEach((zone) => {
|
||||
const max = maxVisitsPerZone[zone.id] || 1;
|
||||
if (max === 1) {
|
||||
zoneHeaders.push(
|
||||
`구역${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,16 +93,23 @@ 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);
|
||||
if (hit) {
|
||||
zoneData.push(
|
||||
formatTimestamp(hit.entryTimestamp),
|
||||
formatPosition(hit.entryPosition),
|
||||
formatTimestamp(hit.exitTimestamp),
|
||||
formatPosition(hit.exitPosition),
|
||||
);
|
||||
} else {
|
||||
zoneData.push('', '', '', '');
|
||||
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),
|
||||
formatPosition(hit.entryPosition),
|
||||
formatTimestamp(hit.exitTimestamp),
|
||||
formatPosition(hit.exitPosition),
|
||||
);
|
||||
} else {
|
||||
zoneData.push('', '', '', '');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
19
src/areaSearch/utils/stsLayerRegistry.js
Normal file
19
src/areaSearch/utils/stsLayerRegistry.js
Normal file
@ -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__ = [];
|
||||
}
|
||||
12
src/areaSearch/utils/zoneLayerRefs.js
Normal file
12
src/areaSearch/utils/zoneLayerRefs.js
Normal file
@ -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; }
|
||||
48
src/components/common/AlertModal.jsx
Normal file
48
src/components/common/AlertModal.jsx
Normal file
@ -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
|
||||
);
|
||||
}
|
||||
57
src/components/common/AlertModal.scss
Normal file
57
src/components/common/AlertModal.scss
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/components/common/LoadingOverlay.jsx
Normal file
20
src/components/common/LoadingOverlay.jsx
Normal file
@ -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,
|
||||
);
|
||||
}
|
||||
45
src/components/common/LoadingOverlay.scss
Normal file
45
src/components/common/LoadingOverlay.scss
Normal file
@ -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,54 +185,76 @@ 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({
|
||||
id: 'replay-trips-trail',
|
||||
data: filteredTripsData,
|
||||
getPath: (d) => d.path,
|
||||
getTimestamps: (d) => d.timestamps,
|
||||
getColor: [120, 120, 120, 180],
|
||||
widthMinPixels: 2,
|
||||
widthMaxPixels: 3,
|
||||
jointRounded: true,
|
||||
capRounded: true,
|
||||
fadeTrail: true,
|
||||
trailLength: TRAIL_LENGTH_MS,
|
||||
currentTime: relativeCurrentTime,
|
||||
});
|
||||
layers.push(tripsLayer);
|
||||
layers.push(
|
||||
new TripsLayer({
|
||||
id: 'replay-trips-trail',
|
||||
data: filteredTripsData,
|
||||
getPath: (d) => d.path,
|
||||
getTimestamps: (d) => d.timestamps,
|
||||
getColor: [120, 120, 120, 180],
|
||||
widthMinPixels: 2,
|
||||
widthMaxPixels: 3,
|
||||
jointRounded: true,
|
||||
capRounded: true,
|
||||
fadeTrail: true,
|
||||
trailLength: TRAIL_LENGTH_MS,
|
||||
currentTime: relativeCurrentTime,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 하이라이트 상태
|
||||
// 2. 정적 PathLayer (캐싱)
|
||||
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({
|
||||
tracks: filteredTracks,
|
||||
showPoints: false,
|
||||
highlightedVesselId: currentHighlightedVesselId,
|
||||
onPathHover: handlePathHover,
|
||||
});
|
||||
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 left = 0;
|
||||
let right = timestampsMs.length - 1;
|
||||
// 커서 기반 탐색
|
||||
let cursor = positionCursors.get(vesselId);
|
||||
|
||||
while (left < right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
if (timestampsMs[mid] < currentTime) {
|
||||
left = mid + 1;
|
||||
} else {
|
||||
right = mid;
|
||||
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 = (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 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,
|
||||
|
||||
34
yarn.lock
34
yarn.lock
@ -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"
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user