ship-gis/src/areaSearch/components/AreaSearchTooltip.jsx
LHT 4945606c1c feat: STS 분석 기능 구현 및 항적분석 고도화
- 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>
2026-02-12 06:20:46 +09:00

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