diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 3b8cdb2..397ff13 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -13,6 +13,7 @@
"@deck.gl/layers": "^9.2.8",
"@deck.gl/mapbox": "^9.2.8",
"@stomp/stompjs": "^7.3.0",
+ "html2canvas": "^1.4.1",
"maplibre-gl": "^5.18.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -3061,6 +3062,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/base64-arraybuffer": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
+ "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -3312,6 +3322,15 @@
"node": "*"
}
},
+ "node_modules/css-line-break": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
+ "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
+ "license": "MIT",
+ "dependencies": {
+ "utrie": "^1.0.2"
+ }
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -4027,6 +4046,19 @@
"hermes-estree": "0.25.1"
}
},
+ "node_modules/html2canvas": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
+ "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
+ "license": "MIT",
+ "dependencies": {
+ "css-line-break": "^2.1.0",
+ "text-segmentation": "^1.0.3"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -5385,6 +5417,15 @@
"terra-draw": "^1.0.0"
}
},
+ "node_modules/text-segmentation": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
+ "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
+ "license": "MIT",
+ "dependencies": {
+ "utrie": "^1.0.2"
+ }
+ },
"node_modules/texture-compressor": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/texture-compressor/-/texture-compressor-1.0.2.tgz",
@@ -5553,6 +5594,15 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
+ "node_modules/utrie": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
+ "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-arraybuffer": "^1.0.2"
+ }
+ },
"node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index a59cd2d..2b56c23 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -15,6 +15,7 @@
"@deck.gl/layers": "^9.2.8",
"@deck.gl/mapbox": "^9.2.8",
"@stomp/stompjs": "^7.3.0",
+ "html2canvas": "^1.4.1",
"maplibre-gl": "^5.18.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
diff --git a/frontend/src/features/area-search/components/AreaSearchPanel.tsx b/frontend/src/features/area-search/components/AreaSearchPanel.tsx
index 1b06c45..f431e5f 100644
--- a/frontend/src/features/area-search/components/AreaSearchPanel.tsx
+++ b/frontend/src/features/area-search/components/AreaSearchPanel.tsx
@@ -99,20 +99,25 @@ export default function AreaSearchPanel({ map, mode }: AreaSearchPanelProps) {
{/* 시간 범위 */}
-
diff --git a/frontend/src/features/area-search/components/AreaSearchTab.tsx b/frontend/src/features/area-search/components/AreaSearchTab.tsx
index c839410..57dd23f 100644
--- a/frontend/src/features/area-search/components/AreaSearchTab.tsx
+++ b/frontend/src/features/area-search/components/AreaSearchTab.tsx
@@ -1,9 +1,10 @@
-import { useCallback } from 'react'
+import { useCallback, useState } from 'react'
import { useAreaSearchStore } from '../stores/areaSearchStore'
import { SEARCH_MODE_OPTIONS } from '../types/areaSearch.types'
import { getShipKindLabel } from '../../vessel-map'
import { exportAreaSearchCSV } from '../utils/csvExport'
import type { SearchMode } from '../types/areaSearch.types'
+import VesselDetailModal from './VesselDetailModal'
export default function AreaSearchTab() {
const searchMode = useAreaSearchStore((s) => s.searchMode)
@@ -17,6 +18,8 @@ export default function AreaSearchTab() {
const toggleVesselEnabled = useAreaSearchStore((s) => s.toggleVesselEnabled)
const setHighlightedVesselId = useAreaSearchStore((s) => s.setHighlightedVesselId)
+ const [detailVesselId, setDetailVesselId] = useState
(null)
+
const handleModeChange = useCallback(
(mode: SearchMode) => setSearchMode(mode),
[setSearchMode],
@@ -79,7 +82,7 @@ export default function AreaSearchTab() {
CSV 내보내기
-
+
{tracks.map((track) => {
const isDisabled = disabledVesselIds.has(track.vesselId)
const isHighlighted = highlightedVesselId === track.vesselId
@@ -102,10 +105,16 @@ export default function AreaSearchTab() {
className="accent-primary"
/>
{track.vesselId}
- {track.shipName || '-'}
- {getShipKindLabel(track.shipKindCode)}
+ {track.shipName || '-'}
+ {getShipKindLabel(track.shipKindCode)}
{hits.length > 0 && (
- {hits.length}회
+
)}
)
@@ -113,6 +122,11 @@ export default function AreaSearchTab() {
)}
+
+ {/* 선박 상세 모달 */}
+ {detailVesselId && (
+ setDetailVesselId(null)} />
+ )}
)
}
diff --git a/frontend/src/features/area-search/components/StsContactDetailModal.tsx b/frontend/src/features/area-search/components/StsContactDetailModal.tsx
new file mode 100644
index 0000000..b326fd0
--- /dev/null
+++ b/frontend/src/features/area-search/components/StsContactDetailModal.tsx
@@ -0,0 +1,350 @@
+/**
+ * STS 접촉 쌍 상세 모달 — 임베디드 MapLibre 지도 + 그리드 레이아웃 + 이미지 저장
+ */
+import { useEffect, useRef, useCallback, useMemo, useState } from 'react'
+import { createPortal } from 'react-dom'
+import maplibregl from 'maplibre-gl'
+import type { FeatureCollection, Feature, Polygon, LineString, Point } from 'geojson'
+
+import { useStsStore } from '../stores/stsStore'
+import { useAreaSearchStore } from '../stores/areaSearchStore'
+import { ZONE_COLORS } from '../types/areaSearch.types'
+import type { Zone } from '../types/areaSearch.types'
+import type { TrackSegment } from '../../vessel-map/types'
+import type { StsVessel, StsContact } from '../types/sts.types'
+import { getContactRiskColor, getIndicatorDetail } from '../types/sts.types'
+import { getTrackColor, getShipKindLabel } from '../../vessel-map'
+import { formatTimestamp, formatPosition, formatDuration, formatDistance } from '../utils/formatUtils'
+import { captureAndDownload, buildTimestampedFilename } from '../utils/captureUtils'
+import { MAP_STYLE_DARK, MAP_STYLE_LIGHT } from '../../../utils/constants'
+import { useTheme } from '../../../hooks/useTheme'
+
+/** Zone → GeoJSON */
+function buildZoneGeoJSON(zones: Zone[]): FeatureCollection {
+ return {
+ type: 'FeatureCollection',
+ features: zones.map((zone) => {
+ const coords = [...zone.coordinates]
+ const first = coords[0]
+ const last = coords[coords.length - 1]
+ if (first[0] !== last[0] || first[1] !== last[1]) coords.push([first[0], first[1]])
+ const color = ZONE_COLORS[zone.colorIndex] || ZONE_COLORS[0]
+ return {
+ type: 'Feature' as const,
+ geometry: { type: 'Polygon' as const, coordinates: [coords] },
+ properties: {
+ fillColor: `rgba(${color.fill.join(',')})`,
+ outlineColor: `rgba(${color.stroke.join(',')})`,
+ labelText: `${zone.name}구역`,
+ labelColor: color.label,
+ },
+ }
+ }),
+ }
+}
+
+/** 2개 항적 → GeoJSON */
+function buildTrackGeoJSON(tracks: TrackSegment[]): FeatureCollection {
+ return {
+ type: 'FeatureCollection',
+ features: tracks.map((track) => {
+ const rgba = getTrackColor(track.shipKindCode)
+ return {
+ type: 'Feature' as const,
+ geometry: { type: 'LineString' as const, coordinates: track.geometry },
+ properties: { color: `rgba(${rgba[0]},${rgba[1]},${rgba[2]},0.8)` },
+ }
+ }),
+ }
+}
+
+/** 접촉 중심 → GeoJSON */
+function buildContactGeoJSON(contacts: StsContact[]): FeatureCollection {
+ const features: Feature[] = []
+ contacts.forEach((contact, idx) => {
+ if (!contact.contactCenterPoint) return
+ const riskColor = getContactRiskColor(contact.indicators ?? undefined)
+ const labelText = contacts.length > 1 ? `#${idx + 1}` : '접촉 중심'
+ const startLabel = contact.contactStartTimestamp ? `시작 ${formatTimestamp(contact.contactStartTimestamp)}` : ''
+ const endLabel = contact.contactEndTimestamp ? `종료 ${formatTimestamp(contact.contactEndTimestamp)}` : ''
+ const timeLabel = startLabel && endLabel ? `${startLabel}\n${endLabel}` : ''
+
+ features.push({
+ type: 'Feature',
+ geometry: { type: 'Point', coordinates: contact.contactCenterPoint },
+ properties: {
+ riskColor: `rgba(${riskColor[0]},${riskColor[1]},${riskColor[2]},0.6)`,
+ labelText,
+ timeLabel,
+ },
+ })
+ })
+ return { type: 'FeatureCollection', features }
+}
+
+const MODAL_WIDTH = 680
+
+interface StsContactDetailModalProps {
+ groupIndex: number
+ onClose: () => void
+}
+
+export default function StsContactDetailModal({ groupIndex, onClose }: StsContactDetailModalProps) {
+ const { theme } = useTheme()
+ const isDark = theme === 'dark'
+
+ 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 - 780) / 2),
+ }))
+ const posRef = useRef(position)
+ const dragging = useRef(false)
+ const dragStart = useRef({ x: 0, y: 0 })
+
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
+ dragging.current = true
+ dragStart.current = { x: e.clientX - posRef.current.x, y: e.clientY - posRef.current.y }
+ e.preventDefault()
+ }, [])
+
+ useEffect(() => {
+ const onMove = (e: MouseEvent) => {
+ if (!dragging.current) return
+ const newPos = { x: e.clientX - dragStart.current.x, y: e.clientY - dragStart.current.y }
+ posRef.current = newPos
+ setPosition(newPos)
+ }
+ const onUp = () => { dragging.current = false }
+ window.addEventListener('mousemove', onMove)
+ window.addEventListener('mouseup', onUp)
+ return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp) }
+ }, [])
+
+ 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])
+
+ // MapLibre
+ useEffect(() => {
+ if (!mapContainerRef.current || !group || !vessel1Track || !vessel2Track) return
+ if (mapRef.current) { mapRef.current.remove(); mapRef.current = null }
+
+ const mlMap = new maplibregl.Map({
+ container: mapContainerRef.current,
+ style: isDark ? MAP_STYLE_DARK : MAP_STYLE_LIGHT,
+ center: [0, 0],
+ zoom: 6,
+ dragPan: false,
+ scrollZoom: false,
+ doubleClickZoom: false,
+ attributionControl: false,
+ })
+
+ mlMap.addControl(new maplibregl.ScaleControl({ unit: 'nautical' }), 'bottom-right')
+
+ mlMap.on('load', () => {
+ const halo = isDark ? 'rgba(0,0,0,0.7)' : 'rgba(255,255,255,0.9)'
+
+ // Zones
+ mlMap.addSource('zone-source', { type: 'geojson', data: buildZoneGeoJSON(zones) })
+ mlMap.addLayer({ id: 'zone-fill', type: 'fill', source: 'zone-source', paint: { 'fill-color': ['get', 'fillColor'], 'fill-opacity': 1 } })
+ mlMap.addLayer({ id: 'zone-line', type: 'line', source: 'zone-source', paint: { 'line-color': ['get', 'outlineColor'], 'line-width': 2 } })
+ mlMap.addLayer({ id: 'zone-label', type: 'symbol', source: 'zone-source', layout: { 'text-field': ['get', 'labelText'], 'text-size': 12, 'text-font': ['Open Sans Bold'], 'text-allow-overlap': true }, paint: { 'text-color': ['get', 'labelColor'], 'text-halo-color': halo, 'text-halo-width': 2 } })
+
+ // Tracks (2)
+ mlMap.addSource('track-source', { type: 'geojson', data: buildTrackGeoJSON([vessel1Track, vessel2Track]) })
+ mlMap.addLayer({ id: 'track-line', type: 'line', source: 'track-source', paint: { 'line-color': ['get', 'color'], 'line-width': 2, 'line-opacity': 0.8 } })
+
+ // Contact centers
+ mlMap.addSource('contact-source', { type: 'geojson', data: buildContactGeoJSON(group.contacts) })
+ mlMap.addLayer({ id: 'contact-circle', type: 'circle', source: 'contact-source', paint: { 'circle-radius': 10, 'circle-color': ['get', 'riskColor'], 'circle-stroke-color': '#fff', 'circle-stroke-width': 2 } })
+ mlMap.addLayer({ id: 'contact-label', type: 'symbol', source: 'contact-source', layout: { 'text-field': ['get', 'labelText'], 'text-size': 11, 'text-font': ['Open Sans Bold'], 'text-offset': [0, -1.8], 'text-allow-overlap': true }, paint: { 'text-color': isDark ? '#fff' : '#333', 'text-halo-color': halo, 'text-halo-width': 3 } })
+ mlMap.addLayer({ id: 'contact-time', type: 'symbol', source: 'contact-source', layout: { 'text-field': ['get', 'timeLabel'], 'text-size': 10, 'text-font': ['Open Sans Regular'], 'text-offset': [0, 2.4], 'text-allow-overlap': false }, paint: { 'text-color': isDark ? '#ced4da' : '#666', 'text-halo-color': halo, 'text-halo-width': 3 } })
+
+ // fitBounds
+ const bounds = new maplibregl.LngLatBounds()
+ zones.forEach((z) => z.coordinates.forEach((c) => bounds.extend([c[0], c[1]])))
+ vessel1Track.geometry.forEach((c) => bounds.extend([c[0], c[1]]))
+ vessel2Track.geometry.forEach((c) => bounds.extend([c[0], c[1]]))
+ group.contacts.forEach((c) => { if (c.contactCenterPoint) bounds.extend([c.contactCenterPoint[0], c.contactCenterPoint[1]]) })
+ mlMap.fitBounds(bounds, { padding: 50, maxZoom: 14 })
+ })
+
+ mapRef.current = mlMap
+ return () => { if (mapRef.current) { mapRef.current.remove(); mapRef.current = null } }
+ }, [group, vessel1Track, vessel2Track, zones, isDark])
+
+ const handleSaveImage = useCallback(async () => {
+ if (!contentRef.current || !group) return
+ const v1Name = group.vessel1?.vesselName || 'V1'
+ const v2Name = group.vessel2?.vesselName || 'V2'
+ const filename = buildTimestampedFilename('STS분석', v1Name, v2Name)
+ await captureAndDownload(contentRef.current, filename, isDark)
+ }, [group, isDark])
+
+ 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()}
+ >
+ {/* 헤더 */}
+
+
+ {/* 콘텐츠 */}
+
+
+
+ {/* 위험도 색상 바 */}
+
+
+ {/* 접촉 요약 */}
+
+
+
+
+
+ {group.contacts.length > 1 && }
+
+
+
+ {/* 특이사항 */}
+ {activeIndicators.length > 0 && (
+
+
+ {activeIndicators.map(({ key, detail }) => (
+
+ {detail}
+
+ ))}
+
+
+ )}
+
+ {/* 접촉 이력 (2회 이상) */}
+ {group.contacts.length > 1 && (
+
+
+ {group.contacts.map((c, ci) => (
+
+ #{ci + 1}
+ {formatTimestamp(c.contactStartTimestamp)} ~ {formatTimestamp(c.contactEndTimestamp)}
+ |
+ {formatDuration(c.contactDurationMinutes)}
+ |
+ 평균 {formatDistance(c.avgDistanceMeters)}
+
+ ))}
+
+
+ )}
+
+ {/* 거리 통계 */}
+
+
+
+
+
+
+
+
+ {group.contactCenterPoint && (
+
+
+
+ )}
+
+
+
+ {/* 선박 상세 */}
+
+
+
+
+ {/* 하단 버튼 */}
+
+
+
+
+
,
+ document.body,
+ )
+}
+
+/** 섹션 래퍼 */
+function Section({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+
+
{title}
+ {children}
+
+ )
+}
+
+/** 통계 아이템 */
+function StatItem({ label, value }: { label: string; value: string }) {
+ return (
+
+ )
+}
+
+/** 헤더 선박 뱃지 */
+function VesselBadge({ vessel, track }: { vessel: StsVessel; track: TrackSegment }) {
+ return (
+
+
+ {getShipKindLabel(track.shipKindCode)}
+
+ {vessel.vesselName || vessel.vesselId || '-'}
+
+ )
+}
+
+/** 선박 상세 섹션 */
+function VesselDetailSection({ label, vessel, track }: { label: string; vessel: StsVessel; track: TrackSegment }) {
+ const rgba = getTrackColor(track.shipKindCode)
+ return (
+
+
+
+ {getShipKindLabel(track.shipKindCode)}
+
+
+
구역 체류 {formatDuration(vessel.insidePolygonDurationMinutes)}
+
평균 속력 {vessel.estimatedAvgSpeedKnots?.toFixed(1) ?? '-'} kn
+
진입 {formatTimestamp(vessel.insidePolygonStartTs)}
+
퇴출 {formatTimestamp(vessel.insidePolygonEndTs)}
+
+
+ )
+}
diff --git a/frontend/src/features/area-search/components/StsContactList.tsx b/frontend/src/features/area-search/components/StsContactList.tsx
index 78137d9..0145103 100644
--- a/frontend/src/features/area-search/components/StsContactList.tsx
+++ b/frontend/src/features/area-search/components/StsContactList.tsx
@@ -1,8 +1,9 @@
-import { useRef, useEffect } from 'react'
+import { useRef, useEffect, useState } from 'react'
import { useStsStore } from '../stores/stsStore'
import { getContactRiskColor, INDICATOR_LABELS } from '../types/sts.types'
import { getShipKindLabel } from '../../vessel-map'
import type { StsGroupedContact } from '../types/sts.types'
+import StsContactDetailModal from './StsContactDetailModal'
function formatDuration(minutes: number): string {
if (minutes < 60) return `${minutes}분`
@@ -23,7 +24,7 @@ function formatTimestamp(ms: number | null): string {
return `${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
-function GroupCard({ group, index }: { group: StsGroupedContact; index: number }) {
+function GroupCard({ group, index, onDetail }: { group: StsGroupedContact; index: number; onDetail: (idx: number) => void }) {
const highlightedGroupIndex = useStsStore((s) => s.highlightedGroupIndex)
const disabledGroupIndices = useStsStore((s) => s.disabledGroupIndices)
const expandedGroupIndex = useStsStore((s) => s.expandedGroupIndex)
@@ -84,7 +85,7 @@ function GroupCard({ group, index }: { group: StsGroupedContact; index: number }
)}
- {/* 토글 + 체크 */}
+ {/* 토글 + 체크 + 보고서 */}
{isExpanded ? '접기 ▲' : '상세 ▼'}
+
{/* 확장 상세 */}
@@ -128,6 +135,7 @@ export default function StsContactList() {
const groupedContacts = useStsStore((s) => s.groupedContacts)
const highlightedGroupIndex = useStsStore((s) => s.highlightedGroupIndex)
const listRef = useRef(null)
+ const [detailGroupIndex, setDetailGroupIndex] = useState(null)
// 하이라이트된 카드로 스크롤
useEffect(() => {
@@ -142,10 +150,17 @@ export default function StsContactList() {
}
return (
-
- {groupedContacts.map((group, idx) => (
-
- ))}
-
+ <>
+
+ {groupedContacts.map((group, idx) => (
+
+ ))}
+
+
+ {/* STS 상세 모달 */}
+ {detailGroupIndex !== null && (
+ setDetailGroupIndex(null)} />
+ )}
+ >
)
}
diff --git a/frontend/src/features/area-search/components/TimelineControl.tsx b/frontend/src/features/area-search/components/TimelineControl.tsx
index 85509ae..caaa57d 100644
--- a/frontend/src/features/area-search/components/TimelineControl.tsx
+++ b/frontend/src/features/area-search/components/TimelineControl.tsx
@@ -31,7 +31,7 @@ export default function TimelineControl() {
if (startTime === 0 && endTime === 0) return null
return (
-
+
{/* 타임라인 */}
{
+ return {
+ type: 'FeatureCollection',
+ features: zones.map((zone) => {
+ const coords = [...zone.coordinates]
+ const first = coords[0]
+ const last = coords[coords.length - 1]
+ if (first[0] !== last[0] || first[1] !== last[1]) {
+ coords.push([first[0], first[1]])
+ }
+ const color = ZONE_COLORS[zone.colorIndex] || ZONE_COLORS[0]
+ return {
+ type: 'Feature' as const,
+ geometry: { type: 'Polygon' as const, coordinates: [coords] },
+ properties: {
+ fillColor: `rgba(${color.fill.join(',')})`,
+ outlineColor: `rgba(${color.stroke.join(',')})`,
+ labelText: `${zone.name}구역`,
+ labelColor: color.label,
+ },
+ }
+ }),
+ }
+}
+
+/** 항적 → GeoJSON LineString */
+function buildTrackGeoJSON(track: TrackSegment): FeatureCollection
{
+ const rgba = getTrackColor(track.shipKindCode)
+ const colorStr = `rgba(${rgba[0]},${rgba[1]},${rgba[2]},0.8)`
+ return {
+ type: 'FeatureCollection',
+ features: [{
+ type: 'Feature' as const,
+ geometry: { type: 'LineString' as const, coordinates: track.geometry },
+ properties: { color: colorStr },
+ }],
+ }
+}
+
+/** IN/OUT 마커 GeoJSON */
+function buildMarkerGeoJSON(sortedHits: HitDetail[]): {
+ inPoints: FeatureCollection
+ outPoints: FeatureCollection
+} {
+ const inFeatures: Feature[] = []
+ const outFeatures: Feature[] = []
+
+ sortedHits.forEach((hit, idx) => {
+ const seqNum = idx + 1
+ if (hit.entryPosition) {
+ inFeatures.push({
+ type: 'Feature',
+ geometry: { type: 'Point', coordinates: hit.entryPosition as [number, number] },
+ properties: {
+ label: `${seqNum}-IN ${formatTimestamp(hit.entryTimestamp)}`,
+ seqNum,
+ },
+ })
+ }
+ if (hit.exitPosition) {
+ outFeatures.push({
+ type: 'Feature',
+ geometry: { type: 'Point', coordinates: hit.exitPosition as [number, number] },
+ properties: {
+ label: `${seqNum}-OUT ${formatTimestamp(hit.exitTimestamp)}`,
+ seqNum,
+ },
+ })
+ }
+ })
+
+ return {
+ inPoints: { type: 'FeatureCollection', features: inFeatures },
+ outPoints: { type: 'FeatureCollection', features: outFeatures },
+ }
+}
+
+const MODAL_WIDTH = 680
+
+interface VesselDetailModalProps {
+ vesselId: string
+ onClose: () => void
+}
+
+export default function VesselDetailModal({ vesselId, onClose }: VesselDetailModalProps) {
+ const { theme } = useTheme()
+ const isDark = theme === 'dark'
+
+ 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 - 780) / 2),
+ }))
+ const posRef = useRef(position)
+ const dragging = useRef(false)
+ const dragStart = useRef({ x: 0, y: 0 })
+
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
+ dragging.current = true
+ dragStart.current = { x: e.clientX - posRef.current.x, y: e.clientY - posRef.current.y }
+ e.preventDefault()
+ }, [])
+
+ useEffect(() => {
+ const onMove = (e: MouseEvent) => {
+ if (!dragging.current) return
+ const newPos = { x: e.clientX - dragStart.current.x, y: e.clientY - dragStart.current.y }
+ posRef.current = newPos
+ setPosition(newPos)
+ }
+ const onUp = () => { dragging.current = false }
+ window.addEventListener('mousemove', onMove)
+ window.addEventListener('mouseup', onUp)
+ return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp) }
+ }, [])
+
+ const track = useMemo(() => tracks.find((t) => t.vesselId === vesselId), [tracks, vesselId])
+ const hits: HitDetail[] = useMemo(() => hitDetails[vesselId] || [], [hitDetails, vesselId])
+
+ const zoneMap = useMemo(() => {
+ const lookup: Record = {}
+ zones.forEach((z, idx) => {
+ lookup[z.id] = z
+ lookup[z.name] = z
+ lookup[String(idx)] = z
+ })
+ return lookup
+ }, [zones])
+
+ const sortedHits = useMemo(
+ () => [...hits].sort((a, b) => (a.entryTimestamp ?? 0) - (b.entryTimestamp ?? 0)),
+ [hits],
+ )
+
+ // MapLibre 지도 초기화
+ useEffect(() => {
+ if (!mapContainerRef.current || !track) return
+
+ if (mapRef.current) { mapRef.current.remove(); mapRef.current = null }
+
+ const mlMap = new maplibregl.Map({
+ container: mapContainerRef.current,
+ style: isDark ? MAP_STYLE_DARK : MAP_STYLE_LIGHT,
+ center: [0, 0],
+ zoom: 6,
+ dragPan: false,
+ scrollZoom: false,
+ doubleClickZoom: false,
+ attributionControl: false,
+ })
+
+ mlMap.addControl(new maplibregl.ScaleControl({ unit: 'nautical' }), 'bottom-right')
+
+ mlMap.on('load', () => {
+ // Zone
+ mlMap.addSource('zone-source', { type: 'geojson', data: buildZoneGeoJSON(zones) })
+ mlMap.addLayer({ id: 'zone-fill', type: 'fill', source: 'zone-source', paint: { 'fill-color': ['get', 'fillColor'], 'fill-opacity': 1 } })
+ mlMap.addLayer({ id: 'zone-line', type: 'line', source: 'zone-source', paint: { 'line-color': ['get', 'outlineColor'], 'line-width': 2 } })
+ mlMap.addLayer({ id: 'zone-label', type: 'symbol', source: 'zone-source', layout: { 'text-field': ['get', 'labelText'], 'text-size': 12, 'text-font': ['Open Sans Bold'], 'text-allow-overlap': true }, paint: { 'text-color': ['get', 'labelColor'], 'text-halo-color': isDark ? 'rgba(0,0,0,0.7)' : 'rgba(255,255,255,0.9)', 'text-halo-width': 2 } })
+
+ // Track
+ mlMap.addSource('track-source', { type: 'geojson', data: buildTrackGeoJSON(track) })
+ mlMap.addLayer({ id: 'track-line', type: 'line', source: 'track-source', paint: { 'line-color': ['get', 'color'], 'line-width': 2, 'line-opacity': 0.8 } })
+
+ // IN/OUT markers
+ const { inPoints, outPoints } = buildMarkerGeoJSON(sortedHits)
+ mlMap.addSource('marker-in', { type: 'geojson', data: inPoints })
+ mlMap.addLayer({ id: 'marker-in-circle', type: 'circle', source: 'marker-in', paint: { 'circle-radius': 6, 'circle-color': '#2ecc71', 'circle-stroke-color': '#fff', 'circle-stroke-width': 1.5 } })
+ mlMap.addLayer({ id: 'marker-in-text', type: 'symbol', source: 'marker-in', layout: { 'text-field': ['get', 'label'], 'text-size': 10, 'text-font': ['Open Sans Bold'], 'text-offset': [0.8, -1.3], 'text-anchor': 'left', 'text-allow-overlap': false }, paint: { 'text-color': '#2ecc71', 'text-halo-color': isDark ? 'rgba(0,0,0,0.8)' : 'rgba(255,255,255,0.9)', 'text-halo-width': 2 } })
+
+ mlMap.addSource('marker-out', { type: 'geojson', data: outPoints })
+ mlMap.addLayer({ id: 'marker-out-circle', type: 'circle', source: 'marker-out', paint: { 'circle-radius': 6, 'circle-color': '#e74c3c', 'circle-stroke-color': '#fff', 'circle-stroke-width': 1.5 } })
+ mlMap.addLayer({ id: 'marker-out-text', type: 'symbol', source: 'marker-out', layout: { 'text-field': ['get', 'label'], 'text-size': 10, 'text-font': ['Open Sans Bold'], 'text-offset': [0.8, 1.3], 'text-anchor': 'left', 'text-allow-overlap': false }, paint: { 'text-color': '#e74c3c', 'text-halo-color': isDark ? 'rgba(0,0,0,0.8)' : 'rgba(255,255,255,0.9)', 'text-halo-width': 2 } })
+
+ // fitBounds
+ const bounds = new maplibregl.LngLatBounds()
+ zones.forEach((z) => z.coordinates.forEach((c) => bounds.extend([c[0], c[1]])))
+ track.geometry.forEach((c) => bounds.extend([c[0], c[1]]))
+ sortedHits.forEach((h) => {
+ if (h.entryPosition) bounds.extend([h.entryPosition[0], h.entryPosition[1]])
+ if (h.exitPosition) bounds.extend([h.exitPosition[0], h.exitPosition[1]])
+ })
+ mlMap.fitBounds(bounds, { padding: 50, maxZoom: 14 })
+ })
+
+ mapRef.current = mlMap
+ return () => { if (mapRef.current) { mapRef.current.remove(); mapRef.current = null } }
+ }, [track, zones, sortedHits, isDark])
+
+ const handleSaveImage = useCallback(async () => {
+ if (!contentRef.current) return
+ const name = track?.shipName || track?.vesselId || 'vessel'
+ const filename = buildTimestampedFilename('항적분석', name)
+ await captureAndDownload(contentRef.current, filename, isDark)
+ }, [track, isDark])
+
+ if (!track) return null
+
+ const kindLabel = getShipKindLabel(track.shipKindCode)
+
+ return createPortal(
+
+
e.stopPropagation()}
+ >
+ {/* 헤더 (드래그 핸들) */}
+
+
+ {kindLabel}
+ {track.shipName || '-'}
+ {track.vesselId}
+
+
+
+
+ {/* 콘텐츠 (캡처 영역) */}
+
+ {/* 지도 */}
+
+
+ {/* 방문 이력 */}
+
+
방문 이력 (시간순)
+ {sortedHits.length === 0 && (
+
방문 이력이 없습니다
+ )}
+
+ {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/frontend/src/features/area-search/index.ts b/frontend/src/features/area-search/index.ts
index 30ec6e7..b6141e8 100644
--- a/frontend/src/features/area-search/index.ts
+++ b/frontend/src/features/area-search/index.ts
@@ -3,7 +3,7 @@ export type { Zone, ZoneDrawType, SearchMode, HitDetail, AreaSearchSummary } fro
export { ZONE_COLORS, ZONE_NAMES, MAX_ZONES, SEARCH_MODE_OPTIONS, PLAYBACK_SPEEDS } from './types/areaSearch.types'
export type { StsContact, StsVessel, StsIndicators, StsGroupedContact, StsSummary } from './types/sts.types'
-export { STS_DEFAULTS, STS_LIMITS, INDICATOR_LABELS, getContactRiskColor, groupContactsByPair } from './types/sts.types'
+export { STS_DEFAULTS, STS_LIMITS, INDICATOR_LABELS, getContactRiskColor, getIndicatorDetail, groupContactsByPair } from './types/sts.types'
// Stores
export { useAreaSearchStore } from './stores/areaSearchStore'
@@ -23,6 +23,10 @@ export { useStsLayer } from './hooks/useStsLayer'
// Components
export { default as AreaSearchPanel } from './components/AreaSearchPanel'
export { default as TimelineControl } from './components/TimelineControl'
+export { default as VesselDetailModal } from './components/VesselDetailModal'
+export { default as StsContactDetailModal } from './components/StsContactDetailModal'
// Utils
export { exportAreaSearchCSV } from './utils/csvExport'
+export { formatTimestamp, formatPosition, formatDuration, formatDistance } from './utils/formatUtils'
+export { captureAndDownload, buildTimestampedFilename } from './utils/captureUtils'
diff --git a/frontend/src/features/area-search/types/sts.types.ts b/frontend/src/features/area-search/types/sts.types.ts
index 0a15c7e..dcd2ea8 100644
--- a/frontend/src/features/area-search/types/sts.types.ts
+++ b/frontend/src/features/area-search/types/sts.types.ts
@@ -1,3 +1,5 @@
+import { getShipKindLabel } from '../../vessel-map'
+
/** STS 선박 정보 */
export interface StsVessel {
vesselId: string
@@ -150,3 +152,33 @@ export function groupContactsByPair(contacts: StsContact[]): StsGroupedContact[]
return group
})
}
+
+/** Indicator 상세 텍스트 (맥락 정보 포함) */
+export function getIndicatorDetail(key: string, contact: StsContact): string {
+ 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 = getShipKindLabel(vessel1.shipKindCode)
+ const name2 = getShipKindLabel(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
+ }
+}
diff --git a/frontend/src/features/area-search/utils/captureUtils.ts b/frontend/src/features/area-search/utils/captureUtils.ts
new file mode 100644
index 0000000..c773c34
--- /dev/null
+++ b/frontend/src/features/area-search/utils/captureUtils.ts
@@ -0,0 +1,58 @@
+import html2canvas from 'html2canvas'
+
+/**
+ * DOM 요소를 PNG로 캡처 후 다운로드
+ * @param contentEl 캡처할 콘텐츠 영역 (헤더 제외)
+ * @param filename 다운로드 파일명 (.png 포함)
+ * @param isDark 다크 테마 여부 → 배경색 결정
+ */
+export async function captureAndDownload(
+ contentEl: HTMLElement,
+ filename: string,
+ isDark: boolean,
+): Promise {
+ const modal = contentEl.parentElement as HTMLElement
+ const saved = {
+ elOverflow: contentEl.style.overflow,
+ modalMaxHeight: modal.style.maxHeight,
+ modalOverflow: modal.style.overflow,
+ }
+
+ // 스크롤 영역 포함 전체 캡처를 위해 일시적으로 제약 해제
+ contentEl.style.overflow = 'visible'
+ modal.style.maxHeight = 'none'
+ modal.style.overflow = 'visible'
+
+ try {
+ const canvas = await html2canvas(contentEl, {
+ backgroundColor: isDark ? '#141820' : '#ffffff',
+ useCORS: true,
+ scale: 2,
+ })
+ canvas.toBlob((blob) => {
+ if (!blob) return
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement('a')
+ link.href = url
+ link.download = filename
+ link.click()
+ URL.revokeObjectURL(url)
+ }, 'image/png')
+ } catch (err) {
+ console.error('[captureAndDownload] 이미지 저장 실패:', err)
+ } finally {
+ contentEl.style.overflow = saved.elOverflow
+ modal.style.maxHeight = saved.modalMaxHeight
+ modal.style.overflow = saved.modalOverflow
+ }
+}
+
+/** 현재 시각 기반 파일명 생성 */
+export function buildTimestampedFilename(prefix: string, ...names: string[]): string {
+ const pad = (n: number) => String(n).padStart(2, '0')
+ const now = new Date()
+ const datePart = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
+ const timePart = `${pad(now.getHours())}${pad(now.getMinutes())}`
+ const namesPart = names.filter(Boolean).join('_')
+ return `${prefix}_${namesPart}_${datePart}_${timePart}.png`
+}
diff --git a/frontend/src/features/area-search/utils/formatUtils.ts b/frontend/src/features/area-search/utils/formatUtils.ts
new file mode 100644
index 0000000..a58263b
--- /dev/null
+++ b/frontend/src/features/area-search/utils/formatUtils.ts
@@ -0,0 +1,33 @@
+/** 타임스탬프 포맷 (ms → YYYY-MM-DD HH:mm:ss) */
+export function formatTimestamp(ms: number | null | undefined): string {
+ if (!ms) return '-'
+ const d = new Date(ms)
+ const pad = (n: number) => String(n).padStart(2, '0')
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
+}
+
+/** 좌표 포맷 ([lon, lat] → 34.1234°N 128.5678°E) */
+export function formatPosition(pos: number[] | null | undefined): string | null {
+ 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 function formatDuration(minutes: number | null | undefined): string {
+ 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}시간`
+}
+
+/** 거리 포맷 (m → 읽기 좋은 형태) */
+export function formatDistance(meters: number | null | undefined): string {
+ if (meters == null) return '-'
+ if (meters >= 1000) return `${(meters / 1000).toFixed(1)}km`
+ return `${Math.round(meters)}m`
+}