feat: UI 레이아웃 수정 + 구역분석/STS 보고서 모달 + 이미지 저장 #70

병합
htlee develop 에서 main 로 2 commits 를 머지했습니다 2026-02-20 18:46:55 +09:00
12개의 변경된 파일901개의 추가작업 그리고 29개의 파일을 삭제

파일 보기

@ -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">&#x2194;</span>
<VesselBadge vessel={vessel2} track={vessel2Track} />
</div>
<button onClick={onClose} className="text-lg text-muted hover:text-foreground">&times;</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">&times;</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
}
}

파일 보기

@ -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`
}

파일 보기

@ -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`
}