-
OUT
+
{idx + 1}-OUT
{formatTimestamp(hit.exitTimestamp)}
{exitPos && (
{exitPos}
diff --git a/src/areaSearch/components/AreaSearchTooltip.scss b/src/areaSearch/components/AreaSearchTooltip.scss
index 1dc07409..561b4401 100644
--- a/src/areaSearch/components/AreaSearchTooltip.scss
+++ b/src/areaSearch/components/AreaSearchTooltip.scss
@@ -72,10 +72,27 @@
gap: 1px;
}
+ &__zone-header {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ margin-bottom: 1px;
+ }
+
+ &__visit-seq {
+ font-size: 10px;
+ color: #868e96;
+ min-width: 14px;
+ }
+
&__zone-name {
font-weight: 700;
font-size: 11px;
- margin-bottom: 1px;
+ }
+
+ &__visit-label {
+ font-size: 10px;
+ color: #868e96;
}
&__zone-row {
@@ -84,14 +101,14 @@
gap: 5px;
color: #ced4da;
font-size: 11px;
- padding-left: 2px;
+ padding-left: 18px;
}
&__zone-label {
font-weight: 600;
font-size: 9px;
color: #868e96;
- min-width: 24px;
+ min-width: 34px;
}
&__pos {
diff --git a/src/areaSearch/components/StsAnalysisTab.jsx b/src/areaSearch/components/StsAnalysisTab.jsx
new file mode 100644
index 00000000..2483f004
--- /dev/null
+++ b/src/areaSearch/components/StsAnalysisTab.jsx
@@ -0,0 +1,140 @@
+/**
+ * STS(Ship-to-Ship) 분석 탭 컴포넌트
+ *
+ * - ZoneDrawPanel (maxZones=1)
+ * - STS 파라미터 슬라이더 (최소 접촉 시간, 최대 접촉 거리)
+ * - 결과: StsContactList
+ */
+import { useCallback, useState } from 'react';
+import './StsAnalysisTab.scss';
+import { useStsStore } from '../stores/stsStore';
+import { useAreaSearchStore } from '../stores/areaSearchStore';
+import { STS_LIMITS } from '../types/sts.types';
+import ZoneDrawPanel from './ZoneDrawPanel';
+import StsContactList from './StsContactList';
+import StsContactDetailModal from './StsContactDetailModal';
+
+export default function StsAnalysisTab({ isLoading, errorMessage }) {
+ const queryCompleted = useStsStore((s) => s.queryCompleted);
+ const groupedContacts = useStsStore((s) => s.groupedContacts);
+ const summary = useStsStore((s) => s.summary);
+ const minContactDuration = useStsStore((s) => s.minContactDurationMinutes);
+ const maxContactDistance = useStsStore((s) => s.maxContactDistanceMeters);
+
+ const handleDurationChange = useCallback((e) => {
+ useStsStore.getState().setMinContactDuration(Number(e.target.value));
+ }, []);
+
+ const handleDistanceChange = useCallback((e) => {
+ useStsStore.getState().setMaxContactDistance(Number(e.target.value));
+ }, []);
+
+ const [detailGroupIndex, setDetailGroupIndex] = useState(null);
+
+ const handleDetailClick = useCallback((idx) => {
+ setDetailGroupIndex(idx);
+ }, []);
+
+ const handleDetailClose = useCallback(() => {
+ setDetailGroupIndex(null);
+ }, []);
+
+ return (
+ <>
+ {/* 구역 설정 (1개만) */}
+
+
+ {/* STS 파라미터 */}
+
+
+
+ 최소 접촉 시간
+ {minContactDuration}분
+
+
+
+ {STS_LIMITS.DURATION_MIN}분
+ {STS_LIMITS.DURATION_MAX}분
+
+
+
+
+
+ 최대 접촉 거리
+ {maxContactDistance}m
+
+
+
+ {STS_LIMITS.DISTANCE_MIN}m
+ {STS_LIMITS.DISTANCE_MAX}m
+
+
+
+
+ {/* 결과 영역 */}
+
+ {errorMessage &&
{errorMessage}
}
+
+ {isLoading &&
데이터를 불러오는 중입니다...
}
+
+ {queryCompleted && groupedContacts.length > 0 && (
+
+ {summary && (
+
+ 접촉 {summary.totalContactPairs}쌍
+ |
+ 관련 {summary.totalVesselsInvolved}척
+ |
+ 구역 내 {summary.totalVesselsInPolygon}척
+ {summary.processingTimeMs != null && (
+ <>
+ |
+
+ {(summary.processingTimeMs / 1000).toFixed(1)}초
+
+ >
+ )}
+
+ )}
+
+
+ )}
+
+ {queryCompleted && groupedContacts.length === 0 && !errorMessage && (
+
접촉 의심 쌍이 없습니다.
+ )}
+
+ {!isLoading && !queryCompleted && !errorMessage && (
+
+ 구역을 설정하고 조회 버튼을 클릭하세요.
+
+ )}
+
+
+ {detailGroupIndex !== null && (
+
+ )}
+ >
+ );
+}
diff --git a/src/areaSearch/components/StsAnalysisTab.scss b/src/areaSearch/components/StsAnalysisTab.scss
new file mode 100644
index 00000000..1b20e80a
--- /dev/null
+++ b/src/areaSearch/components/StsAnalysisTab.scss
@@ -0,0 +1,94 @@
+// STS 분석 탭 전용 스타일
+
+.sts-params {
+ background-color: var(--secondary1, rgba(255, 255, 255, 0.05));
+ border-radius: 0.6rem;
+ padding: 1.2rem 1.5rem;
+ margin-bottom: 1.2rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1.2rem;
+
+ .sts-param {
+ &__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 0.6rem;
+ }
+
+ &__label {
+ font-size: var(--fs-s, 1.2rem);
+ color: var(--tertiary4, #ccc);
+ }
+
+ &__value {
+ font-size: var(--fs-s, 1.2rem);
+ font-weight: var(--fw-bold, 700);
+ color: var(--primary1, #4a9eff);
+ min-width: 5rem;
+ text-align: right;
+ }
+
+ &__slider {
+ width: 100%;
+ height: 0.4rem;
+ -webkit-appearance: none;
+ appearance: none;
+ background: var(--tertiary2, rgba(255, 255, 255, 0.2));
+ border-radius: 0.2rem;
+ outline: none;
+ cursor: pointer;
+
+ &::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ width: 1.4rem;
+ height: 1.4rem;
+ background: var(--primary1, #4a9eff);
+ border-radius: 50%;
+ cursor: pointer;
+ transition: transform 0.1s;
+
+ &:hover {
+ transform: scale(1.2);
+ }
+ }
+
+ &:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ }
+ }
+
+ &__range {
+ display: flex;
+ justify-content: space-between;
+ margin-top: 0.3rem;
+ font-size: var(--fs-xxs, 1rem);
+ color: var(--tertiary3, #666);
+ }
+ }
+}
+
+.sts-summary {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.6rem 0;
+ margin-bottom: 0.8rem;
+ font-size: var(--fs-s, 1.2rem);
+ font-weight: var(--fw-semibold, 600);
+ color: var(--white, #fff);
+ flex-shrink: 0;
+
+ &__sep {
+ color: var(--tertiary3, #555);
+ font-weight: normal;
+ }
+
+ &__time {
+ color: var(--tertiary4, #999);
+ font-weight: normal;
+ font-size: var(--fs-xs, 1.1rem);
+ }
+}
diff --git a/src/areaSearch/components/StsContactDetailModal.jsx b/src/areaSearch/components/StsContactDetailModal.jsx
new file mode 100644
index 00000000..d5d90f65
--- /dev/null
+++ b/src/areaSearch/components/StsContactDetailModal.jsx
@@ -0,0 +1,489 @@
+/**
+ * STS 접촉 쌍 상세 모달 — 임베디드 OL 지도 + 그리드 레이아웃 + 이미지 저장
+ * 그룹 기반: 동일 선박 쌍의 여러 접촉을 리스트로 표시
+ */
+import { useEffect, useRef, useCallback, useMemo, useState } from 'react';
+import { createPortal } from 'react-dom';
+import Map from 'ol/Map';
+import View from 'ol/View';
+import { XYZ } from 'ol/source';
+import TileLayer from 'ol/layer/Tile';
+import VectorSource from 'ol/source/Vector';
+import VectorLayer from 'ol/layer/Vector';
+import { Feature } from 'ol';
+import { Point, LineString, Polygon } from 'ol/geom';
+import { fromLonLat } from 'ol/proj';
+import { Style, Fill, Stroke, Circle as CircleStyle, Text } from 'ol/style';
+import { defaults as defaultControls, ScaleLine } from 'ol/control';
+import { defaults as defaultInteractions } from 'ol/interaction';
+import html2canvas from 'html2canvas';
+
+import { useStsStore } from '../stores/stsStore';
+import { useAreaSearchStore } from '../stores/areaSearchStore';
+import { ZONE_COLORS } from '../types/areaSearch.types';
+import { getShipKindColor, getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types';
+import { formatTimestamp, formatPosition } from './AreaSearchTooltip';
+import {
+ getIndicatorDetail,
+ formatDistance,
+ formatDuration,
+ getContactRiskColor,
+} from '../types/sts.types';
+import { mapLayerConfig } from '../../map/layers/baseLayer';
+import './StsContactDetailModal.scss';
+
+function getNationalFlagUrl(nationalCode) {
+ if (!nationalCode) return null;
+ return `/ship/image/small/${nationalCode}.svg`;
+}
+
+function createZoneFeatures(zones) {
+ const features = [];
+ zones.forEach((zone) => {
+ const coords3857 = zone.coordinates.map((c) => fromLonLat(c));
+ const polygon = new Polygon([coords3857]);
+ const feature = new Feature({ geometry: polygon });
+ const color = ZONE_COLORS[zone.colorIndex] || ZONE_COLORS[0];
+ feature.setStyle([
+ new Style({
+ fill: new Fill({ color: `rgba(${color.fill.join(',')})` }),
+ stroke: new Stroke({ color: `rgba(${color.stroke.join(',')})`, width: 2 }),
+ }),
+ new Style({
+ geometry: () => {
+ const ext = polygon.getExtent();
+ const center = [(ext[0] + ext[2]) / 2, (ext[1] + ext[3]) / 2];
+ return new Point(center);
+ },
+ text: new Text({
+ text: `${zone.name}구역`,
+ font: 'bold 12px sans-serif',
+ fill: new Fill({ color: color.label || '#fff' }),
+ stroke: new Stroke({ color: 'rgba(0,0,0,0.7)', width: 3 }),
+ }),
+ }),
+ ]);
+ features.push(feature);
+ });
+ return features;
+}
+
+function createTrackFeature(track) {
+ const coords3857 = track.geometry.map((c) => fromLonLat(c));
+ const line = new LineString(coords3857);
+ const feature = new Feature({ geometry: line });
+ const color = getShipKindColor(track.shipKindCode);
+ feature.setStyle(new Style({
+ stroke: new Stroke({
+ color: `rgba(${color[0]},${color[1]},${color[2]},0.8)`,
+ width: 2,
+ }),
+ }));
+ return feature;
+}
+
+function createContactMarkers(contacts) {
+ const features = [];
+
+ contacts.forEach((contact, idx) => {
+ if (!contact.contactCenterPoint) return;
+
+ const pos3857 = fromLonLat(contact.contactCenterPoint);
+ const riskColor = getContactRiskColor(contact.indicators);
+
+ const f = new Feature({ geometry: new Point(pos3857) });
+ f.setStyle(new Style({
+ image: new CircleStyle({
+ radius: 10,
+ fill: new Fill({ color: `rgba(${riskColor[0]},${riskColor[1]},${riskColor[2]},0.6)` }),
+ stroke: new Stroke({ color: '#fff', width: 2 }),
+ }),
+ text: new Text({
+ text: contacts.length > 1 ? `#${idx + 1}` : '접촉 중심',
+ font: 'bold 11px sans-serif',
+ fill: new Fill({ color: '#fff' }),
+ stroke: new Stroke({ color: 'rgba(0,0,0,0.8)', width: 3 }),
+ offsetY: -18,
+ }),
+ }));
+ features.push(f);
+
+ if (contact.contactStartTimestamp) {
+ const startLabel = `시작 ${formatTimestamp(contact.contactStartTimestamp)}`;
+ const endLabel = `종료 ${formatTimestamp(contact.contactEndTimestamp)}`;
+ const labelF = new Feature({ geometry: new Point(pos3857) });
+ labelF.setStyle(new Style({
+ text: new Text({
+ text: `${startLabel}\n${endLabel}`,
+ font: '10px sans-serif',
+ fill: new Fill({ color: '#ced4da' }),
+ stroke: new Stroke({ color: 'rgba(0,0,0,0.8)', width: 3 }),
+ offsetY: 24,
+ }),
+ }));
+ features.push(labelF);
+ }
+ });
+
+ return features;
+}
+
+const MODAL_WIDTH = 680;
+const MODAL_APPROX_HEIGHT = 780;
+
+export default function StsContactDetailModal({ groupIndex, onClose }) {
+ const groupedContacts = useStsStore((s) => s.groupedContacts);
+ const tracks = useStsStore((s) => s.tracks);
+ const zones = useAreaSearchStore((s) => s.zones);
+
+ const mapContainerRef = useRef(null);
+ const mapRef = useRef(null);
+ const contentRef = useRef(null);
+
+ const [position, setPosition] = useState(() => ({
+ x: (window.innerWidth - MODAL_WIDTH) / 2,
+ y: Math.max(20, (window.innerHeight - MODAL_APPROX_HEIGHT) / 2),
+ }));
+ const posRef = useRef(position);
+ const dragging = useRef(false);
+ const dragStart = useRef({ x: 0, y: 0 });
+
+ const handleMouseDown = useCallback((e) => {
+ dragging.current = true;
+ dragStart.current = {
+ x: e.clientX - posRef.current.x,
+ y: e.clientY - posRef.current.y,
+ };
+ e.preventDefault();
+ }, []);
+
+ useEffect(() => {
+ const handleMouseMove = (e) => {
+ if (!dragging.current) return;
+ const newPos = {
+ x: e.clientX - dragStart.current.x,
+ y: e.clientY - dragStart.current.y,
+ };
+ posRef.current = newPos;
+ setPosition(newPos);
+ };
+ const handleMouseUp = () => {
+ dragging.current = false;
+ };
+ window.addEventListener('mousemove', handleMouseMove);
+ window.addEventListener('mouseup', handleMouseUp);
+ return () => {
+ window.removeEventListener('mousemove', handleMouseMove);
+ window.removeEventListener('mouseup', handleMouseUp);
+ };
+ }, []);
+
+ const group = useMemo(() => groupedContacts[groupIndex], [groupedContacts, groupIndex]);
+ const vessel1Track = useMemo(
+ () => tracks.find((t) => t.vesselId === group?.vessel1?.vesselId),
+ [tracks, group],
+ );
+ const vessel2Track = useMemo(
+ () => tracks.find((t) => t.vesselId === group?.vessel2?.vesselId),
+ [tracks, group],
+ );
+
+ // OL 지도 초기화
+ useEffect(() => {
+ if (!mapContainerRef.current || !group || !vessel1Track || !vessel2Track) return;
+
+ const tileSource = new XYZ({
+ url: mapLayerConfig.darkLayer.source.getUrls()[0],
+ minZoom: 6,
+ maxZoom: 11,
+ });
+ const tileLayer = new TileLayer({ source: tileSource, preload: Infinity });
+
+ const zoneSource = new VectorSource({ features: createZoneFeatures(zones) });
+ const zoneLayer = new VectorLayer({ source: zoneSource });
+
+ const trackSource = new VectorSource({
+ features: [createTrackFeature(vessel1Track), createTrackFeature(vessel2Track)],
+ });
+ const trackLayer = new VectorLayer({ source: trackSource });
+
+ const markerFeatures = createContactMarkers(group.contacts);
+ const markerSource = new VectorSource({ features: markerFeatures });
+ const markerLayer = new VectorLayer({ source: markerSource });
+
+ const map = new Map({
+ target: mapContainerRef.current,
+ layers: [tileLayer, zoneLayer, trackLayer, markerLayer],
+ view: new View({ center: [0, 0], zoom: 7 }),
+ controls: defaultControls({ attribution: false, zoom: false, rotate: false })
+ .extend([new ScaleLine({ units: 'nautical' })]),
+ interactions: defaultInteractions({ doubleClickZoom: false }),
+ });
+
+ const allSource = new VectorSource();
+ [...zoneSource.getFeatures(), ...trackSource.getFeatures()].forEach((f) => allSource.addFeature(f.clone()));
+ const extent = allSource.getExtent();
+ if (extent && extent[0] !== Infinity) {
+ map.getView().fit(extent, { padding: [50, 50, 50, 50], maxZoom: 14 });
+ }
+
+ mapRef.current = map;
+
+ return () => {
+ map.setTarget(null);
+ map.dispose();
+ mapRef.current = null;
+ };
+ }, [group, vessel1Track, vessel2Track, zones]);
+
+ const handleSaveImage = useCallback(async () => {
+ const el = contentRef.current;
+ if (!el) return;
+
+ const modal = el.parentElement;
+ const saved = {
+ elOverflow: el.style.overflow,
+ modalMaxHeight: modal.style.maxHeight,
+ modalOverflow: modal.style.overflow,
+ };
+
+ el.style.overflow = 'visible';
+ modal.style.maxHeight = 'none';
+ modal.style.overflow = 'visible';
+
+ try {
+ const canvas = await html2canvas(el, {
+ backgroundColor: '#141820',
+ useCORS: true,
+ scale: 2,
+ });
+ canvas.toBlob((blob) => {
+ if (!blob) return;
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ const pad = (n) => String(n).padStart(2, '0');
+ const now = new Date();
+ const v1Name = group?.vessel1?.vesselName || 'V1';
+ const v2Name = group?.vessel2?.vesselName || 'V2';
+ link.href = url;
+ link.download = `STS분석_${v1Name}_${v2Name}_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}.png`;
+ link.click();
+ URL.revokeObjectURL(url);
+ }, 'image/png');
+ } catch (err) {
+ console.error('[StsContactDetailModal] 이미지 저장 실패:', err);
+ } finally {
+ el.style.overflow = saved.elOverflow;
+ modal.style.maxHeight = saved.modalMaxHeight;
+ modal.style.overflow = saved.modalOverflow;
+ }
+ }, [group]);
+
+ if (!group || !vessel1Track || !vessel2Track) return null;
+
+ const { vessel1, vessel2, indicators } = group;
+ const riskColor = getContactRiskColor(indicators);
+ const primaryContact = group.contacts[0];
+ const lastContact = group.contacts[group.contacts.length - 1];
+
+ const activeIndicators = Object.entries(indicators || {})
+ .filter(([, val]) => val)
+ .map(([key]) => ({ key, detail: getIndicatorDetail(key, primaryContact) }));
+
+ return createPortal(
+
+
e.stopPropagation()}
+ >
+ {/* 헤더 */}
+
+
+ {/* 콘텐츠 */}
+
+
+
+
+
+ {/* 접촉 요약 — 그리드 2열 */}
+
+
접촉 요약
+
+
+ 접촉 기간
+ {formatTimestamp(primaryContact.contactStartTimestamp)} ~ {formatTimestamp(lastContact.contactEndTimestamp)}
+
+
+ 총 접촉 시간
+ {formatDuration(group.totalDurationMinutes)}
+
+
+ 평균 거리
+ {formatDistance(group.avgDistanceMeters)}
+
+ {group.contacts.length > 1 && (
+
+ 접촉 횟수
+ {group.contacts.length}회
+
+ )}
+
+
+
+ {/* 특이사항 */}
+ {activeIndicators.length > 0 && (
+
+
특이사항
+
+ {activeIndicators.map(({ key, detail }) => (
+
+ {detail}
+
+ ))}
+
+
+ )}
+
+ {/* 접촉 이력 (2개 이상) */}
+ {group.contacts.length > 1 && (
+
+
접촉 이력 ({group.contacts.length}회)
+
+ {group.contacts.map((c, ci) => (
+
+ #{ci + 1}
+ {formatTimestamp(c.contactStartTimestamp)} ~ {formatTimestamp(c.contactEndTimestamp)}
+ |
+ {formatDuration(c.contactDurationMinutes)}
+ |
+ 평균 {formatDistance(c.avgDistanceMeters)}
+
+ ))}
+
+
+ )}
+
+ {/* 거리 통계 — 3열 그리드 */}
+
+
거리 통계
+
+
+ 최소
+ {formatDistance(group.minDistanceMeters)}
+
+
+ 평균
+ {formatDistance(group.avgDistanceMeters)}
+
+
+ 최대
+ {formatDistance(group.maxDistanceMeters)}
+
+
+
+
+ 측정
+ {group.totalContactPointCount} 포인트
+
+ {group.contactCenterPoint && (
+
+ 중심 좌표
+ {formatPosition(group.contactCenterPoint)}
+
+ )}
+
+
+
+ {/* 선박 상세 — 2열 그리드 */}
+
+
+
+
+
+
+
+
+
,
+ document.body,
+ );
+}
+
+function VesselBadge({ vessel, track }) {
+ const kindName = getShipKindName(track.shipKindCode);
+ const flagUrl = getNationalFlagUrl(vessel.nationalCode);
+ return (
+
+ {kindName}
+ {flagUrl && (
+
{ e.target.style.display = 'none'; }}
+ />
+ )}
+
+ {vessel.vesselName || vessel.vesselId || '-'}
+
+
+ );
+}
+
+function VesselDetailSection({ label, vessel, track }) {
+ const kindName = getShipKindName(track.shipKindCode);
+ const sourceName = getSignalSourceName(track.sigSrcCd);
+ const color = getShipKindColor(track.shipKindCode);
+
+ return (
+
+
+
+ {label} — {vessel.vesselName || vessel.vesselId}
+
+
+
+ 선종
+ {kindName}
+
+
+ 신호원
+ {sourceName}
+
+
+ 구역 체류
+ {formatDuration(vessel.insidePolygonDurationMinutes)}
+
+
+ 평균 속력
+ {vessel.estimatedAvgSpeedKnots?.toFixed(1) ?? '-'} kn
+
+
+ 진입 시각
+ {formatTimestamp(vessel.insidePolygonStartTs)}
+
+
+ 퇴출 시각
+ {formatTimestamp(vessel.insidePolygonEndTs)}
+
+
+
+ );
+}
diff --git a/src/areaSearch/components/StsContactDetailModal.scss b/src/areaSearch/components/StsContactDetailModal.scss
new file mode 100644
index 00000000..d4235795
--- /dev/null
+++ b/src/areaSearch/components/StsContactDetailModal.scss
@@ -0,0 +1,319 @@
+.sts-detail-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 300;
+ background: rgba(0, 0, 0, 0.6);
+}
+
+.sts-detail-modal {
+ position: fixed;
+ z-index: 301;
+ width: 680px;
+ max-height: 90vh;
+ display: flex;
+ flex-direction: column;
+ background: rgba(20, 24, 32, 0.98);
+ border-radius: 8px;
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
+ color: #fff;
+ overflow: hidden;
+
+ &__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 16px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+ flex-shrink: 0;
+ cursor: move;
+ user-select: none;
+ }
+
+ &__title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ flex: 1;
+ }
+
+ &__arrow {
+ color: #4a9eff;
+ font-weight: 700;
+ font-size: 14px;
+ flex-shrink: 0;
+ }
+
+ &__vessel-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ min-width: 0;
+ }
+
+ &__kind {
+ padding: 2px 6px;
+ background: rgba(255, 255, 255, 0.15);
+ border-radius: 3px;
+ font-size: 10px;
+ color: #adb5bd;
+ flex-shrink: 0;
+ }
+
+ &__flag {
+ width: 18px;
+ height: 13px;
+ object-fit: contain;
+ flex-shrink: 0;
+ }
+
+ &__name {
+ font-weight: 700;
+ font-size: 13px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ &__close {
+ width: 28px;
+ height: 28px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: none;
+ border: none;
+ color: #868e96;
+ font-size: 20px;
+ cursor: pointer;
+ border-radius: 4px;
+ flex-shrink: 0;
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.1);
+ color: #fff;
+ }
+ }
+
+ &__content {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ }
+
+ &__map {
+ width: 100%;
+ height: 480px;
+ flex-shrink: 0;
+ background: #0d1117;
+
+ .ol-scale-line {
+ bottom: 8px;
+ left: 8px;
+ }
+ }
+
+ &__risk-bar {
+ height: 3px;
+ flex-shrink: 0;
+ }
+
+ &__section {
+ padding: 10px 16px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+
+ &:last-child {
+ border-bottom: none;
+ }
+ }
+
+ &__section-title {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ font-weight: 700;
+ color: #ced4da;
+ margin: 0 0 8px 0;
+ }
+
+ &__track-dot {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ }
+
+ &__row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ color: #ced4da;
+ padding: 2px 0;
+ }
+
+ &__label {
+ font-weight: 600;
+ font-size: 11px;
+ color: #868e96;
+ min-width: 60px;
+ flex-shrink: 0;
+ }
+
+ &__pos {
+ color: #74b9ff;
+ font-size: 11px;
+ }
+
+ // ========== 그리드 레이아웃 ==========
+
+ &__summary-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 6px;
+ }
+
+ &__stats-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 6px;
+ }
+
+ &__stat-item {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 6px 8px;
+ background: rgba(255, 255, 255, 0.04);
+ border-radius: 4px;
+
+ .stat-label {
+ font-size: 10px;
+ font-weight: 600;
+ color: #868e96;
+ }
+
+ .stat-value {
+ font-size: 12px;
+ color: #ced4da;
+ }
+ }
+
+ &__vessel-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 6px;
+ }
+
+ &__vessel-grid-item {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 6px 8px;
+ background: rgba(255, 255, 255, 0.04);
+ border-radius: 4px;
+
+ .vessel-item-label {
+ font-size: 10px;
+ font-weight: 600;
+ color: #868e96;
+ }
+
+ .vessel-item-value {
+ font-size: 12px;
+ color: #ced4da;
+ }
+ }
+
+ // ========== 접촉 이력 리스트 ==========
+
+ &__contact-list {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ }
+
+ &__contact-item {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 8px;
+ font-size: 11px;
+ color: #ced4da;
+ background: rgba(255, 255, 255, 0.03);
+ border-radius: 3px;
+ }
+
+ &__contact-num {
+ font-weight: 700;
+ color: #4a9eff;
+ min-width: 20px;
+ }
+
+ &__contact-sep {
+ color: #495057;
+ font-size: 10px;
+ }
+
+ &__indicators {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ }
+
+ &__badge {
+ display: inline-block;
+ padding: 3px 8px;
+ border-radius: 3px;
+ font-size: 11px;
+ font-weight: 600;
+
+ &--lowSpeedContact {
+ background: rgba(46, 204, 113, 0.15);
+ color: #2ecc71;
+ }
+
+ &--differentVesselTypes {
+ background: rgba(243, 156, 18, 0.15);
+ color: #f39c12;
+ }
+
+ &--differentNationalities {
+ background: rgba(52, 152, 219, 0.15);
+ color: #3498db;
+ }
+
+ &--nightTimeContact {
+ background: rgba(155, 89, 182, 0.15);
+ color: #9b59b6;
+ }
+ }
+
+ &__footer {
+ display: flex;
+ justify-content: flex-end;
+ padding: 10px 16px;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+ flex-shrink: 0;
+ }
+
+ &__save-btn {
+ padding: 6px 16px;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 4px;
+ background: transparent;
+ color: #ced4da;
+ font-size: 12px;
+ cursor: pointer;
+ transition: all 0.15s;
+
+ &:hover {
+ border-color: #4a9eff;
+ color: #fff;
+ }
+ }
+}
diff --git a/src/areaSearch/components/StsContactList.jsx b/src/areaSearch/components/StsContactList.jsx
new file mode 100644
index 00000000..f4e532f2
--- /dev/null
+++ b/src/areaSearch/components/StsContactList.jsx
@@ -0,0 +1,261 @@
+/**
+ * STS 접촉 쌍 결과 리스트 (그룹 기반)
+ *
+ * - 동일 선박 쌍의 여러 접촉을 하나의 카드로 그룹핑
+ * - 카드 클릭 → on/off 토글
+ * - ▼/▲ 버튼 → 하단 정보 확장
+ * - ▶ 버튼 → 모달 팝업
+ * - 호버 → 지도 하이라이트
+ */
+import { useCallback, useEffect, useRef } from 'react';
+import './StsContactList.scss';
+import { useStsStore } from '../stores/stsStore';
+import { getShipKindName } from '../../tracking/types/trackQuery.types';
+import {
+ getIndicatorDetail,
+ formatDistance,
+ formatDuration,
+ getContactRiskColor,
+} from '../types/sts.types';
+import { formatTimestamp, formatPosition } from './AreaSearchTooltip';
+
+function getNationalFlagUrl(nationalCode) {
+ if (!nationalCode) return null;
+ return `/ship/image/small/${nationalCode}.svg`;
+}
+
+function GroupCard({ group, index, onDetailClick }) {
+ const highlightedGroupIndex = useStsStore((s) => s.highlightedGroupIndex);
+ const expandedGroupIndex = useStsStore((s) => s.expandedGroupIndex);
+ const disabledGroupIndices = useStsStore((s) => s.disabledGroupIndices);
+
+ const isHighlighted = highlightedGroupIndex === index;
+ const isExpanded = expandedGroupIndex === index;
+ const isDisabled = disabledGroupIndices.has(index);
+ const riskColor = getContactRiskColor(group.indicators);
+
+ const handleMouseEnter = useCallback(() => {
+ useStsStore.getState().setHighlightedGroupIndex(index);
+ }, [index]);
+
+ const handleMouseLeave = useCallback(() => {
+ useStsStore.getState().setHighlightedGroupIndex(null);
+ }, []);
+
+ // 카드 클릭 → on/off 토글
+ const handleClick = useCallback(() => {
+ useStsStore.getState().toggleGroupEnabled(index);
+ }, [index]);
+
+ // ▼/▲ 버튼 → 하단 정보 확장
+ const handleExpand = useCallback((e) => {
+ e.stopPropagation();
+ useStsStore.getState().setExpandedGroupIndex(index);
+ }, [index]);
+
+ // ▶ 버튼 → 모달 열기
+ const handleDetail = useCallback((e) => {
+ e.stopPropagation();
+ onDetailClick?.(index);
+ }, [index, onDetailClick]);
+
+ const { vessel1, vessel2, indicators } = group;
+ const v1Kind = getShipKindName(vessel1.shipKindCode);
+ const v2Kind = getShipKindName(vessel2.shipKindCode);
+ const v1Flag = getNationalFlagUrl(vessel1.nationalCode);
+ const v2Flag = getNationalFlagUrl(vessel2.nationalCode);
+
+ const activeIndicators = Object.entries(indicators || {})
+ .filter(([, val]) => val)
+ .map(([key]) => ({
+ key,
+ detail: getIndicatorDetail(key, group.contacts[0]),
+ }));
+
+ // 시간 범위: 첫 접촉 시작 ~ 마지막 접촉 종료
+ const firstContact = group.contacts[0];
+ const lastContact = group.contacts[group.contacts.length - 1];
+
+ return (
+
+
+
+
+ {/* vessel1 */}
+
+
{v1Kind}
+ {v1Flag && (
+

{ e.target.style.display = 'none'; }}
+ />
+ )}
+
{vessel1.vesselName || vessel1.vesselId}
+
+
+ {/* 접촉 요약 (그룹 합산) */}
+
+ ↕
+ {formatDuration(group.totalDurationMinutes)}
+ |
+ 평균 {formatDistance(group.avgDistanceMeters)}
+ {group.contacts.length > 1 && (
+ {group.contacts.length}회
+ )}
+
+
+ {/* vessel2 + 버튼들 */}
+
+
{v2Kind}
+ {v2Flag && (
+

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