- STS(Ship-to-Ship) 접촉 분석 기능 전체 구현 - API 연동 (vessel-contacts), 스토어, 레이어 훅, 레이어 레지스트리 - 접촉 쌍 그룹핑, 그룹 카드 목록, 상세 모달 (그리드 레이아웃) - ScatterplotLayer 접촉 포인트 + 위험도 색상 - 항적분석 탭 UI 분리 (구역분석 / STS분석) - AreaSearchPage → AreaSearchTab, StsAnalysisTab 추출 - 탭 전환 시 결과 초기화 확인, 구역 클리어 - 지도 호버 하이라이트 구현 (구역분석 + STS) - MapContainer pointermove에 STS 레이어 ID 핸들러 추가 - STS 쌍 항적 동시 하이라이트 (vesselId → groupIndex 매핑) - 목록↔지도 호버 연동 자동 스크롤 - pickingRadius 12→20 확대 - 재생 컨트롤러(AreaSearchTimeline) STS 지원 - 항적/궤적 토글 activeTab 기반 스토어 분기 - 닫기 시 양쪽 스토어 + 레이어 정리 - 패널 닫기 초기화 수정 (isOpen 감지, clearResults로 탭 보존) - 조회 중 로딩 오버레이 (LoadingOverlay 공통 컴포넌트) - 항적분석 다중 방문 대응, 선박 상세 모달, 구역 편집 기능 - trackLayer updateTriggers Set 직렬화, highlightedVesselIds 지원 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
144 lines
5.2 KiB
JavaScript
144 lines
5.2 KiB
JavaScript
/**
|
|
* 항적분석 호버 툴팁 컴포넌트
|
|
* - 선박 기본 정보 (선종, 선명, 신호원)
|
|
* - 시간순 방문 이력 (구역 무관, 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';
|
|
import './AreaSearchTooltip.scss';
|
|
|
|
const OFFSET_X = 14;
|
|
const OFFSET_Y = -20;
|
|
|
|
/** nationalCode → 국기 SVG URL */
|
|
function getNationalFlagUrl(nationalCode) {
|
|
if (!nationalCode) return null;
|
|
return `/ship/image/small/${nationalCode}.svg`;
|
|
}
|
|
|
|
export function formatTimestamp(ms) {
|
|
if (!ms) return '-';
|
|
const d = new Date(ms);
|
|
const pad = (n) => String(n).padStart(2, '0');
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
}
|
|
|
|
export function formatPosition(pos) {
|
|
if (!pos || pos.length < 2) return null;
|
|
const lon = pos[0];
|
|
const lat = pos[1];
|
|
const latDir = lat >= 0 ? 'N' : 'S';
|
|
const lonDir = lon >= 0 ? 'E' : 'W';
|
|
return `${Math.abs(lat).toFixed(4)}\u00B0${latDir} ${Math.abs(lon).toFixed(4)}\u00B0${lonDir}`;
|
|
}
|
|
|
|
export default function AreaSearchTooltip() {
|
|
const tooltip = useAreaSearchStore((s) => s.areaSearchTooltip);
|
|
const tracks = useAreaSearchStore((s) => s.tracks);
|
|
const hitDetails = useAreaSearchStore((s) => s.hitDetails);
|
|
const zones = useAreaSearchStore((s) => s.zones);
|
|
|
|
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;
|
|
const track = tracks.find((t) => t.vesselId === vesselId);
|
|
if (!track) return null;
|
|
|
|
const hits = hitDetails[vesselId] || [];
|
|
const kindName = getShipKindName(track.shipKindCode);
|
|
const sourceName = getSignalSourceName(track.sigSrcCd);
|
|
const flagUrl = getNationalFlagUrl(track.nationalCode);
|
|
|
|
// 시간순 정렬 (구역 무관)
|
|
const sortedHits = [...hits].sort((a, b) => a.entryTimestamp - b.entryTimestamp);
|
|
|
|
return (
|
|
<div
|
|
className="area-search-tooltip"
|
|
style={{ left: x + OFFSET_X, top: y + OFFSET_Y }}
|
|
>
|
|
<div className="area-search-tooltip__header">
|
|
<span className="area-search-tooltip__kind">{kindName}</span>
|
|
{flagUrl && (
|
|
<span className="area-search-tooltip__flag">
|
|
<img
|
|
src={flagUrl}
|
|
alt="국기"
|
|
onError={(e) => { e.target.style.display = 'none'; }}
|
|
/>
|
|
</span>
|
|
)}
|
|
<span className="area-search-tooltip__name">
|
|
{track.shipName || track.targetId || '-'}
|
|
</span>
|
|
</div>
|
|
<div className="area-search-tooltip__info">
|
|
<span>{sourceName}</span>
|
|
</div>
|
|
|
|
{sortedHits.length > 0 && (
|
|
<div className="area-search-tooltip__zones">
|
|
{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={`${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">{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">{idx + 1}-OUT</span>
|
|
<span>{formatTimestamp(hit.exitTimestamp)}</span>
|
|
{exitPos && (
|
|
<span className="area-search-tooltip__pos">{exitPos}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|