Merge pull request 'feat: UI 레이아웃 수정 + 구역분석/STS 보고서 모달 + 이미지 저장' (#70) from develop into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 5m10s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 5m10s
This commit is contained in:
커밋
3f55f80d2b
50
frontend/package-lock.json
generated
50
frontend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -99,20 +99,25 @@ export default function AreaSearchPanel({ map, mode }: AreaSearchPanelProps) {
|
||||
{/* 시간 범위 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-muted">조회 기간</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface px-2 py-1.5 text-xs text-foreground"
|
||||
/>
|
||||
<span className="self-center text-xs text-muted">~</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface px-2 py-1.5 text-xs text-foreground"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
<span className="text-[10px] text-muted">시작</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface px-2 py-1.5 text-xs text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] text-muted">종료</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface px-2 py-1.5 text-xs text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -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<string | null>(null)
|
||||
|
||||
const handleModeChange = useCallback(
|
||||
(mode: SearchMode) => setSearchMode(mode),
|
||||
[setSearchMode],
|
||||
@ -79,7 +82,7 @@ export default function AreaSearchTab() {
|
||||
CSV 내보내기
|
||||
</button>
|
||||
</div>
|
||||
<ul className="max-h-48 space-y-0.5 overflow-y-auto">
|
||||
<ul className="max-h-[40vh] space-y-0.5 overflow-y-auto">
|
||||
{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"
|
||||
/>
|
||||
<span className="font-mono text-[10px] text-muted">{track.vesselId}</span>
|
||||
<span className="truncate">{track.shipName || '-'}</span>
|
||||
<span className="text-[10px] text-muted">{getShipKindLabel(track.shipKindCode)}</span>
|
||||
<span className="min-w-0 truncate">{track.shipName || '-'}</span>
|
||||
<span className="shrink-0 text-[10px] text-muted">{getShipKindLabel(track.shipKindCode)}</span>
|
||||
{hits.length > 0 && (
|
||||
<span className="ml-auto text-[10px] text-primary">{hits.length}회</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setDetailVesselId(track.vesselId) }}
|
||||
className="ml-auto shrink-0 rounded px-1 py-0.5 text-[10px] text-primary hover:bg-primary/10"
|
||||
title="보고서"
|
||||
>
|
||||
보고서
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
@ -113,6 +122,11 @@ export default function AreaSearchTab() {
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 선박 상세 모달 */}
|
||||
{detailVesselId && (
|
||||
<VesselDetailModal vesselId={detailVesselId} onClose={() => setDetailVesselId(null)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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<Polygon> {
|
||||
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<LineString> {
|
||||
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<Point> {
|
||||
const features: Feature<Point>[] = []
|
||||
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<HTMLDivElement>(null)
|
||||
const mapRef = useRef<maplibregl.Map | null>(null)
|
||||
const contentRef = useRef<HTMLDivElement>(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(
|
||||
<div className="fixed inset-0 z-50 bg-black/40" onClick={onClose}>
|
||||
<div
|
||||
className="absolute flex max-h-[90vh] flex-col overflow-hidden rounded-lg border border-border bg-surface shadow-xl"
|
||||
style={{ left: position.x, top: position.y, width: MODAL_WIDTH }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex cursor-move items-center justify-between border-b border-border px-4 py-2.5" onMouseDown={handleMouseDown}>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<VesselBadge vessel={vessel1} track={vessel1Track} />
|
||||
<span className="text-muted">↔</span>
|
||||
<VesselBadge vessel={vessel2} track={vessel2Track} />
|
||||
</div>
|
||||
<button onClick={onClose} className="text-lg text-muted hover:text-foreground">×</button>
|
||||
</div>
|
||||
|
||||
{/* 콘텐츠 */}
|
||||
<div className="flex-1 overflow-y-auto" ref={contentRef}>
|
||||
<div ref={mapContainerRef} className="h-[300px] w-full" />
|
||||
|
||||
{/* 위험도 색상 바 */}
|
||||
<div className="h-1" style={{ backgroundColor: `rgba(${riskColor.join(',')})` }} />
|
||||
|
||||
{/* 접촉 요약 */}
|
||||
<Section title="접촉 요약">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<StatItem label="접촉 기간" value={`${formatTimestamp(primaryContact.contactStartTimestamp)} ~ ${formatTimestamp(lastContact.contactEndTimestamp)}`} />
|
||||
<StatItem label="총 접촉 시간" value={formatDuration(group.totalDurationMinutes)} />
|
||||
<StatItem label="평균 거리" value={formatDistance(group.avgDistanceMeters)} />
|
||||
{group.contacts.length > 1 && <StatItem label="접촉 횟수" value={`${group.contacts.length}회`} />}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* 특이사항 */}
|
||||
{activeIndicators.length > 0 && (
|
||||
<Section title="특이사항">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{activeIndicators.map(({ key, detail }) => (
|
||||
<span key={key} className="rounded bg-red-500/10 px-2 py-1 text-[11px] font-medium text-red-600 dark:text-red-400">
|
||||
{detail}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* 접촉 이력 (2회 이상) */}
|
||||
{group.contacts.length > 1 && (
|
||||
<Section title={`접촉 이력 (${group.contacts.length}회)`}>
|
||||
<div className="space-y-1">
|
||||
{group.contacts.map((c, ci) => (
|
||||
<div key={ci} className="flex items-center gap-2 text-[11px] text-foreground/80">
|
||||
<span className="font-mono font-bold text-primary">#{ci + 1}</span>
|
||||
<span>{formatTimestamp(c.contactStartTimestamp)} ~ {formatTimestamp(c.contactEndTimestamp)}</span>
|
||||
<span className="text-muted">|</span>
|
||||
<span>{formatDuration(c.contactDurationMinutes)}</span>
|
||||
<span className="text-muted">|</span>
|
||||
<span>평균 {formatDistance(c.avgDistanceMeters)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* 거리 통계 */}
|
||||
<Section title="거리 통계">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<StatItem label="최소" value={formatDistance(group.minDistanceMeters)} />
|
||||
<StatItem label="평균" value={formatDistance(group.avgDistanceMeters)} />
|
||||
<StatItem label="최대" value={formatDistance(group.maxDistanceMeters)} />
|
||||
</div>
|
||||
<div className="mt-1 grid grid-cols-3 gap-2">
|
||||
<StatItem label="측정" value={`${group.totalContactPointCount} 포인트`} />
|
||||
{group.contactCenterPoint && (
|
||||
<div className="col-span-2">
|
||||
<StatItem label="중심 좌표" value={formatPosition(group.contactCenterPoint) || '-'} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* 선박 상세 */}
|
||||
<VesselDetailSection label="선박 1" vessel={vessel1} track={vessel1Track} />
|
||||
<VesselDetailSection label="선박 2" vessel={vessel2} track={vessel2Track} />
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="flex justify-end border-t border-border px-4 py-2">
|
||||
<button onClick={handleSaveImage} className="rounded-md bg-primary px-4 py-1.5 text-xs font-medium text-white hover:bg-primary/90">
|
||||
이미지 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
/** 섹션 래퍼 */
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="border-b border-border/50 px-4 py-3">
|
||||
<h4 className="mb-2 text-[11px] font-bold text-foreground">{title}</h4>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 통계 아이템 */
|
||||
function StatItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="text-[11px]">
|
||||
<span className="text-muted">{label}</span>
|
||||
<div className="font-medium text-foreground">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 헤더 선박 뱃지 */
|
||||
function VesselBadge({ vessel, track }: { vessel: StsVessel; track: TrackSegment }) {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
|
||||
{getShipKindLabel(track.shipKindCode)}
|
||||
</span>
|
||||
<span className="font-bold text-sm">{vessel.vesselName || vessel.vesselId || '-'}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** 선박 상세 섹션 */
|
||||
function VesselDetailSection({ label, vessel, track }: { label: string; vessel: StsVessel; track: TrackSegment }) {
|
||||
const rgba = getTrackColor(track.shipKindCode)
|
||||
return (
|
||||
<Section title={`${label} — ${vessel.vesselName || vessel.vesselId}`}>
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: `rgb(${rgba[0]},${rgba[1]},${rgba[2]})` }} />
|
||||
<span className="text-[10px] text-muted">{getShipKindLabel(track.shipKindCode)}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-[11px]">
|
||||
<div><span className="text-muted">구역 체류</span> <span className="font-medium">{formatDuration(vessel.insidePolygonDurationMinutes)}</span></div>
|
||||
<div><span className="text-muted">평균 속력</span> <span className="font-medium">{vessel.estimatedAvgSpeedKnots?.toFixed(1) ?? '-'} kn</span></div>
|
||||
<div><span className="text-muted">진입</span> <span className="font-medium">{formatTimestamp(vessel.insidePolygonStartTs)}</span></div>
|
||||
<div><span className="text-muted">퇴출</span> <span className="font-medium">{formatTimestamp(vessel.insidePolygonEndTs)}</span></div>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@ -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 }
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 토글 + 체크 */}
|
||||
{/* 토글 + 체크 + 보고서 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
@ -99,6 +100,12 @@ function GroupCard({ group, index }: { group: StsGroupedContact; index: number }
|
||||
>
|
||||
{isExpanded ? '접기 ▲' : '상세 ▼'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDetail(index)}
|
||||
className="ml-auto rounded px-1.5 py-0.5 text-[10px] text-primary hover:bg-primary/10"
|
||||
>
|
||||
보고서
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 확장 상세 */}
|
||||
@ -128,6 +135,7 @@ export default function StsContactList() {
|
||||
const groupedContacts = useStsStore((s) => s.groupedContacts)
|
||||
const highlightedGroupIndex = useStsStore((s) => s.highlightedGroupIndex)
|
||||
const listRef = useRef<HTMLUListElement>(null)
|
||||
const [detailGroupIndex, setDetailGroupIndex] = useState<number | null>(null)
|
||||
|
||||
// 하이라이트된 카드로 스크롤
|
||||
useEffect(() => {
|
||||
@ -142,10 +150,17 @@ export default function StsContactList() {
|
||||
}
|
||||
|
||||
return (
|
||||
<ul ref={listRef} className="max-h-56 space-y-1.5 overflow-y-auto">
|
||||
{groupedContacts.map((group, idx) => (
|
||||
<GroupCard key={group.pairKey} group={group} index={idx} />
|
||||
))}
|
||||
</ul>
|
||||
<>
|
||||
<ul ref={listRef} className="max-h-[40vh] space-y-1.5 overflow-y-auto">
|
||||
{groupedContacts.map((group, idx) => (
|
||||
<GroupCard key={group.pairKey} group={group} index={idx} onDetail={setDetailGroupIndex} />
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* STS 상세 모달 */}
|
||||
{detailGroupIndex !== null && (
|
||||
<StsContactDetailModal groupIndex={detailGroupIndex} onClose={() => setDetailGroupIndex(null)} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -31,7 +31,7 @@ export default function TimelineControl() {
|
||||
if (startTime === 0 && endTime === 0) return null
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-4 left-1/2 z-10 w-[560px] -translate-x-1/2 rounded-lg border border-border bg-surface/95 p-3 shadow-lg backdrop-blur">
|
||||
<div className="absolute bottom-4 left-1/2 z-20 w-[calc(100%-2rem)] max-w-[560px] -translate-x-1/2 rounded-lg border border-border bg-surface/95 p-3 shadow-lg backdrop-blur">
|
||||
{/* 타임라인 */}
|
||||
<div className="mb-2">
|
||||
<input
|
||||
|
||||
@ -0,0 +1,310 @@
|
||||
/**
|
||||
* 구역분석 선박 상세 모달 — 임베디드 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 { useAreaSearchStore } from '../stores/areaSearchStore'
|
||||
import { ZONE_COLORS } from '../types/areaSearch.types'
|
||||
import type { Zone, HitDetail } from '../types/areaSearch.types'
|
||||
import type { TrackSegment } from '../../vessel-map/types'
|
||||
import { getTrackColor, getShipKindLabel } from '../../vessel-map'
|
||||
import { formatTimestamp, formatPosition } 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<Polygon> {
|
||||
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<LineString> {
|
||||
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<Point>
|
||||
outPoints: FeatureCollection<Point>
|
||||
} {
|
||||
const inFeatures: Feature<Point>[] = []
|
||||
const outFeatures: Feature<Point>[] = []
|
||||
|
||||
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<HTMLDivElement>(null)
|
||||
const mapRef = useRef<maplibregl.Map | null>(null)
|
||||
const contentRef = useRef<HTMLDivElement>(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<string, Zone> = {}
|
||||
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(
|
||||
<div className="fixed inset-0 z-50 bg-black/40" onClick={onClose}>
|
||||
<div
|
||||
className="absolute flex max-h-[90vh] flex-col overflow-hidden rounded-lg border border-border bg-surface shadow-xl"
|
||||
style={{ left: position.x, top: position.y, width: MODAL_WIDTH }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 헤더 (드래그 핸들) */}
|
||||
<div
|
||||
className="flex cursor-move items-center justify-between border-b border-border px-4 py-2.5"
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">{kindLabel}</span>
|
||||
<span className="font-bold">{track.shipName || '-'}</span>
|
||||
<span className="font-mono text-[11px] text-muted">{track.vesselId}</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-lg text-muted hover:text-foreground">×</button>
|
||||
</div>
|
||||
|
||||
{/* 콘텐츠 (캡처 영역) */}
|
||||
<div className="flex-1 overflow-y-auto" ref={contentRef}>
|
||||
{/* 지도 */}
|
||||
<div ref={mapContainerRef} className="h-[300px] w-full" />
|
||||
|
||||
{/* 방문 이력 */}
|
||||
<div className="p-4">
|
||||
<h4 className="mb-2 text-xs font-bold text-foreground">방문 이력 (시간순)</h4>
|
||||
{sortedHits.length === 0 && (
|
||||
<p className="text-xs text-muted">방문 이력이 없습니다</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{sortedHits.map((hit, idx) => {
|
||||
const zone = zoneMap[hit.polygonId]
|
||||
const zoneColor = zone ? (ZONE_COLORS[zone.colorIndex]?.label || '#ffd43b') : '#adb5bd'
|
||||
const zoneName = zone ? `${zone.name}구역` : (hit.polygonName ? `${hit.polygonName}구역` : '구역')
|
||||
const visitLabel = hit.visitIndex > 1 || hits.filter((h) => h.polygonId === hit.polygonId).length > 1
|
||||
? `${hit.visitIndex}차`
|
||||
: ''
|
||||
const entryPos = formatPosition(hit.entryPosition)
|
||||
const exitPos = formatPosition(hit.exitPosition)
|
||||
|
||||
return (
|
||||
<div key={`${hit.polygonId}-${hit.visitIndex}-${idx}`} className="flex gap-2 text-[11px]">
|
||||
<span className="mt-0.5 font-mono text-muted">{idx + 1}.</span>
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: zoneColor }} />
|
||||
<span className="font-bold" style={{ color: zoneColor }}>{zoneName}</span>
|
||||
{visitLabel && <span className="text-[10px] text-muted">({visitLabel})</span>}
|
||||
</div>
|
||||
<div className="flex gap-2 text-foreground/80">
|
||||
<span className="font-mono text-[10px] font-bold text-green-500">{idx + 1}-IN</span>
|
||||
<span>{formatTimestamp(hit.entryTimestamp)}</span>
|
||||
{entryPos && <span className="text-[10px] text-muted">{entryPos}</span>}
|
||||
</div>
|
||||
<div className="flex gap-2 text-foreground/80">
|
||||
<span className="font-mono text-[10px] font-bold text-red-500">{idx + 1}-OUT</span>
|
||||
<span>{formatTimestamp(hit.exitTimestamp)}</span>
|
||||
{exitPos && <span className="text-[10px] text-muted">{exitPos}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="flex justify-end border-t border-border px-4 py-2">
|
||||
<button
|
||||
onClick={handleSaveImage}
|
||||
className="rounded-md bg-primary px-4 py-1.5 text-xs font-medium text-white hover:bg-primary/90"
|
||||
>
|
||||
이미지 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
58
frontend/src/features/area-search/utils/captureUtils.ts
Normal file
58
frontend/src/features/area-search/utils/captureUtils.ts
Normal file
@ -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<void> {
|
||||
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`
|
||||
}
|
||||
33
frontend/src/features/area-search/utils/formatUtils.ts
Normal file
33
frontend/src/features/area-search/utils/formatUtils.ts
Normal file
@ -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`
|
||||
}
|
||||
불러오는 중...
Reference in New Issue
Block a user